feat: add recipient roles (#716)

Fixes #705

---------

Co-authored-by: Lucas Smith <me@lucasjamessmith.me>
Co-authored-by: David Nguyen <davidngu28@gmail.com>
This commit is contained in:
Hani
2024-02-01 18:45:02 -05:00
committed by GitHub
parent e42088a5bf
commit 7ece6ef239
28 changed files with 466 additions and 156 deletions

View File

@ -109,7 +109,7 @@ It's similar to the Kanban board for the development backlog.
While the internal design backlog also existed in Linear, the public design repository is new. Since designing in the open is tricky, we opted to publish the detailed design artifacts with the corresponding feature instead. While the internal design backlog also existed in Linear, the public design repository is new. Since designing in the open is tricky, we opted to publish the detailed design artifacts with the corresponding feature instead.
We already have design.documenso.com housing our general design system. Here, we will publish the specifics of how we applied this to each feature. We will publish the first artifacts here soon, what may be in the cards can be found on the [LIVE Roadmap](https://documen.so/live). We already have design.documenso.com housing our general design system. Here, we will publish the specifics of how we applied this to each feature. We will publish the first artifacts here soon, what may be in the cards can be found on the [LIVE Roadmap](https://documen.so/live).
Feel free to connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here to help and would love to hear from you :) Feel free to connect with us on [Twitter / X](https://twitter.com/eltimuro) (DM open) or [Discord](https://documen.so/discord) if you have any questions or comments! We're always here to help and would love to hear from you :)
Best from Hamburg\ Best from Hamburg\
Timur Timur

View File

@ -158,6 +158,7 @@ export const SinglePlayerClient = () => {
readStatus: 'OPENED', readStatus: 'OPENED',
signingStatus: 'NOT_SIGNED', signingStatus: 'NOT_SIGNED',
sendStatus: 'NOT_SENT', sendStatus: 'NOT_SENT',
role: 'SIGNER',
}; };
const onFileDrop = async (file: File) => { const onFileDrop = async (file: File) => {

View File

@ -2,13 +2,13 @@
import Link from 'next/link'; import Link from 'next/link';
import { Download, Edit, Pencil } from 'lucide-react'; import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import type { Document, Recipient, User } from '@documenso/prisma/client'; import type { Document, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc as trpcClient } from '@documenso/trpc/client'; import { trpc as trpcClient } from '@documenso/trpc/client';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@ -37,6 +37,7 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
const isPending = row.status === DocumentStatus.PENDING; const isPending = row.status === DocumentStatus.PENDING;
const isComplete = row.status === DocumentStatus.COMPLETED; const isComplete = row.status === DocumentStatus.COMPLETED;
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED; const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const role = recipient?.role;
const onDownloadClick = async () => { const onDownloadClick = async () => {
try { try {
@ -68,6 +69,11 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
} }
}; };
// TODO: Consider if want to keep this logic for hiding viewing for CC'ers
if (recipient?.role === RecipientRole.CC && isComplete === false) {
return null;
}
return match({ return match({
isOwner, isOwner,
isRecipient, isRecipient,
@ -87,15 +93,32 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
.with({ isRecipient: true, isPending: true, isSigned: false }, () => ( .with({ isRecipient: true, isPending: true, isSigned: false }, () => (
<Button className="w-32" asChild> <Button className="w-32" asChild>
<Link href={`/sign/${recipient?.token}`}> <Link href={`/sign/${recipient?.token}`}>
<Pencil className="-ml-1 mr-2 h-4 w-4" /> {match(role)
Sign .with(RecipientRole.SIGNER, () => (
<>
<Pencil className="-ml-1 mr-2 h-4 w-4" />
Sign
</>
))
.with(RecipientRole.APPROVER, () => (
<>
<CheckCircle className="-ml-1 mr-2 h-4 w-4" />
Approve
</>
))
.otherwise(() => (
<>
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
View
</>
))}
</Link> </Link>
</Button> </Button>
)) ))
.with({ isPending: true, isSigned: true }, () => ( .with({ isPending: true, isSigned: true }, () => (
<Button className="w-32" disabled={true}> <Button className="w-32" disabled={true}>
<Pencil className="-ml-1 mr-2 inline h-4 w-4" /> <EyeIcon className="-ml-1 mr-2 h-4 w-4" />
Sign View
</Button> </Button>
)) ))
.with({ isComplete: true }, () => ( .with({ isComplete: true }, () => (

View File

@ -5,9 +5,11 @@ import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { import {
CheckCircle,
Copy, Copy,
Download, Download,
Edit, Edit,
EyeIcon,
Loader, Loader,
MoreHorizontal, MoreHorizontal,
Pencil, Pencil,
@ -19,7 +21,7 @@ import { useSession } from 'next-auth/react';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf'; import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import type { Document, Recipient, User } from '@documenso/prisma/client'; import type { Document, Recipient, User } from '@documenso/prisma/client';
import { DocumentStatus } from '@documenso/prisma/client'; import { DocumentStatus, RecipientRole } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { trpc as trpcClient } from '@documenso/trpc/client'; import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
@ -105,12 +107,32 @@ export const DataTableActionDropdown = ({ row }: DataTableActionDropdownProps) =
<DropdownMenuContent className="w-52" align="start" forceMount> <DropdownMenuContent className="w-52" align="start" forceMount>
<DropdownMenuLabel>Action</DropdownMenuLabel> <DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuItem disabled={!recipient || isComplete} asChild> {recipient?.role !== RecipientRole.CC && (
<Link href={`/sign/${recipient?.token}`}> <DropdownMenuItem disabled={!recipient || isComplete} asChild>
<Pencil className="mr-2 h-4 w-4" /> <Link href={`/sign/${recipient?.token}`}>
Sign {recipient?.role === RecipientRole.VIEWER && (
</Link> <>
</DropdownMenuItem> <EyeIcon className="mr-2 h-4 w-4" />
View
</>
)}
{recipient?.role === RecipientRole.SIGNER && (
<>
<Pencil className="mr-2 h-4 w-4" />
Sign
</>
)}
{recipient?.role === RecipientRole.APPROVER && (
<>
<CheckCircle className="mr-2 h-4 w-4" />
Approve
</>
)}
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem disabled={!isOwner || isComplete} asChild> <DropdownMenuItem disabled={!isOwner || isComplete} asChild>
<Link href={`/documents/${row.id}`}> <Link href={`/documents/${row.id}`}>

View File

@ -10,7 +10,7 @@ import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures'; import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { DocumentStatus, FieldType } from '@documenso/prisma/client'; import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client';
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button'; import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button'; import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { SigningCard3D } from '@documenso/ui/components/signing-card'; import { SigningCard3D } from '@documenso/ui/components/signing-card';
@ -94,7 +94,10 @@ export default async function CompletedSigningPage({
))} ))}
<h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl"> <h2 className="mt-6 max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
You have signed You have
{recipient.role === RecipientRole.SIGNER && ' signed '}
{recipient.role === RecipientRole.VIEWER && ' viewed '}
{recipient.role === RecipientRole.APPROVER && ' approved '}
<span className="mt-1.5 block">"{truncatedTitle}"</span> <span className="mt-1.5 block">"{truncatedTitle}"</span>
</h2> </h2>

View File

@ -9,7 +9,7 @@ import { useForm } from 'react-hook-form';
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import type { Document, Field, Recipient } from '@documenso/prisma/client'; import { type Document, type Field, type Recipient, RecipientRole } from '@documenso/prisma/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@ -96,74 +96,114 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
<fieldset <fieldset
disabled={isSubmitting} disabled={isSubmitting}
className={cn('-mx-2 flex flex-1 flex-col overflow-hidden px-2')} className={cn(
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
)}
> >
<div <div className={cn('flex flex-1 flex-col')}>
className={cn( <h3 className="text-foreground text-2xl font-semibold">
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2', {recipient.role === RecipientRole.VIEWER && 'View Document'}
)} {recipient.role === RecipientRole.SIGNER && 'Sign Document'}
> {recipient.role === RecipientRole.APPROVER && 'Approve Document'}
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3> </h3>
<p className="text-muted-foreground mt-2 text-sm"> {recipient.role === RecipientRole.VIEWER ? (
Please review the document before signing. <>
</p> <p className="text-muted-foreground mt-2 text-sm">
Please mark as viewed to complete
</p>
<hr className="border-border mb-8 mt-4" /> <hr className="border-border mb-8 mt-4" />
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2"> <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 className="flex flex-1 flex-col gap-y-4" />
<div> <div className="flex flex-col gap-4 md:flex-row">
<Label htmlFor="full-name">Full Name</Label> <Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={() => router.back()}
>
Cancel
</Button>
<Input <SignDialog
type="text" isSubmitting={isSubmitting}
id="full-name" onSignatureComplete={handleSubmit(onFormSubmit)}
className="bg-background mt-2" document={document}
value={fullName} fields={fields}
onChange={(e) => setFullName(e.target.value.trimStart())} fieldsValidated={fieldsValidated}
/> role={recipient.role}
/>
</div>
</div> </div>
</>
) : (
<>
<p className="text-muted-foreground mt-2 text-sm">
Please review the document before signing.
</p>
<div> <hr className="border-border mb-8 mt-4" />
<Label htmlFor="Signature">Signature</Label>
<Card className="mt-2" gradient degrees={-120}> <div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
<CardContent className="p-0"> <div className="flex flex-1 flex-col gap-y-4">
<SignaturePad <div>
className="h-44 w-full" <Label htmlFor="full-name">Full Name</Label>
disabled={isSubmitting}
defaultValue={signature ?? undefined} <Input
onChange={(value) => { type="text"
setSignature(value); id="full-name"
}} className="bg-background mt-2"
value={fullName}
onChange={(e) => setFullName(e.target.value.trimStart())}
/> />
</CardContent> </div>
</Card>
<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 className="flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={() => router.back()}
>
Cancel
</Button>
<SignDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
document={document}
fields={fields}
fieldsValidated={fieldsValidated}
role={recipient.role}
/>
</div>
</div> </div>
</div> </>
)}
<div className="flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={() => router.back()}
>
Cancel
</Button>
<SignDialog
isSubmitting={isSubmitting}
onSignatureComplete={handleSubmit(onFormSubmit)}
document={document}
fields={fields}
fieldsValidated={fieldsValidated}
/>
</div>
</div>
</div> </div>
</fieldset> </fieldset>
</form> </form>

View File

@ -14,7 +14,7 @@ import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-f
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures'; import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { DocumentStatus, FieldType, SigningStatus } from '@documenso/prisma/client'; import { DocumentStatus, FieldType, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
@ -110,7 +110,10 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
<div className="mt-2.5 flex items-center gap-x-6"> <div className="mt-2.5 flex items-center gap-x-6">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{document.User.name} ({document.User.email}) has invited you to sign this document. {document.User.name} ({document.User.email}) has invited you to{' '}
{recipient.role === RecipientRole.VIEWER && 'view'}
{recipient.role === RecipientRole.SIGNER && 'sign'}
{recipient.role === RecipientRole.APPROVER && 'approve'} this document.
</p> </p>
</div> </div>

View File

@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import type { Document, Field } from '@documenso/prisma/client'; import type { Document, Field } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { import {
Dialog, Dialog,
@ -17,6 +18,7 @@ export type SignDialogProps = {
fields: Field[]; fields: Field[];
fieldsValidated: () => void | Promise<void>; fieldsValidated: () => void | Promise<void>;
onSignatureComplete: () => void | Promise<void>; onSignatureComplete: () => void | Promise<void>;
role: RecipientRole;
}; };
export const SignDialog = ({ export const SignDialog = ({
@ -25,6 +27,7 @@ export const SignDialog = ({
fields, fields,
fieldsValidated, fieldsValidated,
onSignatureComplete, onSignatureComplete,
role,
}: SignDialogProps) => { }: SignDialogProps) => {
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false);
const truncatedTitle = truncateTitle(document.title); const truncatedTitle = truncateTitle(document.title);
@ -45,9 +48,18 @@ export const SignDialog = ({
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<div className="text-center"> <div className="text-center">
<div className="text-foreground text-xl font-semibold">Sign Document</div> <div className="text-foreground text-xl font-semibold">
{role === RecipientRole.VIEWER && 'Mark Document as Viewed'}
{role === RecipientRole.SIGNER && 'Sign Document'}
{role === RecipientRole.APPROVER && 'Approve Document'}
</div>
<div className="text-muted-foreground mx-auto w-4/5 py-2 text-center"> <div className="text-muted-foreground mx-auto w-4/5 py-2 text-center">
You are about to finish signing "{truncatedTitle}". Are you sure? {role === RecipientRole.VIEWER &&
`You are about to finish viewing "${truncatedTitle}". Are you sure?`}
{role === RecipientRole.SIGNER &&
`You are about to finish signing "${truncatedTitle}". Are you sure?`}
{role === RecipientRole.APPROVER &&
`You are about to finish approving "${truncatedTitle}". Are you sure?`}
</div> </div>
</div> </div>
@ -71,7 +83,9 @@ export const SignDialog = ({
loading={isSubmitting} loading={isSubmitting}
onClick={onSignatureComplete} onClick={onSignatureComplete}
> >
Sign {role === RecipientRole.VIEWER && 'Mark as Viewed'}
{role === RecipientRole.SIGNER && 'Sign'}
{role === RecipientRole.APPROVER && 'Approve'}
</Button> </Button>
</div> </div>
</DialogFooter> </DialogFooter>

View File

@ -4,6 +4,7 @@ import React from 'react';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
@ -47,8 +48,17 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
type={getRecipientType(recipient)} type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)} fallbackText={recipientAbbreviation(recipient)}
/> />
<div>
<span className="text-muted-foreground text-sm">{recipient.email}</span> <div
className="text-muted-foreground text-sm"
title="Click to copy signing link for sending to recipient"
>
<p>{recipient.email} </p>
<p className="text-muted-foreground/70 text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
</p>
</div>
</div>
</div> </div>
); );
} }

View File

@ -1,4 +1,5 @@
import { getRecipientType } from '@documenso/lib/client-only/recipient-type'; import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter'; import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import { import {
@ -59,7 +60,12 @@ export const StackAvatarsWithTooltip = ({
type={getRecipientType(recipient)} type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)} fallbackText={recipientAbbreviation(recipient)}
/> />
<span className="text-muted-foreground text-sm">{recipient.email}</span> <div className="">
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
{RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName}
</p>
</div>
</div> </div>
))} ))}
</div> </div>

View File

@ -1,3 +1,6 @@
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { RecipientRole } from '@documenso/prisma/client';
import { Button, Section, Text } from '../components'; import { Button, Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image'; import { TemplateDocumentImage } from './template-document-image';
@ -7,6 +10,7 @@ export interface TemplateDocumentInviteProps {
documentName: string; documentName: string;
signDocumentLink: string; signDocumentLink: string;
assetBaseUrl: string; assetBaseUrl: string;
role: RecipientRole;
} }
export const TemplateDocumentInvite = ({ export const TemplateDocumentInvite = ({
@ -14,19 +18,22 @@ export const TemplateDocumentInvite = ({
documentName, documentName,
signDocumentLink, signDocumentLink,
assetBaseUrl, assetBaseUrl,
role,
}: TemplateDocumentInviteProps) => { }: TemplateDocumentInviteProps) => {
const { actionVerb, progressiveVerb } = RECIPIENT_ROLES_DESCRIPTION[role];
return ( return (
<> <>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} /> <TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section> <Section>
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold"> <Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
{inviterName} has invited you to sign {inviterName} has invited you to {actionVerb.toLowerCase()}
<br />"{documentName}" <br />"{documentName}"
</Text> </Text>
<Text className="my-1 text-center text-base text-slate-400"> <Text className="my-1 text-center text-base text-slate-400">
Continue by signing the document. Continue by {progressiveVerb.toLowerCase()} the document.
</Text> </Text>
<Section className="mb-6 mt-8 text-center"> <Section className="mb-6 mt-8 text-center">
@ -34,7 +41,7 @@ export const TemplateDocumentInvite = ({
className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline" className="bg-documenso-500 inline-flex items-center justify-center rounded-lg px-6 py-3 text-center text-sm font-medium text-black no-underline"
href={signDocumentLink} href={signDocumentLink}
> >
Sign Document {actionVerb} Document
</Button> </Button>
</Section> </Section>
</Section> </Section>

View File

@ -1,3 +1,5 @@
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { RecipientRole } from '@documenso/prisma/client';
import config from '@documenso/tailwind-config'; import config from '@documenso/tailwind-config';
import { import {
@ -19,6 +21,7 @@ import { TemplateFooter } from '../template-components/template-footer';
export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & { export type DocumentInviteEmailTemplateProps = Partial<TemplateDocumentInviteProps> & {
customBody?: string; customBody?: string;
role: RecipientRole;
}; };
export const DocumentInviteEmailTemplate = ({ export const DocumentInviteEmailTemplate = ({
@ -28,8 +31,11 @@ export const DocumentInviteEmailTemplate = ({
signDocumentLink = 'https://documenso.com', signDocumentLink = 'https://documenso.com',
assetBaseUrl = 'http://localhost:3002', assetBaseUrl = 'http://localhost:3002',
customBody, customBody,
role,
}: DocumentInviteEmailTemplateProps) => { }: DocumentInviteEmailTemplateProps) => {
const previewText = `${inviterName} has invited you to sign ${documentName}`; const action = RECIPIENT_ROLES_DESCRIPTION[role].actionVerb.toLowerCase();
const previewText = `${inviterName} has invited you to ${action} ${documentName}`;
const getAssetUrl = (path: string) => { const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString(); return new URL(path, assetBaseUrl).toString();
@ -64,6 +70,7 @@ export const DocumentInviteEmailTemplate = ({
documentName={documentName} documentName={documentName}
signDocumentLink={signDocumentLink} signDocumentLink={signDocumentLink}
assetBaseUrl={assetBaseUrl} assetBaseUrl={assetBaseUrl}
role={role}
/> />
</Section> </Section>
</Container> </Container>
@ -81,7 +88,7 @@ export const DocumentInviteEmailTemplate = ({
{customBody ? ( {customBody ? (
<pre className="font-sans text-base text-slate-400">{customBody}</pre> <pre className="font-sans text-base text-slate-400">{customBody}</pre>
) : ( ) : (
`${inviterName} has invited you to sign the document "${documentName}".` `${inviterName} has invited you to ${action} the document "${documentName}".`
)} )}
</Text> </Text>
</Section> </Section>

View File

@ -1,10 +1,10 @@
import type { Recipient } from '@documenso/prisma/client'; import type { Recipient } from '@documenso/prisma/client';
import { ReadStatus, SendStatus, SigningStatus } from '@documenso/prisma/client'; import { ReadStatus, RecipientRole, SendStatus, SigningStatus } from '@documenso/prisma/client';
export const getRecipientType = (recipient: Recipient) => { export const getRecipientType = (recipient: Recipient) => {
if ( if (
recipient.sendStatus === SendStatus.SENT && recipient.role === RecipientRole.CC ||
recipient.signingStatus === SigningStatus.SIGNED (recipient.sendStatus === SendStatus.SENT && recipient.signingStatus === SigningStatus.SIGNED)
) { ) {
return 'completed'; return 'completed';
} }

View File

@ -0,0 +1,26 @@
import { RecipientRole } from '@documenso/prisma/client';
export const RECIPIENT_ROLES_DESCRIPTION: {
[key in RecipientRole]: { actionVerb: string; progressiveVerb: string; roleName: string };
} = {
[RecipientRole.APPROVER]: {
actionVerb: 'Approve',
progressiveVerb: 'Approving',
roleName: 'Approver',
},
[RecipientRole.CC]: {
actionVerb: 'CC',
progressiveVerb: 'CC',
roleName: 'CC',
},
[RecipientRole.SIGNER]: {
actionVerb: 'Sign',
progressiveVerb: 'Signing',
roleName: 'Signer',
},
[RecipientRole.VIEWER]: {
actionVerb: 'View',
progressiveVerb: 'Viewing',
roleName: 'Viewer',
},
};

View File

@ -3,7 +3,7 @@ import { P, match } from 'ts-pattern';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import type { Document, Prisma } from '@documenso/prisma/client'; import type { Document, Prisma } from '@documenso/prisma/client';
import { SigningStatus } from '@documenso/prisma/client'; import { RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type { FindResultSet } from '../../types/find-result-set'; import type { FindResultSet } from '../../types/find-result-set';
@ -87,6 +87,9 @@ export const findDocuments = async ({
some: { some: {
email: user.email, email: user.email,
signingStatus: SigningStatus.NOT_SIGNED, signingStatus: SigningStatus.NOT_SIGNED,
role: {
not: RecipientRole.CC,
},
}, },
}, },
deletedAt: null, deletedAt: null,
@ -109,6 +112,9 @@ export const findDocuments = async ({
some: { some: {
email: user.email, email: user.email,
signingStatus: SigningStatus.SIGNED, signingStatus: SigningStatus.SIGNED,
role: {
not: RecipientRole.CC,
},
}, },
}, },
deletedAt: null, deletedAt: null,

View File

@ -6,7 +6,9 @@ import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email'; import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template'; import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
export type ResendDocumentOptions = { export type ResendDocumentOptions = {
documentId: number; documentId: number;
@ -59,6 +61,10 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
await Promise.all( await Promise.all(
document.Recipient.map(async (recipient) => { document.Recipient.map(async (recipient) => {
if (recipient.role === RecipientRole.CC) {
return;
}
const { email, name } = recipient; const { email, name } = recipient;
const customEmailTemplate = { const customEmailTemplate = {
@ -77,8 +83,11 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
assetBaseUrl, assetBaseUrl,
signDocumentLink, signDocumentLink,
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate), customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
role: recipient.role,
}); });
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
await mailer.sendMail({ await mailer.sendMail({
to: { to: {
address: email, address: email,
@ -90,7 +99,7 @@ export const resendDocument = async ({ documentId, userId, recipients }: ResendD
}, },
subject: customEmail?.subject subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: 'Please sign this document', : `Please ${actionVerb.toLowerCase()} this document`,
html: render(template), html: render(template),
text: render(template, { plainText: true }), text: render(template, { plainText: true }),
}); });

View File

@ -6,7 +6,7 @@ import { PDFDocument } from 'pdf-lib';
import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client'; import PostHogServerClient from '@documenso/lib/server-only/feature-flags/get-post-hog-server-client';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentStatus, SigningStatus } from '@documenso/prisma/client'; import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { signPdf } from '@documenso/signing'; import { signPdf } from '@documenso/signing';
import { getFile } from '../../universal/upload/get-file'; import { getFile } from '../../universal/upload/get-file';
@ -44,6 +44,9 @@ export const sealDocument = async ({ documentId, sendEmail = true }: SealDocumen
const recipients = await prisma.recipient.findMany({ const recipients = await prisma.recipient.findMany({
where: { where: {
documentId: document.id, documentId: document.id,
role: {
not: RecipientRole.CC,
},
}, },
}); });

View File

@ -6,7 +6,9 @@ import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document
import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email'; import { FROM_ADDRESS, FROM_NAME } from '@documenso/lib/constants/email';
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template'; import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { DocumentStatus, SendStatus } from '@documenso/prisma/client'; import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client';
import { RECIPIENT_ROLES_DESCRIPTION } from '../../constants/recipient-roles';
export type SendDocumentOptions = { export type SendDocumentOptions = {
documentId: number; documentId: number;
@ -47,6 +49,10 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
await Promise.all( await Promise.all(
document.Recipient.map(async (recipient) => { document.Recipient.map(async (recipient) => {
if (recipient.sendStatus === SendStatus.SENT || recipient.role === RecipientRole.CC) {
return;
}
const { email, name } = recipient; const { email, name } = recipient;
const customEmailTemplate = { const customEmailTemplate = {
@ -55,10 +61,6 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
'document.name': document.title, 'document.name': document.title,
}; };
if (recipient.sendStatus === SendStatus.SENT) {
return;
}
const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000'; const assetBaseUrl = process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000';
const signDocumentLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`; const signDocumentLink = `${process.env.NEXT_PUBLIC_WEBAPP_URL}/sign/${recipient.token}`;
@ -69,8 +71,11 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
assetBaseUrl, assetBaseUrl,
signDocumentLink, signDocumentLink,
customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate), customBody: renderCustomEmailTemplate(customEmail?.message || '', customEmailTemplate),
role: recipient.role,
}); });
const { actionVerb } = RECIPIENT_ROLES_DESCRIPTION[recipient.role];
await mailer.sendMail({ await mailer.sendMail({
to: { to: {
address: email, address: email,
@ -82,7 +87,7 @@ export const sendDocument = async ({ documentId, userId }: SendDocumentOptions)
}, },
subject: customEmail?.subject subject: customEmail?.subject
? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate) ? renderCustomEmailTemplate(customEmail.subject, customEmailTemplate)
: 'Please sign this document', : `Please ${actionVerb.toLowerCase()} this document`,
html: render(template), html: render(template),
text: render(template, { plainText: true }), text: render(template, { plainText: true }),
}); });

View File

@ -1,4 +1,5 @@
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { RecipientRole } from '@documenso/prisma/client';
import { SendStatus, SigningStatus } from '@documenso/prisma/client'; import { SendStatus, SigningStatus } from '@documenso/prisma/client';
import { nanoid } from '../../universal/id'; import { nanoid } from '../../universal/id';
@ -10,6 +11,7 @@ export interface SetRecipientsForDocumentOptions {
id?: number | null; id?: number | null;
email: string; email: string;
name: string; name: string;
role: RecipientRole;
}[]; }[];
} }
@ -79,13 +81,20 @@ export const setRecipientsForDocument = async ({
update: { update: {
name: recipient.name, name: recipient.name,
email: recipient.email, email: recipient.email,
role: recipient.role,
documentId, documentId,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
}, },
create: { create: {
name: recipient.name, name: recipient.name,
email: recipient.email, email: recipient.email,
role: recipient.role,
token: nanoid(), token: nanoid(),
documentId, documentId,
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
}, },
}), }),
), ),

View File

@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "RecipientRole" AS ENUM ('CC', 'SIGNER', 'VIEWER', 'APPROVER');
-- AlterTable
ALTER TABLE "Recipient" ADD COLUMN "role" "RecipientRole" NOT NULL DEFAULT 'SIGNER';

View File

@ -209,6 +209,13 @@ enum SigningStatus {
SIGNED SIGNED
} }
enum RecipientRole {
CC
SIGNER
VIEWER
APPROVER
}
model Recipient { model Recipient {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
documentId Int? documentId Int?
@ -218,6 +225,7 @@ model Recipient {
token String token String
expired DateTime? expired DateTime?
signedAt DateTime? signedAt DateTime?
role RecipientRole @default(SIGNER)
readStatus ReadStatus @default(NOT_OPENED) readStatus ReadStatus @default(NOT_OPENED)
signingStatus SigningStatus @default(NOT_SIGNED) signingStatus SigningStatus @default(NOT_SIGNED)
sendStatus SendStatus @default(NOT_SENT) sendStatus SendStatus @default(NOT_SENT)

View File

@ -1,6 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
import { DocumentStatus, FieldType } from '@documenso/prisma/client'; import { DocumentStatus, FieldType, RecipientRole } from '@documenso/prisma/client';
export const ZGetDocumentByIdQuerySchema = z.object({ export const ZGetDocumentByIdQuerySchema = z.object({
id: z.number().min(1), id: z.number().min(1),
@ -35,6 +35,7 @@ export const ZSetRecipientsForDocumentMutationSchema = z.object({
id: z.number().nullish(), id: z.number().nullish(),
email: z.string().min(1).email(), email: z.string().min(1).email(),
name: z.string(), name: z.string(),
role: z.nativeEnum(RecipientRole),
}), }),
), ),
}); });

View File

@ -25,6 +25,7 @@ export const recipientRouter = router({
id: signer.nativeId, id: signer.nativeId,
email: signer.email, email: signer.email,
name: signer.name, name: signer.name,
role: signer.role,
})), })),
}); });
} catch (err) { } catch (err) {

View File

@ -1,5 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { RecipientRole } from '@documenso/prisma/client';
export const ZAddSignersMutationSchema = z export const ZAddSignersMutationSchema = z
.object({ .object({
documentId: z.number(), documentId: z.number(),
@ -8,6 +10,7 @@ export const ZAddSignersMutationSchema = z
nativeId: z.number().optional(), nativeId: z.number().optional(),
email: z.string().email().min(1), email: z.string().email().min(1),
name: z.string(), name: z.string(),
role: z.nativeEnum(RecipientRole),
}), }),
), ),
}) })

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Caveat } from 'next/font/google'; import { Caveat } from 'next/font/google';
@ -10,8 +10,10 @@ import { useFieldArray, useForm } from 'react-hook-form';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect'; import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element'; import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import type { Field, Recipient } from '@documenso/prisma/client'; import type { Field, Recipient } from '@documenso/prisma/client';
import { RecipientRole } from '@documenso/prisma/client';
import { FieldType, SendStatus } from '@documenso/prisma/client'; import { FieldType, SendStatus } from '@documenso/prisma/client';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
@ -102,6 +104,12 @@ export const AddFieldsFormPartial = ({
const hasSelectedSignerBeenSent = selectedSigner?.sendStatus === SendStatus.SENT; const hasSelectedSignerBeenSent = selectedSigner?.sendStatus === SendStatus.SENT;
const isFieldsDisabled =
!selectedSigner ||
hasSelectedSignerBeenSent ||
selectedSigner?.role === RecipientRole.VIEWER ||
selectedSigner?.role === RecipientRole.CC;
const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false); const [isFieldWithinBounds, setIsFieldWithinBounds] = useState(false);
const [coords, setCoords] = useState({ const [coords, setCoords] = useState({
x: 0, x: 0,
@ -281,12 +289,28 @@ export const AddFieldsFormPartial = ({
setSelectedSigner(recipients.find((r) => r.sendStatus !== SendStatus.SENT) ?? recipients[0]); setSelectedSigner(recipients.find((r) => r.sendStatus !== SendStatus.SENT) ?? recipients[0]);
}, [recipients]); }, [recipients]);
const recipientsByRole = useMemo(() => {
const recipientsByRole: Record<RecipientRole, Recipient[]> = {
CC: [],
VIEWER: [],
SIGNER: [],
APPROVER: [],
};
recipients.forEach((recipient) => {
recipientsByRole[recipient.role].push(recipient);
});
return recipientsByRole;
}, [recipients]);
return ( return (
<> <>
<DocumentFlowFormContainerHeader <DocumentFlowFormContainerHeader
title={documentFlow.title} title={documentFlow.title}
description={documentFlow.description} description={documentFlow.description}
/> />
<DocumentFlowFormContainerContent> <DocumentFlowFormContainerContent>
<div className="flex flex-col"> <div className="flex flex-col">
{selectedField && ( {selectedField && (
@ -351,72 +375,94 @@ export const AddFieldsFormPartial = ({
<PopoverContent className="p-0" align="start"> <PopoverContent className="p-0" align="start">
<Command> <Command>
<CommandInput /> <CommandInput />
<CommandEmpty> <CommandEmpty>
<span className="text-muted-foreground inline-block px-4"> <span className="text-muted-foreground inline-block px-4">
No recipient matching this description was found. No recipient matching this description was found.
</span> </span>
</CommandEmpty> </CommandEmpty>
<CommandGroup> {Object.entries(recipientsByRole).map(([role, recipients], roleIndex) => (
{recipients.map((recipient, index) => ( <CommandGroup key={roleIndex}>
<CommandItem <div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
key={index} {
className={cn({ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT, RECIPIENT_ROLES_DESCRIPTION[role as RecipientRole].roleName
})} }
onSelect={() => { </div>
setSelectedSigner(recipient);
setShowRecipientsSelector(false);
}}
>
{recipient.sendStatus !== SendStatus.SENT ? (
<Check
aria-hidden={recipient !== selectedSigner}
className={cn('mr-2 h-4 w-4 flex-shrink-0', {
'opacity-0': recipient !== selectedSigner,
'opacity-100': recipient === selectedSigner,
})}
/>
) : (
<Tooltip>
<TooltipTrigger>
<Info className="mr-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
This document has already been sent to this recipient. You can no
longer edit this recipient.
</TooltipContent>
</Tooltip>
)}
{recipient.name && ( {recipients.length === 0 && (
<div
key={`${role}-empty`}
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
>
No recipients with this role
</div>
)}
{recipients.map((recipient) => (
<CommandItem
key={recipient.id}
className={cn('!rounded-2xl px-4 last:mb-1 [&:not(:first-child)]:mt-1', {
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
})}
onSelect={() => {
setSelectedSigner(recipient);
setShowRecipientsSelector(false);
}}
>
<span <span
className="truncate" className={cn('text-foreground/70 truncate', {
title={`${recipient.name} (${recipient.email})`} 'text-foreground': recipient === selectedSigner,
})}
> >
{recipient.name} ({recipient.email}) {recipient.name && (
</span> <span title={`${recipient.name} (${recipient.email})`}>
)} {recipient.name} ({recipient.email})
</span>
)}
{!recipient.name && ( {!recipient.name && (
<span className="truncate" title={recipient.email}> <span title={recipient.email}>{recipient.email}</span>
{recipient.email} )}
</span> </span>
)}
</CommandItem> <div className="ml-auto flex items-center justify-center">
))} {recipient.sendStatus !== SendStatus.SENT ? (
</CommandGroup> <Check
aria-hidden={recipient !== selectedSigner}
className={cn('h-4 w-4 flex-shrink-0', {
'opacity-0': recipient !== selectedSigner,
'opacity-100': recipient === selectedSigner,
})}
/>
) : (
<Tooltip>
<TooltipTrigger>
<Info className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
This document has already been sent to this recipient. You can no
longer edit this recipient.
</TooltipContent>
</Tooltip>
)}
</div>
</CommandItem>
))}
</CommandGroup>
))}
</Command> </Command>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
)} )}
<div className="-mx-2 flex-1 overflow-y-auto px-2"> <div className="-mx-2 flex-1 overflow-y-auto px-2">
<div className="grid grid-cols-2 gap-x-4 gap-y-8"> <fieldset disabled={isFieldsDisabled} className="grid grid-cols-2 gap-x-4 gap-y-8">
<button <button
type="button" type="button"
className="group h-full w-full" className="group h-full w-full"
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
onClick={() => setSelectedField(FieldType.SIGNATURE)} onClick={() => setSelectedField(FieldType.SIGNATURE)}
onMouseDown={() => setSelectedField(FieldType.SIGNATURE)} onMouseDown={() => setSelectedField(FieldType.SIGNATURE)}
data-selected={selectedField === FieldType.SIGNATURE ? true : undefined} data-selected={selectedField === FieldType.SIGNATURE ? true : undefined}
@ -440,7 +486,6 @@ export const AddFieldsFormPartial = ({
<button <button
type="button" type="button"
className="group h-full w-full" className="group h-full w-full"
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
onClick={() => setSelectedField(FieldType.EMAIL)} onClick={() => setSelectedField(FieldType.EMAIL)}
onMouseDown={() => setSelectedField(FieldType.EMAIL)} onMouseDown={() => setSelectedField(FieldType.EMAIL)}
data-selected={selectedField === FieldType.EMAIL ? true : undefined} data-selected={selectedField === FieldType.EMAIL ? true : undefined}
@ -463,7 +508,6 @@ export const AddFieldsFormPartial = ({
<button <button
type="button" type="button"
className="group h-full w-full" className="group h-full w-full"
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
onClick={() => setSelectedField(FieldType.NAME)} onClick={() => setSelectedField(FieldType.NAME)}
onMouseDown={() => setSelectedField(FieldType.NAME)} onMouseDown={() => setSelectedField(FieldType.NAME)}
data-selected={selectedField === FieldType.NAME ? true : undefined} data-selected={selectedField === FieldType.NAME ? true : undefined}
@ -486,7 +530,6 @@ export const AddFieldsFormPartial = ({
<button <button
type="button" type="button"
className="group h-full w-full" className="group h-full w-full"
disabled={!selectedSigner || selectedSigner?.sendStatus === SendStatus.SENT}
onClick={() => setSelectedField(FieldType.DATE)} onClick={() => setSelectedField(FieldType.DATE)}
onMouseDown={() => setSelectedField(FieldType.DATE)} onMouseDown={() => setSelectedField(FieldType.DATE)}
data-selected={selectedField === FieldType.DATE ? true : undefined} data-selected={selectedField === FieldType.DATE ? true : undefined}
@ -505,7 +548,7 @@ export const AddFieldsFormPartial = ({
</CardContent> </CardContent>
</Card> </Card>
</button> </button>
</div> </fieldset>
</div> </div>
</div> </div>
</DocumentFlowFormContainerContent> </DocumentFlowFormContainerContent>

View File

@ -4,19 +4,20 @@ import React, { useId } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { Plus, Trash } from 'lucide-react'; import { BadgeCheck, Copy, Eye, PencilLine, Plus, Trash } from 'lucide-react';
import { Controller, useFieldArray, useForm } from 'react-hook-form'; import { Controller, useFieldArray, useForm } from 'react-hook-form';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import type { Field, Recipient } from '@documenso/prisma/client'; import type { Field, Recipient } from '@documenso/prisma/client';
import { DocumentStatus, SendStatus } from '@documenso/prisma/client'; import { DocumentStatus, RecipientRole, SendStatus } from '@documenso/prisma/client';
import type { DocumentWithData } from '@documenso/prisma/types/document-with-data'; import type { DocumentWithData } from '@documenso/prisma/types/document-with-data';
import { Button } from '../button'; import { Button } from '../button';
import { FormErrorMessage } from '../form/form-error-message'; import { FormErrorMessage } from '../form/form-error-message';
import { Input } from '../input'; import { Input } from '../input';
import { Label } from '../label'; import { Label } from '../label';
import { Select, SelectContent, SelectItem, SelectTrigger } from '../select';
import { useStep } from '../stepper'; import { useStep } from '../stepper';
import { useToast } from '../use-toast'; import { useToast } from '../use-toast';
import type { TAddSignersFormSchema } from './add-signers.types'; import type { TAddSignersFormSchema } from './add-signers.types';
@ -31,6 +32,13 @@ import {
import { ShowFieldItem } from './show-field-item'; import { ShowFieldItem } from './show-field-item';
import type { DocumentFlowStep } from './types'; import type { DocumentFlowStep } from './types';
const ROLE_ICONS: Record<RecipientRole, JSX.Element> = {
SIGNER: <PencilLine className="h-4 w-4" />,
APPROVER: <BadgeCheck className="h-4 w-4" />,
CC: <Copy className="h-4 w-4" />,
VIEWER: <Eye className="h-4 w-4" />,
};
export type AddSignersFormProps = { export type AddSignersFormProps = {
documentFlow: DocumentFlowStep; documentFlow: DocumentFlowStep;
recipients: Recipient[]; recipients: Recipient[];
@ -67,12 +75,14 @@ export const AddSignersFormPartial = ({
formId: String(recipient.id), formId: String(recipient.id),
name: recipient.name, name: recipient.name,
email: recipient.email, email: recipient.email,
role: recipient.role,
})) }))
: [ : [
{ {
formId: initialId, formId: initialId,
name: '', name: '',
email: '', email: '',
role: RecipientRole.SIGNER,
}, },
], ],
}, },
@ -104,6 +114,7 @@ export const AddSignersFormPartial = ({
formId: nanoid(12), formId: nanoid(12),
name: '', name: '',
email: '', email: '',
role: RecipientRole.SIGNER,
}); });
}; };
@ -189,6 +200,48 @@ export const AddSignersFormPartial = ({
/> />
</div> </div>
<div className="w-[60px]">
<Controller
control={control}
name={`signers.${index}.role`}
render={({ field: { value, onChange } }) => (
<Select value={value} onValueChange={(x) => onChange(x)}>
<SelectTrigger className="bg-background">{ROLE_ICONS[value]}</SelectTrigger>
<SelectContent className="" align="end">
<SelectItem value={RecipientRole.SIGNER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.SIGNER]}</span>
Signer
</div>
</SelectItem>
<SelectItem value={RecipientRole.CC}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.CC]}</span>
Receives copy
</div>
</SelectItem>
<SelectItem value={RecipientRole.APPROVER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.APPROVER]}</span>
Approver
</div>
</SelectItem>
<SelectItem value={RecipientRole.VIEWER}>
<div className="flex items-center">
<span className="mr-2">{ROLE_ICONS[RecipientRole.VIEWER]}</span>
Viewer
</div>
</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div> <div>
<button <button
type="button" type="button"

View File

@ -1,5 +1,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { RecipientRole } from '.prisma/client';
export const ZAddSignersFormSchema = z export const ZAddSignersFormSchema = z
.object({ .object({
signers: z.array( signers: z.array(
@ -8,6 +10,7 @@ export const ZAddSignersFormSchema = z
nativeId: z.number().optional(), nativeId: z.number().optional(),
email: z.string().email().min(1), email: z.string().email().min(1),
name: z.string(), name: z.string(),
role: z.nativeEnum(RecipientRole),
}), }),
), ),
}) })

View File

@ -110,7 +110,6 @@ export const AddSubjectFormPartial = ({
<Input <Input
id="subject" id="subject"
// placeholder="Subject"
className="bg-background mt-2" className="bg-background mt-2"
disabled={isSubmitting} disabled={isSubmitting}
{...register('meta.subject')} {...register('meta.subject')}