mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
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:
@ -256,6 +256,7 @@ export const EditTemplateForm = ({
|
||||
documentFlow={documentFlow.signers}
|
||||
recipients={recipients}
|
||||
fields={fields}
|
||||
templateDirectLink={template.directLink}
|
||||
onSubmit={onAddTemplatePlaceholderFormSubmit}
|
||||
isEnterprise={isEnterprise}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { LinkIcon } from 'lucide-react';
|
||||
|
||||
import type { Recipient, Template, TemplateDirectLink } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { TemplateDirectLinkDialog } from '../template-direct-link-dialog';
|
||||
|
||||
export type TemplatePageViewProps = {
|
||||
template: Template & { directLink?: TemplateDirectLink | null; Recipient: Recipient[] };
|
||||
};
|
||||
|
||||
export const TemplateDirectLinkDialogWrapper = ({ template }: TemplatePageViewProps) => {
|
||||
const [isTemplateDirectLinkOpen, setTemplateDirectLinkOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="px-3"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setTemplateDirectLinkOpen(true);
|
||||
}}
|
||||
>
|
||||
<LinkIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
{template.directLink ? 'Manage' : 'Create'} Direct Link
|
||||
</Button>
|
||||
|
||||
<TemplateDirectLinkDialog
|
||||
template={template}
|
||||
open={isTemplateDirectLinkOpen}
|
||||
onOpenChange={setTemplateDirectLinkOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -13,7 +13,9 @@ import type { Team } from '@documenso/prisma/client';
|
||||
|
||||
import { TemplateType } from '~/components/formatter/template-type';
|
||||
|
||||
import { TemplateDirectLinkBadge } from '../template-direct-link-badge';
|
||||
import { EditTemplateForm } from './edit-template';
|
||||
import { TemplateDirectLinkDialogWrapper } from './template-direct-link-dialog-wrapper';
|
||||
|
||||
export type TemplatePageViewProps = {
|
||||
params: {
|
||||
@ -50,17 +52,33 @@ export const TemplatePageView = async ({ params, team }: TemplatePageViewProps)
|
||||
|
||||
return (
|
||||
<div className="mx-auto -mt-4 max-w-screen-xl px-4 md:px-8">
|
||||
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
Templates
|
||||
</Link>
|
||||
<div className="flex flex-col justify-between sm:flex-row">
|
||||
<div>
|
||||
<Link href="/templates" className="flex items-center text-[#7AC455] hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
Templates
|
||||
</Link>
|
||||
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
||||
{template.title}
|
||||
</h1>
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
||||
{template.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex items-center gap-x-6">
|
||||
<TemplateType inheritColor type={template.type} className="text-muted-foreground" />
|
||||
<div className="mt-2.5 flex items-center">
|
||||
<TemplateType inheritColor className="text-muted-foreground" type={template.type} />
|
||||
|
||||
{template.directLink?.token && (
|
||||
<TemplateDirectLinkBadge
|
||||
className="ml-4"
|
||||
token={template.directLink.token}
|
||||
enabled={template.directLink.enabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 sm:mt-0 sm:self-end">
|
||||
<TemplateDirectLinkDialogWrapper template={template} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EditTemplateForm
|
||||
|
||||
@ -4,10 +4,10 @@ import { useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Copy, Edit, MoreHorizontal, Trash2 } from 'lucide-react';
|
||||
import { Copy, Edit, MoreHorizontal, Share2Icon, Trash2 } from 'lucide-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
|
||||
import type { Template } from '@documenso/prisma/client';
|
||||
import { type FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@ -18,9 +18,10 @@ import {
|
||||
|
||||
import { DeleteTemplateDialog } from './delete-template-dialog';
|
||||
import { DuplicateTemplateDialog } from './duplicate-template-dialog';
|
||||
import { TemplateDirectLinkDialog } from './template-direct-link-dialog';
|
||||
|
||||
export type DataTableActionDropdownProps = {
|
||||
row: Template;
|
||||
row: FindTemplateRow;
|
||||
templateRootPath: string;
|
||||
teamId?: number;
|
||||
};
|
||||
@ -33,6 +34,7 @@ export const DataTableActionDropdown = ({
|
||||
const { data: session } = useSession();
|
||||
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isTemplateDirectLinkDialogOpen, setTemplateDirectLinkDialogOpen] = useState(false);
|
||||
const [isDuplicateDialogOpen, setDuplicateDialogOpen] = useState(false);
|
||||
|
||||
if (!session) {
|
||||
@ -66,6 +68,11 @@ export const DataTableActionDropdown = ({
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={() => setTemplateDirectLinkDialogOpen(true)}>
|
||||
<Share2Icon className="mr-2 h-4 w-4" />
|
||||
Direct link
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
disabled={!isOwner && !isTeamTemplate}
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
@ -82,6 +89,12 @@ export const DataTableActionDropdown = ({
|
||||
onOpenChange={setDuplicateDialogOpen}
|
||||
/>
|
||||
|
||||
<TemplateDirectLinkDialog
|
||||
template={row}
|
||||
open={isTemplateDirectLinkDialogOpen}
|
||||
onOpenChange={setTemplateDirectLinkDialogOpen}
|
||||
/>
|
||||
|
||||
<DeleteTemplateDialog
|
||||
id={row.id}
|
||||
open={isDeleteDialogOpen}
|
||||
|
||||
@ -4,32 +4,26 @@ import { useTransition } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { AlertTriangle, Loader } from 'lucide-react';
|
||||
import { AlertTriangle, Globe2Icon, InfoIcon, Link2Icon, Loader, LockIcon } from 'lucide-react';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import type { Recipient, Template } from '@documenso/prisma/client';
|
||||
import type { FindTemplateRow } from '@documenso/lib/server-only/template/find-templates';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
|
||||
import { LocaleDate } from '~/components/formatter/locale-date';
|
||||
import { TemplateType } from '~/components/formatter/template-type';
|
||||
|
||||
import { DataTableActionDropdown } from './data-table-action-dropdown';
|
||||
import { DataTableTitle } from './data-table-title';
|
||||
import { TemplateDirectLinkBadge } from './template-direct-link-badge';
|
||||
import { UseTemplateDialog } from './use-template-dialog';
|
||||
|
||||
type TemplateWithRecipient = Template & {
|
||||
Recipient: Recipient[];
|
||||
};
|
||||
|
||||
type TemplatesDataTableProps = {
|
||||
templates: Array<
|
||||
TemplateWithRecipient & {
|
||||
team: { id: number; url: string } | null;
|
||||
}
|
||||
>;
|
||||
templates: FindTemplateRow[];
|
||||
perPage: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
@ -48,6 +42,7 @@ export const TemplatesDataTable = ({
|
||||
teamId,
|
||||
}: TemplatesDataTableProps) => {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const { remaining } = useLimits();
|
||||
@ -88,9 +83,70 @@ export const TemplatesDataTable = ({
|
||||
cell: ({ row }) => <DataTableTitle row={row.original} />,
|
||||
},
|
||||
{
|
||||
header: 'Type',
|
||||
header: () => (
|
||||
<div className="flex flex-row items-center">
|
||||
Type
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent className="text-foreground max-w-md space-y-2 !p-0">
|
||||
<ul className="text-muted-foreground space-y-0.5 divide-y [&>li]:p-4">
|
||||
<li>
|
||||
<h2 className="mb-2 flex flex-row items-center font-semibold">
|
||||
<Globe2Icon className="mr-2 h-5 w-5 text-green-500 dark:text-green-300" />
|
||||
Public
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
Public templates are connected to your public profile. Any modifications
|
||||
to public templates will also appear in your public profile.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<div className="mb-2 flex w-fit flex-row items-center rounded border border-neutral-300 bg-neutral-200 px-1.5 py-0.5 text-xs dark:border-neutral-500 dark:bg-neutral-600">
|
||||
<Link2Icon className="mr-1 h-3 w-3" />
|
||||
direct link
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Direct link templates contain one dynamic recipient placeholder. Anyone
|
||||
with access to this link can sign the document, and it will then appear on
|
||||
your documents page.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<h2 className="mb-2 flex flex-row items-center font-semibold">
|
||||
<LockIcon className="mr-2 h-5 w-5 text-blue-600 dark:text-blue-300" />
|
||||
{teamId ? 'Team Only' : 'Private'}
|
||||
</h2>
|
||||
|
||||
<p>
|
||||
{teamId
|
||||
? 'Team only templates are not linked anywhere and are visible only to your team.'
|
||||
: 'Private templates can only be modified and viewed by you.'}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
accessorKey: 'type',
|
||||
cell: ({ row }) => <TemplateType type={row.original.type} />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-row items-center">
|
||||
<TemplateType type="PRIVATE" />
|
||||
|
||||
{row.original.directLink?.token && (
|
||||
<TemplateDirectLinkBadge
|
||||
className="ml-2"
|
||||
token={row.original.directLink.token}
|
||||
enabled={row.original.directLink.enabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Actions',
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { Link2Icon } from 'lucide-react';
|
||||
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type TemplateDirectLinkBadgeProps = {
|
||||
token: string;
|
||||
enabled: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const TemplateDirectLinkBadge = ({
|
||||
token,
|
||||
enabled,
|
||||
className,
|
||||
}: TemplateDirectLinkBadgeProps) => {
|
||||
const [, copy] = useCopyToClipboard();
|
||||
const { toast } = useToast();
|
||||
|
||||
const onCopyClick = async (token: string) =>
|
||||
copy(formatDirectTemplatePath(token)).then(() => {
|
||||
toast({
|
||||
title: 'Copied to clipboard',
|
||||
description: 'The direct link has been copied to your clipboard',
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
title="Copy direct link"
|
||||
className={cn(
|
||||
'flex flex-row items-center rounded border border-neutral-300 bg-neutral-200 px-1.5 py-0.5 text-xs dark:border-neutral-500 dark:bg-neutral-600',
|
||||
className,
|
||||
)}
|
||||
onClick={async () => onCopyClick(token)}
|
||||
>
|
||||
<Link2Icon className="mr-1 h-3 w-3" />
|
||||
direct link {!enabled && 'disabled'}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,448 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { CircleDotIcon, CircleIcon, ClipboardCopyIcon, InfoIcon, LoaderIcon } from 'lucide-react';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import {
|
||||
DIRECT_TEMPLATE_DOCUMENTATION,
|
||||
DIRECT_TEMPLATE_RECIPIENT_EMAIL,
|
||||
} from '@documenso/lib/constants/template';
|
||||
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
|
||||
import {
|
||||
type Recipient,
|
||||
RecipientRole,
|
||||
type Template,
|
||||
type TemplateDirectLink,
|
||||
} from '@documenso/prisma/client';
|
||||
import { trpc as trpcReact } from '@documenso/trpc/react';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import { Alert, AlertDescription, AlertTitle } 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 { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@documenso/ui/primitives/table';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type TemplateDirectLinkDialogProps = {
|
||||
template: Template & {
|
||||
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
|
||||
Recipient: Recipient[];
|
||||
};
|
||||
open: boolean;
|
||||
onOpenChange: (_open: boolean) => void;
|
||||
};
|
||||
|
||||
type TemplateDirectLinkStep = 'ONBOARD' | 'SELECT_RECIPIENT' | 'MANAGE' | 'CONFIRM_DELETE';
|
||||
|
||||
export const TemplateDirectLinkDialog = ({
|
||||
template,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: TemplateDirectLinkDialogProps) => {
|
||||
const { toast } = useToast();
|
||||
const { quota, remaining } = useLimits();
|
||||
|
||||
const [, copy] = useCopyToClipboard();
|
||||
const router = useRouter();
|
||||
|
||||
const [isEnabled, setIsEnabled] = useState(template.directLink?.enabled ?? false);
|
||||
const [token, setToken] = useState(template.directLink?.token ?? null);
|
||||
const [selectedRecipientId, setSelectedRecipientId] = useState<number | null>(null);
|
||||
const [currentStep, setCurrentStep] = useState<TemplateDirectLinkStep>(
|
||||
token ? 'MANAGE' : 'ONBOARD',
|
||||
);
|
||||
|
||||
const validDirectTemplateRecipients = useMemo(
|
||||
() => template.Recipient.filter((recipient) => recipient.role !== RecipientRole.CC),
|
||||
[template.Recipient],
|
||||
);
|
||||
|
||||
const {
|
||||
mutateAsync: createTemplateDirectLink,
|
||||
isLoading: isCreatingTemplateDirectLink,
|
||||
reset: resetCreateTemplateDirectLink,
|
||||
} = trpcReact.template.createTemplateDirectLink.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setToken(data.token);
|
||||
setIsEnabled(data.enabled);
|
||||
setCurrentStep('MANAGE');
|
||||
|
||||
router.refresh();
|
||||
},
|
||||
onError: () => {
|
||||
setSelectedRecipientId(null);
|
||||
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'Unable to create direct template access. Please try again later.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: toggleTemplateDirectLink, isLoading: isTogglingTemplateAccess } =
|
||||
trpcReact.template.toggleTemplateDirectLink.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: `Direct link signing has been ${data.enabled ? 'enabled' : 'disabled'}`,
|
||||
});
|
||||
},
|
||||
onError: (_ctx, data) => {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: `An error occurred while ${
|
||||
data.enabled ? 'enabling' : 'disabling'
|
||||
} direct link signing.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: deleteTemplateDirectLink, isLoading: isDeletingTemplateDirectLink } =
|
||||
trpcReact.template.deleteTemplateDirectLink.useMutation({
|
||||
onSuccess: () => {
|
||||
onOpenChange(false);
|
||||
setToken(null);
|
||||
|
||||
toast({
|
||||
title: 'Success',
|
||||
description: 'Direct template link deleted',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
setToken(null);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description:
|
||||
'We encountered an error while removing the direct template link. Please try again later.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onCopyClick = async (token: string) =>
|
||||
copy(formatDirectTemplatePath(token)).then(() => {
|
||||
toast({
|
||||
title: 'Copied to clipboard',
|
||||
description: 'The direct link has been copied to your clipboard',
|
||||
});
|
||||
});
|
||||
|
||||
const onRecipientTableRowClick = async (recipientId: number) => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedRecipientId(recipientId);
|
||||
|
||||
await createTemplateDirectLink({
|
||||
templateId: template.id,
|
||||
directRecipientId: recipientId,
|
||||
});
|
||||
};
|
||||
|
||||
const isLoading =
|
||||
isCreatingTemplateDirectLink || isTogglingTemplateAccess || isDeletingTemplateDirectLink;
|
||||
|
||||
useEffect(() => {
|
||||
resetCreateTemplateDirectLink();
|
||||
setCurrentStep(token ? 'MANAGE' : 'ONBOARD');
|
||||
setSelectedRecipientId(null);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
||||
<fieldset disabled={isLoading} className="relative">
|
||||
<AnimateGenericFadeInOut motionKey={currentStep}>
|
||||
{match({ token, currentStep })
|
||||
.with({ token: P.nullish, currentStep: 'ONBOARD' }, () => (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Direct Signing Link</DialogTitle>
|
||||
|
||||
<DialogDescription>Here's how it works:</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ul className="mt-4 space-y-4 pl-12">
|
||||
{DIRECT_TEMPLATE_DOCUMENTATION.map((step, index) => (
|
||||
<li className="relative" key={index}>
|
||||
<div className="absolute -left-12">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full border-[3px] border-neutral-200 text-sm font-bold">
|
||||
{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold">{step.title}</h3>
|
||||
<p className="text-muted-foreground mt-1 text-sm">{step.description}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{remaining.directTemplates === 0 && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>
|
||||
Direct template link usage exceeded ({quota.directTemplates}/
|
||||
{quota.directTemplates})
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
You have reached the maximum limit of {quota.directTemplates} direct
|
||||
templates.{' '}
|
||||
<Link
|
||||
className="mt-1 block underline underline-offset-4"
|
||||
href="/settings/billing"
|
||||
>
|
||||
Upgrade your account to continue!
|
||||
</Link>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{remaining.directTemplates !== 0 && (
|
||||
<DialogFooter className="mx-auto mt-4">
|
||||
<Button type="button" onClick={() => setCurrentStep('SELECT_RECIPIENT')}>
|
||||
Enable direct link signing
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
))
|
||||
.with({ token: P.nullish, currentStep: 'SELECT_RECIPIENT' }, () => (
|
||||
<DialogContent className="relative">
|
||||
{isCreatingTemplateDirectLink && validDirectTemplateRecipients.length !== 0 && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center rounded bg-white/50 dark:bg-black/50">
|
||||
<LoaderIcon className="h-6 w-6 animate-spin text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogHeader>
|
||||
<DialogTitle>Choose Direct Link Recipient</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
Choose an existing recipient from below to continue
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="custom-scrollbar max-h-[60vh] overflow-y-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Recipient</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{validDirectTemplateRecipients.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-16 text-center">
|
||||
<p className="text-muted-foreground">No valid recipients found</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
{validDirectTemplateRecipients.map((row) => (
|
||||
<TableRow
|
||||
className="cursor-pointer"
|
||||
key={row.id}
|
||||
onClick={async () => onRecipientTableRowClick(row.id)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<p>{row.name}</p>
|
||||
<p className="text-muted-foreground/70 text-xs">{row.email}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{RECIPIENT_ROLES_DESCRIPTION[row.role].roleName}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{selectedRecipientId === row.id ? (
|
||||
<CircleDotIcon className="h-5 w-5 text-neutral-300" />
|
||||
) : (
|
||||
<CircleIcon className="h-5 w-5 text-neutral-300" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Prevent creating placeholder direct template recipient if the email already exists. */}
|
||||
{!template.Recipient.some(
|
||||
(recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL,
|
||||
) && (
|
||||
<DialogFooter className="mx-auto">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
{validDirectTemplateRecipients.length !== 0 && (
|
||||
<p className="text-muted-foreground text-sm">Or</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-2"
|
||||
loading={isCreatingTemplateDirectLink && !selectedRecipientId}
|
||||
onClick={async () =>
|
||||
createTemplateDirectLink({
|
||||
templateId: template.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
Create one automatically
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
))
|
||||
.with({ token: P.string, currentStep: 'MANAGE' }, ({ token }) => (
|
||||
<DialogContent className="relative">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Direct Link Signing</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
Manage the direct link signing for this template
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<Label className="flex flex-row">
|
||||
Enable Direct Link Signing
|
||||
<Tooltip>
|
||||
<TooltipTrigger tabIndex={-1} className="ml-2">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-foreground z-9999 max-w-md p-4">
|
||||
Disabling direct link signing will prevent anyone from accessing the link.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Label>
|
||||
|
||||
<Switch
|
||||
className="mt-2"
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(value) => setIsEnabled(value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<Label htmlFor="copy-direct-link">Copy Shareable Link</Label>
|
||||
|
||||
<div className="relative mt-1">
|
||||
<Input
|
||||
id="copy-direct-link"
|
||||
disabled
|
||||
value={formatDirectTemplatePath(token).replace(/https?:\/\//, '')}
|
||||
readOnly
|
||||
className="pr-12"
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-0 right-1 top-0 flex items-center justify-center">
|
||||
<Button
|
||||
variant="none"
|
||||
type="button"
|
||||
className="h-8 w-8"
|
||||
onClick={() => void onCopyClick(token)}
|
||||
>
|
||||
<ClipboardCopyIcon className="h-4 w-4 flex-shrink-0" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className='mt-4'>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="mr-auto w-full sm:w-auto"
|
||||
loading={isDeletingTemplateDirectLink}
|
||||
onClick={() => setCurrentStep('CONFIRM_DELETE')}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
loading={isTogglingTemplateAccess}
|
||||
onClick={async () =>
|
||||
toggleTemplateDirectLink({
|
||||
templateId: template.id,
|
||||
enabled: isEnabled,
|
||||
})
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
))
|
||||
.with({ token: P.string, currentStep: 'CONFIRM_DELETE' }, () => (
|
||||
<DialogContent className="relative">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
Please note that proceeding will remove direct linking recipient and turn it
|
||||
into a placeholder.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setCurrentStep('MANAGE')}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={isDeletingTemplateDirectLink}
|
||||
onClick={() => void deleteTemplateDirectLink({ templateId: template.id })}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
</AnimateGenericFadeInOut>
|
||||
</fieldset>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -172,7 +172,7 @@ export function UseTemplateDialog({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="cursor-pointer">
|
||||
<Button variant="outline" className="bg-background">
|
||||
<Plus className="-ml-1 mr-2 h-4 w-4" />
|
||||
Use Template
|
||||
</Button>
|
||||
|
||||
@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { Field, Recipient } from '@documenso/prisma/client';
|
||||
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
|
||||
import {
|
||||
DocumentFlowFormContainerActions,
|
||||
DocumentFlowFormContainerContent,
|
||||
DocumentFlowFormContainerFooter,
|
||||
DocumentFlowFormContainerHeader,
|
||||
DocumentFlowFormContainerStep,
|
||||
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||
import { ShowFieldItem } from '@documenso/ui/primitives/document-flow/show-field-item';
|
||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useStep } from '@documenso/ui/primitives/stepper';
|
||||
|
||||
import { useRequiredDocumentAuthContext } from '~/app/(signing)/sign/[token]/document-auth-provider';
|
||||
|
||||
const ZConfigureDirectTemplateFormSchema = z.object({
|
||||
email: z.string().email('Email is invalid'),
|
||||
});
|
||||
|
||||
export type TConfigureDirectTemplateFormSchema = z.infer<typeof ZConfigureDirectTemplateFormSchema>;
|
||||
|
||||
export type ConfigureDirectTemplateFormProps = {
|
||||
flowStep: DocumentFlowStep;
|
||||
isDocumentPdfLoaded: boolean;
|
||||
template: TemplateWithDetails;
|
||||
directTemplateRecipient: Recipient & { Field: Field[] };
|
||||
initialEmail?: string;
|
||||
onSubmit: (_data: TConfigureDirectTemplateFormSchema) => void;
|
||||
};
|
||||
|
||||
export const ConfigureDirectTemplateFormPartial = ({
|
||||
flowStep,
|
||||
isDocumentPdfLoaded,
|
||||
template,
|
||||
directTemplateRecipient,
|
||||
initialEmail,
|
||||
onSubmit,
|
||||
}: ConfigureDirectTemplateFormProps) => {
|
||||
const { Recipient } = template;
|
||||
const { derivedRecipientAccessAuth } = useRequiredDocumentAuthContext();
|
||||
const { data: session } = useSession();
|
||||
|
||||
const recipientsWithBlankDirectRecipientEmail = Recipient.map((recipient) => {
|
||||
if (recipient.id === directTemplateRecipient.id) {
|
||||
return {
|
||||
...recipient,
|
||||
email: '',
|
||||
};
|
||||
}
|
||||
|
||||
return recipient;
|
||||
});
|
||||
|
||||
const form = useForm<TConfigureDirectTemplateFormSchema>({
|
||||
resolver: zodResolver(
|
||||
ZConfigureDirectTemplateFormSchema.superRefine((items, ctx) => {
|
||||
if (template.Recipient.map((recipient) => recipient.email).includes(items.email)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Email cannot already exist in the template',
|
||||
path: ['email'],
|
||||
});
|
||||
}
|
||||
}),
|
||||
),
|
||||
defaultValues: {
|
||||
email: initialEmail || '',
|
||||
},
|
||||
});
|
||||
|
||||
const { stepIndex, currentStep, totalSteps, previousStep } = useStep();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
|
||||
|
||||
<DocumentFlowFormContainerContent>
|
||||
{isDocumentPdfLoaded &&
|
||||
directTemplateRecipient.Field.map((field, index) => (
|
||||
<ShowFieldItem
|
||||
key={index}
|
||||
field={field}
|
||||
recipients={recipientsWithBlankDirectRecipientEmail}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Form {...form}>
|
||||
<fieldset
|
||||
className="flex h-full flex-col space-y-6"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>Email</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={
|
||||
field.disabled ||
|
||||
derivedRecipientAccessAuth !== null ||
|
||||
session?.user.email !== undefined
|
||||
}
|
||||
placeholder="recipient@documenso.com"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{!fieldState.error && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Enter your email address to receive the completed document.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
</Form>
|
||||
</DocumentFlowFormContainerContent>
|
||||
|
||||
<DocumentFlowFormContainerFooter>
|
||||
<DocumentFlowFormContainerStep
|
||||
title={flowStep.title}
|
||||
step={currentStep}
|
||||
maxStep={totalSteps}
|
||||
/>
|
||||
|
||||
<DocumentFlowFormContainerActions
|
||||
loading={form.formState.isSubmitting}
|
||||
disabled={form.formState.isSubmitting}
|
||||
canGoBack={stepIndex !== 0}
|
||||
onGoBackClick={previousStep}
|
||||
onGoNextClick={form.handleSubmit(onSubmit)}
|
||||
/>
|
||||
</DocumentFlowFormContainerFooter>
|
||||
</>
|
||||
);
|
||||
};
|
||||
156
apps/web/src/app/(recipient)/d/[token]/direct-template.tsx
Normal file
156
apps/web/src/app/(recipient)/d/[token]/direct-template.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type { Field } from '@documenso/prisma/client';
|
||||
import { type Recipient } from '@documenso/prisma/client';
|
||||
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
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';
|
||||
import { Stepper } from '@documenso/ui/primitives/stepper';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useRequiredDocumentAuthContext } from '~/app/(signing)/sign/[token]/document-auth-provider';
|
||||
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
|
||||
|
||||
import type { TConfigureDirectTemplateFormSchema } from './configure-direct-template';
|
||||
import { ConfigureDirectTemplateFormPartial } from './configure-direct-template';
|
||||
import type { DirectTemplateLocalField } from './sign-direct-template';
|
||||
import { SignDirectTemplateForm } from './sign-direct-template';
|
||||
|
||||
export type TemplatesDirectPageViewProps = {
|
||||
template: TemplateWithDetails;
|
||||
directTemplateToken: string;
|
||||
directTemplateRecipient: Recipient & { Field: Field[] };
|
||||
};
|
||||
|
||||
type DirectTemplateStep = 'configure' | 'sign';
|
||||
const DirectTemplateSteps: DirectTemplateStep[] = ['configure', 'sign'];
|
||||
|
||||
export const DirectTemplatePageView = ({
|
||||
template,
|
||||
directTemplateRecipient,
|
||||
directTemplateToken,
|
||||
}: TemplatesDirectPageViewProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const { email, setEmail } = useRequiredSigningContext();
|
||||
const { recipient, setRecipient } = useRequiredDocumentAuthContext();
|
||||
|
||||
const [step, setStep] = useState<DirectTemplateStep>('configure');
|
||||
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
|
||||
|
||||
const directTemplateFlow: Record<DirectTemplateStep, DocumentFlowStep> = {
|
||||
configure: {
|
||||
title: 'General',
|
||||
description: 'Preview and configure template.',
|
||||
stepIndex: 1,
|
||||
},
|
||||
sign: {
|
||||
title: 'Sign document',
|
||||
description: 'Sign the document to complete the process.',
|
||||
stepIndex: 2,
|
||||
},
|
||||
};
|
||||
|
||||
const { mutateAsync: createDocumentFromDirectTemplate } =
|
||||
trpc.template.createDocumentFromDirectTemplate.useMutation();
|
||||
|
||||
/**
|
||||
* Set the email into a temporary recipient so it can be used for reauth and signing email fields.
|
||||
*/
|
||||
const onConfigureDirectTemplateSubmit = ({ email }: TConfigureDirectTemplateFormSchema) => {
|
||||
setEmail(email);
|
||||
|
||||
setRecipient({
|
||||
...recipient,
|
||||
email,
|
||||
});
|
||||
|
||||
setStep('sign');
|
||||
};
|
||||
|
||||
const onSignDirectTemplateSubmit = async (fields: DirectTemplateLocalField[]) => {
|
||||
try {
|
||||
const token = await createDocumentFromDirectTemplate({
|
||||
directTemplateToken,
|
||||
directRecipientEmail: recipient.email,
|
||||
templateUpdatedAt: template.updatedAt,
|
||||
signedFieldValues: fields.map((field) => {
|
||||
if (!field.signedValue) {
|
||||
throw new Error('Invalid configuration');
|
||||
}
|
||||
|
||||
return field.signedValue;
|
||||
}),
|
||||
});
|
||||
|
||||
const redirectUrl = template.templateMeta?.redirectUrl;
|
||||
|
||||
redirectUrl ? router.push(redirectUrl) : router.push(`/sign/${token}/complete`);
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Something went wrong',
|
||||
description: 'We were unable to submit this document at this time. Please try again later.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const currentDocumentFlow = directTemplateFlow[step];
|
||||
|
||||
return (
|
||||
<div className="grid w-full grid-cols-12 gap-8">
|
||||
<Card
|
||||
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<LazyPDFViewer
|
||||
key={template.id}
|
||||
documentData={template.templateDocumentData}
|
||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
|
||||
<DocumentFlowFormContainer
|
||||
className="lg:h-[calc(100vh-6rem)]"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
>
|
||||
<Stepper
|
||||
currentStep={currentDocumentFlow.stepIndex}
|
||||
setCurrentStep={(step) => setStep(DirectTemplateSteps[step - 1])}
|
||||
>
|
||||
<ConfigureDirectTemplateFormPartial
|
||||
flowStep={directTemplateFlow.configure}
|
||||
template={template}
|
||||
directTemplateRecipient={directTemplateRecipient}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
onSubmit={onConfigureDirectTemplateSubmit}
|
||||
initialEmail={email}
|
||||
/>
|
||||
|
||||
<SignDirectTemplateForm
|
||||
flowStep={directTemplateFlow.sign}
|
||||
directRecipient={recipient}
|
||||
directRecipientFields={directTemplateRecipient.Field}
|
||||
template={template}
|
||||
onSubmit={onSignDirectTemplateSubmit}
|
||||
/>
|
||||
</Stepper>
|
||||
</DocumentFlowFormContainer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
33
apps/web/src/app/(recipient)/d/[token]/not-found.tsx
Normal file
33
apps/web/src/app/(recipient)/d/[token]/not-found.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[80vh] w-full items-center justify-center py-32">
|
||||
<div>
|
||||
<p className="text-muted-foreground font-semibold">404 Template not found</p>
|
||||
|
||||
<h1 className="mt-3 text-2xl font-bold md:text-3xl">Oops! Something went wrong.</h1>
|
||||
|
||||
<p className="text-muted-foreground mt-4 text-sm">
|
||||
The template you are looking for may have been disabled, deleted or may have never
|
||||
existed.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex gap-x-2.5 gap-y-4 md:items-center">
|
||||
<Button asChild className="w-32">
|
||||
<Link href="/">
|
||||
<ChevronLeft className="mr-2 h-4 w-4" />
|
||||
Go Back
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
apps/web/src/app/(recipient)/d/[token]/page.tsx
Normal file
92
apps/web/src/app/(recipient)/d/[token]/page.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
|
||||
import { UsersIcon } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
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 { truncateTitle } from '~/helpers/truncate-title';
|
||||
|
||||
import { DirectTemplatePageView } from './direct-template';
|
||||
import { DirectTemplateAuthPageView } from './signing-auth-page';
|
||||
|
||||
export type TemplatesDirectPageProps = {
|
||||
params: {
|
||||
token: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default async function TemplatesDirectPage({ params }: TemplatesDirectPageProps) {
|
||||
const { token } = params;
|
||||
|
||||
if (!token) {
|
||||
redirect('/');
|
||||
}
|
||||
|
||||
const { user } = await getServerComponentSession();
|
||||
|
||||
const template = await getTemplateByDirectLinkToken({
|
||||
token,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!template || !template.directLink?.enabled) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const directTemplateRecipient = template.Recipient.find(
|
||||
(recipient) => recipient.id === template.directLink?.directTemplateRecipientId,
|
||||
);
|
||||
|
||||
if (!directTemplateRecipient) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: template.authOptions,
|
||||
});
|
||||
|
||||
// Ensure typesafety when we add more options.
|
||||
const isAccessAuthValid = match(derivedRecipientAccessAuth)
|
||||
.with(DocumentAccessAuth.ACCOUNT, () => user !== null)
|
||||
.with(null, () => true)
|
||||
.exhaustive();
|
||||
|
||||
if (!isAccessAuthValid) {
|
||||
return <DirectTemplateAuthPageView />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SigningProvider email={user?.email} fullName={user?.name} signature={user?.signature}>
|
||||
<DocumentAuthProvider
|
||||
documentAuthOptions={template.authOptions}
|
||||
recipient={directTemplateRecipient}
|
||||
user={user}
|
||||
>
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<h1 className="mt-4 truncate text-2xl font-semibold md:text-3xl" title={template.title}>
|
||||
{truncateTitle(template.title)}
|
||||
</h1>
|
||||
|
||||
<div className="text-muted-foreground mb-8 mt-2.5 flex items-center gap-x-2">
|
||||
<UsersIcon className="h-4 w-4" />
|
||||
<p className="text-muted-foreground/80">
|
||||
{template.Recipient.length}{' '}
|
||||
{template.Recipient.length > 1 ? 'recipients' : 'recipient'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DirectTemplatePageView
|
||||
directTemplateRecipient={directTemplateRecipient}
|
||||
directTemplateToken={template.directLink.token}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
</DocumentAuthProvider>
|
||||
</SigningProvider>
|
||||
);
|
||||
}
|
||||
278
apps/web/src/app/(recipient)/d/[token]/sign-direct-template.tsx
Normal file
278
apps/web/src/app/(recipient)/d/[token]/sign-direct-template.tsx
Normal file
@ -0,0 +1,278 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
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 { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
||||
import type { Field, Recipient, Signature } from '@documenso/prisma/client';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
import type { TemplateWithDetails } from '@documenso/prisma/types/template';
|
||||
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 {
|
||||
DocumentFlowFormContainerContent,
|
||||
DocumentFlowFormContainerFooter,
|
||||
DocumentFlowFormContainerHeader,
|
||||
DocumentFlowFormContainerStep,
|
||||
} from '@documenso/ui/primitives/document-flow/document-flow-root';
|
||||
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
import { useStep } from '@documenso/ui/primitives/stepper';
|
||||
|
||||
import { DateField } from '~/app/(signing)/sign/[token]/date-field';
|
||||
import { EmailField } from '~/app/(signing)/sign/[token]/email-field';
|
||||
import { NameField } from '~/app/(signing)/sign/[token]/name-field';
|
||||
import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider';
|
||||
import { SignDialog } from '~/app/(signing)/sign/[token]/sign-dialog';
|
||||
import { SignatureField } from '~/app/(signing)/sign/[token]/signature-field';
|
||||
import { TextField } from '~/app/(signing)/sign/[token]/text-field';
|
||||
|
||||
export type SignDirectTemplateFormProps = {
|
||||
flowStep: DocumentFlowStep;
|
||||
directRecipient: Recipient;
|
||||
directRecipientFields: Field[];
|
||||
template: TemplateWithDetails;
|
||||
onSubmit: (_data: DirectTemplateLocalField[]) => Promise<void>;
|
||||
};
|
||||
|
||||
export type DirectTemplateLocalField = Field & {
|
||||
signedValue?: TSignFieldWithTokenMutationSchema;
|
||||
Signature?: Signature;
|
||||
};
|
||||
|
||||
export const SignDirectTemplateForm = ({
|
||||
flowStep,
|
||||
directRecipient,
|
||||
directRecipientFields,
|
||||
template,
|
||||
onSubmit,
|
||||
}: SignDirectTemplateFormProps) => {
|
||||
const { fullName, signature, setFullName, setSignature } = useRequiredSigningContext();
|
||||
|
||||
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(directRecipientFields);
|
||||
const [validateUninsertedFields, setValidateUninsertedFields] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const { currentStep, totalSteps, previousStep } = useStep();
|
||||
|
||||
const onSignField = (value: TSignFieldWithTokenMutationSchema) => {
|
||||
setLocalFields(
|
||||
localFields.map((field) => {
|
||||
if (field.id !== value.fieldId) {
|
||||
return field;
|
||||
}
|
||||
|
||||
const tempField: DirectTemplateLocalField = {
|
||||
...field,
|
||||
customText: value.value,
|
||||
inserted: true,
|
||||
signedValue: value,
|
||||
};
|
||||
|
||||
if (field.type === FieldType.SIGNATURE) {
|
||||
tempField.Signature = {
|
||||
id: 1,
|
||||
created: new Date(),
|
||||
recipientId: 1,
|
||||
fieldId: 1,
|
||||
signatureImageAsBase64: value.value,
|
||||
typedSignature: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (field.type === FieldType.DATE) {
|
||||
tempField.customText = DateTime.now()
|
||||
.setZone(template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE)
|
||||
.toFormat(template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT);
|
||||
}
|
||||
return tempField;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onUnsignField = (value: TRemovedSignedFieldWithTokenMutationSchema) => {
|
||||
setLocalFields(
|
||||
localFields.map((field) => {
|
||||
if (field.id !== value.fieldId) {
|
||||
return field;
|
||||
}
|
||||
|
||||
return {
|
||||
...field,
|
||||
customText: '',
|
||||
inserted: false,
|
||||
signedValue: undefined,
|
||||
Signature: undefined,
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const uninsertedFields = useMemo(() => {
|
||||
return sortFieldsByPosition(localFields.filter((field) => !field.inserted));
|
||||
}, [localFields]);
|
||||
|
||||
const fieldsValidated = () => {
|
||||
setValidateUninsertedFields(true);
|
||||
validateFieldsInserted(localFields);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setValidateUninsertedFields(true);
|
||||
|
||||
const isFieldsValid = validateFieldsInserted(localFields);
|
||||
|
||||
if (!isFieldsValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await onSubmit(localFields);
|
||||
} catch {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
||||
// Do not reset to false since we do a redirect.
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentFlowFormContainerHeader title={flowStep.title} description={flowStep.description} />
|
||||
|
||||
<DocumentFlowFormContainerContent>
|
||||
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
|
||||
{validateUninsertedFields && uninsertedFields[0] && (
|
||||
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
|
||||
Click to insert field
|
||||
</FieldToolTip>
|
||||
)}
|
||||
|
||||
{localFields.map((field) =>
|
||||
match(field.type)
|
||||
.with(FieldType.SIGNATURE, () => (
|
||||
<SignatureField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.NAME, () => (
|
||||
<NameField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.DATE, () => (
|
||||
<DateField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={directRecipient}
|
||||
dateFormat={template.templateMeta?.dateFormat ?? DEFAULT_DOCUMENT_DATE_FORMAT}
|
||||
timezone={template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.EMAIL, () => (
|
||||
<EmailField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.TEXT, () => (
|
||||
<TextField
|
||||
key={field.id}
|
||||
field={field}
|
||||
recipient={directRecipient}
|
||||
onSignField={onSignField}
|
||||
onUnsignField={onUnsignField}
|
||||
/>
|
||||
))
|
||||
.otherwise(() => null),
|
||||
)}
|
||||
</ElementVisible>
|
||||
|
||||
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
||||
<div className="flex flex-1 flex-col gap-y-4">
|
||||
<div>
|
||||
<Label htmlFor="full-name">Full Name</Label>
|
||||
|
||||
<Input
|
||||
id="full-name"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value.trimStart())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Signature">Signature</Label>
|
||||
|
||||
<Card className="mt-2" gradient degrees={-120}>
|
||||
<CardContent className="p-0">
|
||||
<SignaturePad
|
||||
className="h-44 w-full"
|
||||
disabled={isSubmitting}
|
||||
defaultValue={signature ?? undefined}
|
||||
onChange={(value) => {
|
||||
setSignature(value);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentFlowFormContainerContent>
|
||||
|
||||
<DocumentFlowFormContainerFooter>
|
||||
<DocumentFlowFormContainerStep
|
||||
title={flowStep.title}
|
||||
step={currentStep}
|
||||
maxStep={totalSteps}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex gap-x-4">
|
||||
<Button
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
disabled={isSubmitting}
|
||||
onClick={previousStep}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<SignDialog
|
||||
isSubmitting={isSubmitting}
|
||||
onSignatureComplete={handleSubmit}
|
||||
documentTitle={template.title}
|
||||
fields={localFields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
role={directRecipient.role}
|
||||
/>
|
||||
</div>
|
||||
</DocumentFlowFormContainerFooter>
|
||||
</>
|
||||
);
|
||||
};
|
||||
54
apps/web/src/app/(recipient)/d/[token]/signing-auth-page.tsx
Normal file
54
apps/web/src/app/(recipient)/d/[token]/signing-auth-page.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { signOut } from 'next-auth/react';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export const DirectTemplateAuthPageView = () => {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [isSigningOut, setIsSigningOut] = useState(false);
|
||||
|
||||
const handleChangeAccount = async () => {
|
||||
try {
|
||||
setIsSigningOut(true);
|
||||
|
||||
await signOut({
|
||||
callbackUrl: '/signin',
|
||||
});
|
||||
} 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 to view this page.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
className="mt-4 w-full"
|
||||
type="submit"
|
||||
onClick={async () => handleChangeAccount()}
|
||||
loading={isSigningOut}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
38
apps/web/src/app/(recipient)/layout.tsx
Normal file
38
apps/web/src/app/(recipient)/layout.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
|
||||
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import type { GetTeamsResponse } from '@documenso/lib/server-only/team/get-teams';
|
||||
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
||||
|
||||
import { Header as AuthenticatedHeader } from '~/components/(dashboard)/layout/header';
|
||||
import { NextAuthProvider } from '~/providers/next-auth';
|
||||
|
||||
type RecipientLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* A layout to handle scenarios where the user is a recipient of a given resource
|
||||
* where we do not care whether they are authenticated or not.
|
||||
*
|
||||
* Such as direct template access, or signing.
|
||||
*/
|
||||
export default async function RecipientLayout({ children }: RecipientLayoutProps) {
|
||||
const { user, session } = await getServerComponentSession();
|
||||
|
||||
let teams: GetTeamsResponse = [];
|
||||
|
||||
if (user && session) {
|
||||
teams = await getTeams({ userId: user.id });
|
||||
}
|
||||
|
||||
return (
|
||||
<NextAuthProvider session={session}>
|
||||
<div className="min-h-screen">
|
||||
{user && <AuthenticatedHeader user={user} teams={teams} />}
|
||||
|
||||
<main className="mb-8 mt-8 px-4 md:mb-12 md:mt-12 md:px-8">{children}</main>
|
||||
</div>
|
||||
</NextAuthProvider>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { Globe, Lock } from 'lucide-react';
|
||||
import { Globe2, Lock } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react/dist/lucide-react';
|
||||
|
||||
import type { TemplateType as TemplateTypePrisma } from '@documenso/prisma/client';
|
||||
@ -22,7 +22,7 @@ const TEMPLATE_TYPES: Record<TemplateTypes, TemplateTypeIcon> = {
|
||||
},
|
||||
PUBLIC: {
|
||||
label: 'Public',
|
||||
icon: Globe,
|
||||
icon: Globe2,
|
||||
color: 'text-green-500 dark:text-green-300',
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user