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

@@ -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}`}>
{match(role)
.with(RecipientRole.SIGNER, () => (
<>
<Pencil className="-ml-1 mr-2 h-4 w-4" /> <Pencil className="-ml-1 mr-2 h-4 w-4" />
Sign 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>
{recipient?.role !== RecipientRole.CC && (
<DropdownMenuItem disabled={!recipient || isComplete} asChild> <DropdownMenuItem disabled={!recipient || isComplete} asChild>
<Link href={`/sign/${recipient?.token}`}> <Link href={`/sign/${recipient?.token}`}>
{recipient?.role === RecipientRole.VIEWER && (
<>
<EyeIcon className="mr-2 h-4 w-4" />
View
</>
)}
{recipient?.role === RecipientRole.SIGNER && (
<>
<Pencil className="mr-2 h-4 w-4" /> <Pencil className="mr-2 h-4 w-4" />
Sign Sign
</>
)}
{recipient?.role === RecipientRole.APPROVER && (
<>
<CheckCircle className="mr-2 h-4 w-4" />
Approve
</>
)}
</Link> </Link>
</DropdownMenuItem> </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,15 +96,52 @@ 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')}
>
<div
className={cn( className={cn(
'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2', 'custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2',
)} )}
> >
<h3 className="text-foreground text-2xl font-semibold">Sign Document</h3> <div className={cn('flex flex-1 flex-col')}>
<h3 className="text-foreground text-2xl font-semibold">
{recipient.role === RecipientRole.VIEWER && 'View Document'}
{recipient.role === RecipientRole.SIGNER && 'Sign Document'}
{recipient.role === RecipientRole.APPROVER && 'Approve Document'}
</h3>
{recipient.role === RecipientRole.VIEWER ? (
<>
<p className="text-muted-foreground mt-2 text-sm">
Please mark as viewed to complete
</p>
<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="flex flex-1 flex-col gap-y-4" />
<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>
</>
) : (
<>
<p className="text-muted-foreground mt-2 text-sm"> <p className="text-muted-foreground mt-2 text-sm">
Please review the document before signing. Please review the document before signing.
</p> </p>
@@ -161,9 +198,12 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
document={document} document={document}
fields={fields} fields={fields}
fieldsValidated={fieldsValidated} fieldsValidated={fieldsValidated}
role={recipient.role}
/> />
</div> </div>
</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,17 +375,35 @@ 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}>
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
{
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
RECIPIENT_ROLES_DESCRIPTION[role as RecipientRole].roleName
}
</div>
{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 <CommandItem
key={index} key={recipient.id}
className={cn({ className={cn('!rounded-2xl px-4 last:mb-1 [&:not(:first-child)]:mt-1', {
'text-muted-foreground': recipient.sendStatus === SendStatus.SENT, 'text-muted-foreground': recipient.sendStatus === SendStatus.SENT,
})} })}
onSelect={() => { onSelect={() => {
@@ -369,10 +411,27 @@ export const AddFieldsFormPartial = ({
setShowRecipientsSelector(false); setShowRecipientsSelector(false);
}} }}
> >
<span
className={cn('text-foreground/70 truncate', {
'text-foreground': recipient === selectedSigner,
})}
>
{recipient.name && (
<span title={`${recipient.name} (${recipient.email})`}>
{recipient.name} ({recipient.email})
</span>
)}
{!recipient.name && (
<span title={recipient.email}>{recipient.email}</span>
)}
</span>
<div className="ml-auto flex items-center justify-center">
{recipient.sendStatus !== SendStatus.SENT ? ( {recipient.sendStatus !== SendStatus.SENT ? (
<Check <Check
aria-hidden={recipient !== selectedSigner} aria-hidden={recipient !== selectedSigner}
className={cn('mr-2 h-4 w-4 flex-shrink-0', { className={cn('h-4 w-4 flex-shrink-0', {
'opacity-0': recipient !== selectedSigner, 'opacity-0': recipient !== selectedSigner,
'opacity-100': recipient === selectedSigner, 'opacity-100': recipient === selectedSigner,
})} })}
@@ -380,43 +439,30 @@ export const AddFieldsFormPartial = ({
) : ( ) : (
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
<Info className="mr-2 h-4 w-4" /> <Info className="mx-2 h-4 w-4" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="max-w-xs">
<TooltipContent className="text-muted-foreground max-w-xs">
This document has already been sent to this recipient. You can no This document has already been sent to this recipient. You can no
longer edit this recipient. longer edit this recipient.
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} )}
</div>
{recipient.name && (
<span
className="truncate"
title={`${recipient.name} (${recipient.email})`}
>
{recipient.name} ({recipient.email})
</span>
)}
{!recipient.name && (
<span className="truncate" title={recipient.email}>
{recipient.email}
</span>
)}
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </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')}