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:
@@ -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.
|
||||
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\
|
||||
Timur
|
||||
|
||||
@@ -158,6 +158,7 @@ export const SinglePlayerClient = () => {
|
||||
readStatus: 'OPENED',
|
||||
signingStatus: 'NOT_SIGNED',
|
||||
sendStatus: 'NOT_SENT',
|
||||
role: 'SIGNER',
|
||||
};
|
||||
|
||||
const onFileDrop = async (file: File) => {
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
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 { match } from 'ts-pattern';
|
||||
|
||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
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 { trpc as trpcClient } from '@documenso/trpc/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -37,6 +37,7 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||
const isPending = row.status === DocumentStatus.PENDING;
|
||||
const isComplete = row.status === DocumentStatus.COMPLETED;
|
||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||
const role = recipient?.role;
|
||||
|
||||
const onDownloadClick = async () => {
|
||||
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({
|
||||
isOwner,
|
||||
isRecipient,
|
||||
@@ -87,15 +93,32 @@ export const DataTableActionButton = ({ row }: DataTableActionButtonProps) => {
|
||||
.with({ isRecipient: true, isPending: true, isSigned: false }, () => (
|
||||
<Button className="w-32" asChild>
|
||||
<Link href={`/sign/${recipient?.token}`}>
|
||||
<Pencil className="-ml-1 mr-2 h-4 w-4" />
|
||||
Sign
|
||||
{match(role)
|
||||
.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>
|
||||
</Button>
|
||||
))
|
||||
.with({ isPending: true, isSigned: true }, () => (
|
||||
<Button className="w-32" disabled={true}>
|
||||
<Pencil className="-ml-1 mr-2 inline h-4 w-4" />
|
||||
Sign
|
||||
<EyeIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||
View
|
||||
</Button>
|
||||
))
|
||||
.with({ isComplete: true }, () => (
|
||||
|
||||
@@ -5,9 +5,11 @@ import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import {
|
||||
CheckCircle,
|
||||
Copy,
|
||||
Download,
|
||||
Edit,
|
||||
EyeIcon,
|
||||
Loader,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
@@ -19,7 +21,7 @@ import { useSession } from 'next-auth/react';
|
||||
|
||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||
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 { trpc as trpcClient } from '@documenso/trpc/client';
|
||||
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>
|
||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
||||
<Link href={`/sign/${recipient?.token}`}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Sign
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{recipient?.role !== RecipientRole.CC && (
|
||||
<DropdownMenuItem disabled={!recipient || isComplete} asChild>
|
||||
<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" />
|
||||
Sign
|
||||
</>
|
||||
)}
|
||||
|
||||
{recipient?.role === RecipientRole.APPROVER && (
|
||||
<>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Approve
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem disabled={!isOwner || isComplete} asChild>
|
||||
<Link href={`/documents/${row.id}`}>
|
||||
|
||||
@@ -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 { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
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 { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||
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">
|
||||
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>
|
||||
</h2>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useForm } from 'react-hook-form';
|
||||
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
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 { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -96,74 +96,114 @@ export const SigningForm = ({ document, recipient, fields }: SigningFormProps) =
|
||||
|
||||
<fieldset
|
||||
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
|
||||
className={cn(
|
||||
'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>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Please review the document before signing.
|
||||
</p>
|
||||
{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" />
|
||||
<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>
|
||||
<Label htmlFor="full-name">Full Name</Label>
|
||||
<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>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value.trimStart())}
|
||||
/>
|
||||
<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">
|
||||
Please review the document before signing.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Signature">Signature</Label>
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
|
||||
<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);
|
||||
}}
|
||||
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
||||
<div className="flex flex-1 flex-col gap-y-4">
|
||||
<div>
|
||||
<Label htmlFor="full-name">Full Name</Label>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value.trimStart())}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Signature">Signature</Label>
|
||||
|
||||
<Card className="mt-2" gradient degrees={-120}>
|
||||
<CardContent className="p-0">
|
||||
<SignaturePad
|
||||
className="h-44 w-full"
|
||||
disabled={isSubmitting}
|
||||
defaultValue={signature ?? undefined}
|
||||
onChange={(value) => {
|
||||
setSignature(value);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div 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 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>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -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 { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get-recipient-signatures';
|
||||
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 { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
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">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { Document, Field } from '@documenso/prisma/client';
|
||||
import { RecipientRole } from '@documenso/prisma/client';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -17,6 +18,7 @@ export type SignDialogProps = {
|
||||
fields: Field[];
|
||||
fieldsValidated: () => void | Promise<void>;
|
||||
onSignatureComplete: () => void | Promise<void>;
|
||||
role: RecipientRole;
|
||||
};
|
||||
|
||||
export const SignDialog = ({
|
||||
@@ -25,6 +27,7 @@ export const SignDialog = ({
|
||||
fields,
|
||||
fieldsValidated,
|
||||
onSignatureComplete,
|
||||
role,
|
||||
}: SignDialogProps) => {
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const truncatedTitle = truncateTitle(document.title);
|
||||
@@ -45,9 +48,18 @@ export const SignDialog = ({
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<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">
|
||||
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>
|
||||
|
||||
@@ -71,7 +83,9 @@ export const SignDialog = ({
|
||||
loading={isSubmitting}
|
||||
onClick={onSignatureComplete}
|
||||
>
|
||||
Sign
|
||||
{role === RecipientRole.VIEWER && 'Mark as Viewed'}
|
||||
{role === RecipientRole.SIGNER && 'Sign'}
|
||||
{role === RecipientRole.APPROVER && 'Approve'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -4,6 +4,7 @@ import React from 'react';
|
||||
|
||||
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
||||
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 type { Recipient } from '@documenso/prisma/client';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -47,8 +48,17 @@ export function AvatarWithRecipient({ recipient }: AvatarWithRecipientProps) {
|
||||
type={getRecipientType(recipient)}
|
||||
fallbackText={recipientAbbreviation(recipient)}
|
||||
/>
|
||||
|
||||
<span className="text-muted-foreground text-sm">{recipient.email}</span>
|
||||
<div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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 type { Recipient } from '@documenso/prisma/client';
|
||||
import {
|
||||
@@ -59,7 +60,12 @@ export const StackAvatarsWithTooltip = ({
|
||||
type={getRecipientType(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>
|
||||
|
||||
Reference in New Issue
Block a user