Compare commits

...

13 Commits

32 changed files with 1082 additions and 80 deletions

View File

@ -12,13 +12,14 @@ import {
MailOpenIcon, MailOpenIcon,
PenIcon, PenIcon,
PlusIcon, PlusIcon,
Timer,
} from 'lucide-react'; } from 'lucide-react';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { formatSigningLink } from '@documenso/lib/utils/recipients'; import { formatSigningLink } from '@documenso/lib/utils/recipients';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { Document, Recipient } from '@documenso/prisma/client'; import type { Document, Recipient } from '@documenso/prisma/client';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button'; import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { SignatureIcon } from '@documenso/ui/icons/signature'; import { SignatureIcon } from '@documenso/ui/icons/signature';
import { AvatarWithText } from '@documenso/ui/primitives/avatar'; import { AvatarWithText } from '@documenso/ui/primitives/avatar';
@ -132,6 +133,14 @@ export const DocumentPageViewRecipients = ({
</Badge> </Badge>
)} )}
{document.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.EXPIRED && (
<Badge variant="destructive">
<Timer className="mr-1 h-3 w-3" />
<Trans>Expired</Trans>
</Badge>
)}
{document.status !== DocumentStatus.DRAFT && {document.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.REJECTED && ( recipient.signingStatus === SigningStatus.REJECTED && (
<PopoverHover <PopoverHover

View File

@ -15,9 +15,8 @@ import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/g
import { DocumentVisibility } from '@documenso/lib/types/document-visibility'; import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentStatus } from '@documenso/prisma/client';
import type { Team, TeamEmail } from '@documenso/prisma/client'; import type { Team, TeamEmail } from '@documenso/prisma/client';
import { TeamMemberRole } from '@documenso/prisma/client'; import { DocumentStatus, SigningStatus, TeamMemberRole } from '@documenso/prisma/client';
import { Badge } from '@documenso/ui/primitives/badge'; import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card'; import { Card, CardContent } from '@documenso/ui/primitives/card';
@ -218,7 +217,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
<DocumentPageViewDropdown document={documentWithRecipients} team={team} /> <DocumentPageViewDropdown document={documentWithRecipients} team={team} />
</div> </div>
<p className="text-muted-foreground mt-2 px-4 text-sm "> <p className="text-muted-foreground mt-2 px-4 text-sm">
{match(document.status) {match(document.status)
.with(DocumentStatus.COMPLETED, () => ( .with(DocumentStatus.COMPLETED, () => (
<Trans>This document has been signed by all recipients</Trans> <Trans>This document has been signed by all recipients</Trans>
@ -228,8 +227,52 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
)) ))
.with(DocumentStatus.PENDING, () => { .with(DocumentStatus.PENDING, () => {
const pendingRecipients = recipients.filter( const pendingRecipients = recipients.filter(
(recipient) => recipient.signingStatus === 'NOT_SIGNED', (recipient) => recipient.signingStatus === SigningStatus.NOT_SIGNED,
); );
const rejectedCount = recipients.filter(
(recipient) => recipient.signingStatus === SigningStatus.REJECTED,
).length;
const expiredCount = recipients.filter(
(recipient) => recipient.signingStatus === SigningStatus.EXPIRED,
).length;
if (rejectedCount > 0 && expiredCount > 0) {
return (
<>
<Plural
value={rejectedCount}
one="1 recipient has rejected the document"
other="# recipients have rejected the document"
/>
{' and '}
<Plural
value={expiredCount}
one="1 recipient's signing link has expired"
other="# recipients' signing links have expired"
/>
</>
);
}
if (rejectedCount > 0) {
return (
<Plural
value={rejectedCount}
one="1 recipient has rejected the document"
other="# recipients have rejected the document"
/>
);
}
if (expiredCount > 0) {
return (
<Plural
value={expiredCount}
one="1 recipient's signing link has expired"
other="# recipients' signing links have expired"
/>
);
}
return ( return (
<Plural <Plural

View File

@ -419,6 +419,8 @@ export const EditDocumentForm = ({
isDocumentEnterprise={isDocumentEnterprise} isDocumentEnterprise={isDocumentEnterprise}
onSubmit={onAddSignersFormSubmit} onSubmit={onAddSignersFormSubmit}
isDocumentPdfLoaded={isDocumentPdfLoaded} isDocumentPdfLoaded={isDocumentPdfLoaded}
documentId={document.id}
// teamId={team?.id}
/> />
<AddFieldsFormPartial <AddFieldsFormPartial

View File

@ -100,7 +100,7 @@ export const ResendDocumentActionItem = ({
}); });
setIsOpen(false); setIsOpen(false);
} catch (err) { } catch {
toast({ toast({
title: _(msg`Something went wrong`), title: _(msg`Something went wrong`),
description: _(msg`This document could not be re-sent at this time. Please try again.`), description: _(msg`This document could not be re-sent at this time. Please try again.`),
@ -177,12 +177,7 @@ export const ResendDocumentActionItem = ({
<DialogFooter> <DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4"> <div className="flex w-full flex-1 flex-nowrap gap-4">
<DialogClose asChild> <DialogClose asChild>
<Button <Button type="button" className="flex-1" variant="secondary" disabled={isSubmitting}>
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
variant="secondary"
disabled={isSubmitting}
>
<Trans>Cancel</Trans> <Trans>Cancel</Trans>
</Button> </Button>
</DialogClose> </DialogClose>

View File

@ -0,0 +1,99 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { Trans } from '@lingui/macro';
import { Timer } from 'lucide-react';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { truncateTitle } from '~/helpers/truncate-title';
import { SigningAuthPageView } from '../signing-auth-page';
export type ExpiredSigningPageProps = {
params: {
token?: string;
};
};
export default async function ExpiredSigningPage({ params: { token } }: ExpiredSigningPageProps) {
await setupI18nSSR();
if (!token) {
return notFound();
}
const { user } = await getServerComponentSession();
const document = await getDocumentAndSenderByToken({
token,
requireAccessAuth: false,
}).catch(() => null);
if (!document) {
return notFound();
}
const truncatedTitle = truncateTitle(document.title);
const recipient = await getRecipientByToken({ token }).catch(() => null);
if (!recipient) {
return notFound();
}
const isDocumentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
documentAuthOptions: document.authOptions,
recipient,
userId: user?.id,
});
if (!isDocumentAccessValid) {
return <SigningAuthPageView email={recipient.email} />;
}
return (
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
{truncatedTitle}
</Badge>
<div className="flex flex-col items-center">
<div className="flex items-center gap-x-4">
<Timer className="text-destructive h-10 w-10" />
<h2 className="max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
<Trans>Document Expired</Trans>
</h2>
</div>
<div className="text-destructive mt-4 flex items-center text-center text-sm">
<Trans>This document has expired and is no longer available to sign</Trans>
</div>
<p className="text-muted-foreground mt-6 max-w-[60ch] text-center text-sm">
<Trans>
{/* TODO: send email to owner when a user tried to sign an expired document??? */}
The document owner has been notified. They may send you a new signing link if required.
</Trans>
</p>
<p className="text-muted-foreground mt-2 max-w-[60ch] text-center text-sm">
<Trans>No further action is required from you at this time.</Trans>
</p>
{user && (
<Button className="mt-6" asChild>
<Link href={`/`}>Return Home</Link>
</Button>
)}
</div>
</div>
);
}

View File

@ -12,6 +12,7 @@ import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-f
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn'; import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
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 { isRecipientExpired } from '@documenso/lib/server-only/recipient/is-recipient-expired';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email'; import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { symmetricDecrypt } from '@documenso/lib/universal/crypto'; import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { extractNextHeaderRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
@ -43,6 +44,16 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders); const requestMetadata = extractNextHeaderRequestMetadata(requestHeaders);
const isExpired = await isRecipientExpired({ token });
if (isExpired) {
return redirect(`/sign/${token}/expired`);
}
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token });
if (!isRecipientsTurn) {
return redirect(`/sign/${token}/waiting`);
}
const [document, fields, recipient, completedFields] = await Promise.all([ const [document, fields, recipient, completedFields] = await Promise.all([
getDocumentAndSenderByToken({ getDocumentAndSenderByToken({
token, token,
@ -63,12 +74,6 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
return notFound(); return notFound();
} }
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token });
if (!isRecipientsTurn) {
return redirect(`/sign/${token}/waiting`);
}
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({ const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
documentAuth: document.authOptions, documentAuth: document.authOptions,
recipientAuth: recipient.authOptions, recipientAuth: recipient.authOptions,

View File

@ -8,9 +8,7 @@ import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized'; import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
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 { FieldType } from '@documenso/prisma/client';
import { Badge } from '@documenso/ui/primitives/badge'; import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@ -44,10 +42,7 @@ export default async function RejectedSigningPage({ params: { token } }: Rejecte
const truncatedTitle = truncateTitle(document.title); const truncatedTitle = truncateTitle(document.title);
const [fields, recipient] = await Promise.all([ const recipient = await getRecipientByToken({ token }).catch(() => null);
getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null),
]);
if (!recipient) { if (!recipient) {
return notFound(); return notFound();
@ -64,11 +59,6 @@ export default async function RejectedSigningPage({ params: { token } }: Rejecte
return <SigningAuthPageView email={recipient.email} />; return <SigningAuthPageView email={recipient.email} />;
} }
const recipientName =
recipient.name ||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
recipient.email;
return ( return (
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44"> <div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent"> <Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">

View File

@ -39,8 +39,10 @@ export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAva
classes = 'bg-documenso-200 text-documenso-800'; classes = 'bg-documenso-200 text-documenso-800';
break; break;
case RecipientStatusType.REJECTED: case RecipientStatusType.REJECTED:
case RecipientStatusType.EXPIRED:
classes = 'bg-red-200 text-red-800'; classes = 'bg-red-200 text-red-800';
break; break;
default: default:
break; break;
} }

View File

@ -50,6 +50,10 @@ export const StackAvatarsWithTooltip = ({
(recipient) => getRecipientType(recipient) === RecipientStatusType.REJECTED, (recipient) => getRecipientType(recipient) === RecipientStatusType.REJECTED,
); );
const expiredRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === RecipientStatusType.EXPIRED,
);
const sortedRecipients = useMemo(() => { const sortedRecipients = useMemo(() => {
const otherRecipients = recipients.filter( const otherRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) !== RecipientStatusType.REJECTED, (recipient) => getRecipientType(recipient) !== RecipientStatusType.REJECTED,
@ -119,6 +123,30 @@ export const StackAvatarsWithTooltip = ({
</div> </div>
)} )}
{expiredRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">
<Trans>Expired</Trans>
</h1>
{expiredRecipients.map((recipient: Recipient) => (
<div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div>
<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>
)}
{waitingRecipients.length > 0 && ( {waitingRecipients.length > 0 && (
<div> <div>
<h1 className="text-base font-medium"> <h1 className="text-base font-medium">

View File

@ -0,0 +1,71 @@
'use client';
import { useState } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
type DocumentExpirySettingsProps = {
onChange: (value: number | undefined, unit: 'day' | 'week' | 'month' | undefined) => void;
};
export const DocumentExpirySettings = ({ onChange }: DocumentExpirySettingsProps) => {
const [expiryValue, setExpiryValue] = useState<number | undefined>(undefined);
const [expiryUnit, setExpiryUnit] = useState<'day' | 'week' | 'month'>();
const { _ } = useLingui();
const handleExpiryValueChange = (value: string) => {
const parsedValue = parseInt(value, 10);
if (Number.isNaN(parsedValue) || parsedValue <= 0) {
setExpiryValue(undefined);
return;
} else {
setExpiryValue(parsedValue);
onChange(parsedValue, expiryUnit);
}
};
const handleExpiryUnitChange = (value: 'day' | 'week' | 'month') => {
setExpiryUnit(value);
onChange(expiryValue, value);
};
return (
<div className="mt-2 flex flex-row gap-4">
<Input
type="number"
placeholder={_(msg`Enter a number`)}
className="w-16"
value={expiryValue}
onChange={(e) => handleExpiryValueChange(e.target.value)}
min={1}
/>
<Select value={expiryUnit} onValueChange={handleExpiryUnitChange}>
<SelectTrigger className="text-muted-foreground">
<SelectValue placeholder={_(msg`Select...`)} />
</SelectTrigger>
<SelectContent>
<SelectItem value="day">
<Trans>Day</Trans>
</SelectItem>
<SelectItem value="week">
<Trans>Week</Trans>
</SelectItem>
<SelectItem value="month">
<Trans>Month</Trans>
</SelectItem>
</SelectContent>
</Select>
</div>
);
};

View File

@ -4,7 +4,7 @@ import { useState } from 'react';
import { Trans } from '@lingui/macro'; import { Trans } from '@lingui/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { Clock, EyeOffIcon } from 'lucide-react'; import { AlertTriangle, Clock, EyeOffIcon, Timer } from 'lucide-react';
import { P, match } from 'ts-pattern'; import { P, match } from 'ts-pattern';
import { import {
@ -75,6 +75,9 @@ export const DocumentReadOnlyFields = ({
variant={ variant={
field.Recipient.signingStatus === SigningStatus.SIGNED field.Recipient.signingStatus === SigningStatus.SIGNED
? 'default' ? 'default'
: field.Recipient.signingStatus === SigningStatus.REJECTED ||
field.Recipient.signingStatus === SigningStatus.EXPIRED
? 'destructive'
: 'secondary' : 'secondary'
} }
> >
@ -83,6 +86,16 @@ export const DocumentReadOnlyFields = ({
<SignatureIcon className="mr-1 h-3 w-3" /> <SignatureIcon className="mr-1 h-3 w-3" />
<Trans>Signed</Trans> <Trans>Signed</Trans>
</> </>
) : field.Recipient.signingStatus === SigningStatus.REJECTED ? (
<>
<AlertTriangle className="mr-1 h-3 w-3" />
<Trans>Rejected</Trans>
</>
) : field.Recipient.signingStatus === SigningStatus.EXPIRED ? (
<>
<Timer className="mr-1 h-3 w-3" />
<Trans>Expired</Trans>
</>
) : ( ) : (
<> <>
<Clock className="mr-1 h-3 w-3" /> <Clock className="mr-1 h-3 w-3" />

View File

@ -7,6 +7,7 @@ export enum RecipientStatusType {
WAITING = 'waiting', WAITING = 'waiting',
UNSIGNED = 'unsigned', UNSIGNED = 'unsigned',
REJECTED = 'rejected', REJECTED = 'rejected',
EXPIRED = 'expired',
} }
export const getRecipientType = (recipient: Recipient) => { export const getRecipientType = (recipient: Recipient) => {
@ -36,6 +37,10 @@ export const getRecipientType = (recipient: Recipient) => {
return RecipientStatusType.WAITING; return RecipientStatusType.WAITING;
} }
if (recipient.signingStatus === SigningStatus.EXPIRED) {
return RecipientStatusType.EXPIRED;
}
return RecipientStatusType.UNSIGNED; return RecipientStatusType.UNSIGNED;
}; };
@ -54,5 +59,9 @@ export const getExtraRecipientsType = (extraRecipients: Recipient[]) => {
return RecipientStatusType.WAITING; return RecipientStatusType.WAITING;
} }
if (types.includes(RecipientStatusType.EXPIRED)) {
return RecipientStatusType.EXPIRED;
}
return RecipientStatusType.COMPLETED; return RecipientStatusType.COMPLETED;
}; };

View File

@ -8,14 +8,15 @@ import {
RecipientRole, RecipientRole,
SendStatus, SendStatus,
SigningStatus, SigningStatus,
WebhookTriggerEvents,
} from '@documenso/prisma/client'; } from '@documenso/prisma/client';
import { WebhookTriggerEvents } from '@documenso/prisma/client';
import { jobs } from '../../jobs/client'; import { jobs } from '../../jobs/client';
import type { TRecipientActionAuth } from '../../types/document-auth'; import type { TRecipientActionAuth } from '../../types/document-auth';
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn'; import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
import { sendPendingEmail } from './send-pending-email'; import { sendPendingEmail } from './send-pending-email';
import { updateExpiredRecipients } from './update-expired-recipients';
export type CompleteDocumentWithTokenOptions = { export type CompleteDocumentWithTokenOptions = {
token: string; token: string;
@ -61,12 +62,22 @@ export const completeDocumentWithToken = async ({
throw new Error(`Document ${document.id} has no recipient with token ${token}`); throw new Error(`Document ${document.id} has no recipient with token ${token}`);
} }
await updateExpiredRecipients(documentId);
const [recipient] = document.Recipient; const [recipient] = document.Recipient;
if (recipient.expired && recipient.expired < new Date()) {
throw new Error(`Recipient ${recipient.id} signature period has expired`);
}
if (recipient.signingStatus === SigningStatus.SIGNED) { if (recipient.signingStatus === SigningStatus.SIGNED) {
throw new Error(`Recipient ${recipient.id} has already signed`); throw new Error(`Recipient ${recipient.id} has already signed`);
} }
if (recipient.signingStatus === SigningStatus.EXPIRED) {
throw new Error(`Recipient ${recipient.id} signature period has expired`);
}
if (document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) { if (document.documentMeta?.signingOrder === DocumentSigningOrder.SEQUENTIAL) {
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token: recipient.token }); const isRecipientsTurn = await getIsRecipientsTurnToSign({ token: recipient.token });

View File

@ -14,8 +14,8 @@ import type { RequestMetadata } from '@documenso/lib/universal/extract-request-m
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs'; import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
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, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import type { Prisma } from '@documenso/prisma/client'; import type { Prisma } from '@documenso/prisma/client';
import { DocumentStatus, RecipientRole, SigningStatus } from '@documenso/prisma/client';
import { getI18nInstance } from '../../client-only/providers/i18n.server'; import { getI18nInstance } from '../../client-only/providers/i18n.server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app'; import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
@ -166,6 +166,22 @@ export const resendDocument = async ({
await prisma.$transaction( await prisma.$transaction(
async (tx) => { async (tx) => {
if (recipient.expired) {
const durationInMs = recipient.expired.getTime() - document.updatedAt.getTime();
const newExpiryDate = new Date(Date.now() + durationInMs);
await tx.recipient.update({
where: { id: recipient.id },
data: {
expired: newExpiryDate,
signingStatus:
recipient.signingStatus === SigningStatus.EXPIRED
? SigningStatus.NOT_SIGNED
: recipient.signingStatus,
},
});
}
const [html, text] = await Promise.all([ const [html, text] = await Promise.all([
renderEmailWithI18N(template, { renderEmailWithI18N(template, {
lang: document.documentMeta?.language, lang: document.documentMeta?.language,

View File

@ -0,0 +1,55 @@
import { prisma } from '@documenso/prisma';
import { SigningStatus } from '@documenso/prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
export const updateExpiredRecipients = async (documentId: number) => {
const now = new Date();
const expiredRecipients = await prisma.recipient.findMany({
where: {
documentId,
expired: {
lt: now,
},
signingStatus: {
not: SigningStatus.EXPIRED,
},
},
});
if (expiredRecipients.length > 0) {
await prisma.recipient.updateMany({
where: {
id: {
in: expiredRecipients.map((recipient) => recipient.id),
},
},
data: {
signingStatus: SigningStatus.EXPIRED,
},
});
await prisma.documentAuditLog.createMany({
data: expiredRecipients.map((recipient) =>
createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRED,
documentId,
user: {
name: recipient.name,
email: recipient.email,
},
data: {
recipientName: recipient.name,
recipientRole: recipient.role,
recipientId: recipient.id,
recipientEmail: recipient.email,
},
}),
),
});
}
return expiredRecipients;
};

View File

@ -0,0 +1,36 @@
import { DateTime } from 'luxon';
import { prisma } from '@documenso/prisma';
import { SigningStatus } from '@documenso/prisma/client';
export type IsRecipientExpiredOptions = {
token: string;
};
export const isRecipientExpired = async ({ token }: IsRecipientExpiredOptions) => {
const recipient = await prisma.recipient.findFirst({
where: {
token,
},
});
if (!recipient) {
throw new Error('Recipient not found');
}
const now = DateTime.now();
const hasExpired = recipient.expired && DateTime.fromJSDate(recipient.expired) <= now;
if (hasExpired && recipient.signingStatus !== SigningStatus.EXPIRED) {
await prisma.recipient.update({
where: {
id: recipient.id,
},
data: {
signingStatus: SigningStatus.EXPIRED,
},
});
}
return hasExpired;
};

View File

@ -0,0 +1,112 @@
import { prisma } from '@documenso/prisma';
import type { Team } from '@documenso/prisma/client';
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData, diffRecipientChanges } from '../../utils/document-audit-logs';
export type SetRecipientExpiryOptions = {
documentId: number;
recipientId: number;
expiry: Date;
userId: number;
teamId?: number;
requestMetadata?: RequestMetadata;
};
export const setRecipientExpiry = async ({
documentId,
recipientId,
expiry,
userId,
teamId,
requestMetadata,
}: SetRecipientExpiryOptions) => {
const recipient = await prisma.recipient.findFirst({
where: {
id: recipientId,
Document: {
id: documentId,
...(teamId
? {
team: {
id: teamId,
members: {
some: {
userId,
},
},
},
}
: {
userId,
teamId: null,
}),
},
},
});
if (!recipient) {
throw new Error('Recipient not found');
}
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
},
});
let team: Team | null = null;
if (teamId) {
team = await prisma.team.findFirst({
where: {
id: teamId,
members: {
some: {
userId,
},
},
},
});
}
const updatedRecipient = await prisma.$transaction(async (tx) => {
const persisted = await tx.recipient.update({
where: {
id: recipient.id,
},
data: {
expired: new Date(expiry),
},
});
const changes = diffRecipientChanges(recipient, persisted);
if (changes.length > 0) {
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED,
documentId: documentId,
user: {
id: team?.id ?? user.id,
name: team?.name ?? user.name,
email: team ? '' : user.email,
},
requestMetadata,
data: {
changes,
recipientId,
recipientEmail: persisted.email,
recipientName: persisted.name,
recipientRole: persisted.role,
},
}),
});
}
return persisted;
});
return updatedRecipient;
};

View File

@ -34,6 +34,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'DOCUMENT_META_UPDATED', // When the document meta data is updated. 'DOCUMENT_META_UPDATED', // When the document meta data is updated.
'DOCUMENT_OPENED', // When the document is opened by a recipient. 'DOCUMENT_OPENED', // When the document is opened by a recipient.
'DOCUMENT_RECIPIENT_REJECTED', // When a recipient rejects the document. 'DOCUMENT_RECIPIENT_REJECTED', // When a recipient rejects the document.
'DOCUMENT_RECIPIENT_EXPIRED', // When the recipient cannot access the document anymore.
'DOCUMENT_RECIPIENT_COMPLETED', // When a recipient completes all their required tasks for the document. 'DOCUMENT_RECIPIENT_COMPLETED', // When a recipient completes all their required tasks for the document.
'DOCUMENT_SENT', // When the document transitions from DRAFT to PENDING. 'DOCUMENT_SENT', // When the document transitions from DRAFT to PENDING.
'DOCUMENT_TITLE_UPDATED', // When the document title is updated. 'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
@ -65,6 +66,7 @@ export const ZRecipientDiffTypeSchema = z.enum([
'EMAIL', 'EMAIL',
'ACCESS_AUTH', 'ACCESS_AUTH',
'ACTION_AUTH', 'ACTION_AUTH',
'EXPIRY',
]); ]);
export const DOCUMENT_AUDIT_LOG_TYPE = ZDocumentAuditLogTypeSchema.Enum; export const DOCUMENT_AUDIT_LOG_TYPE = ZDocumentAuditLogTypeSchema.Enum;
@ -146,12 +148,17 @@ export const ZRecipientDiffEmailSchema = ZGenericFromToSchema.extend({
type: z.literal(RECIPIENT_DIFF_TYPE.EMAIL), type: z.literal(RECIPIENT_DIFF_TYPE.EMAIL),
}); });
export const ZRecipientDiffExpirySchema = ZGenericFromToSchema.extend({
type: z.literal(RECIPIENT_DIFF_TYPE.EXPIRY),
});
export const ZDocumentAuditLogRecipientDiffSchema = z.discriminatedUnion('type', [ export const ZDocumentAuditLogRecipientDiffSchema = z.discriminatedUnion('type', [
ZRecipientDiffActionAuthSchema, ZRecipientDiffActionAuthSchema,
ZRecipientDiffAccessAuthSchema, ZRecipientDiffAccessAuthSchema,
ZRecipientDiffNameSchema, ZRecipientDiffNameSchema,
ZRecipientDiffRoleSchema, ZRecipientDiffRoleSchema,
ZRecipientDiffEmailSchema, ZRecipientDiffEmailSchema,
ZRecipientDiffExpirySchema,
]); ]);
const ZBaseFieldEventDataSchema = z.object({ const ZBaseFieldEventDataSchema = z.object({
@ -365,7 +372,7 @@ export const ZDocumentAuditLogEventDocumentRecipientCompleteSchema = z.object({
}); });
/** /**
* Event: Document recipient completed the document (the recipient has fully actioned and completed their required steps for the document). * Event: Document recipient rejected the document
*/ */
export const ZDocumentAuditLogEventDocumentRecipientRejectedSchema = z.object({ export const ZDocumentAuditLogEventDocumentRecipientRejectedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED), type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED),
@ -374,6 +381,14 @@ export const ZDocumentAuditLogEventDocumentRecipientRejectedSchema = z.object({
}), }),
}); });
/**
* Event: Recipient expired
*/
export const ZDocumentAuditLogEventDocumentRecipientExpiredSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRED),
data: ZBaseRecipientDataSchema,
});
/** /**
* Event: Document sent. * Event: Document sent.
*/ */
@ -499,6 +514,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventDocumentOpenedSchema, ZDocumentAuditLogEventDocumentOpenedSchema,
ZDocumentAuditLogEventDocumentRecipientCompleteSchema, ZDocumentAuditLogEventDocumentRecipientCompleteSchema,
ZDocumentAuditLogEventDocumentRecipientRejectedSchema, ZDocumentAuditLogEventDocumentRecipientRejectedSchema,
ZDocumentAuditLogEventDocumentRecipientExpiredSchema,
ZDocumentAuditLogEventDocumentSentSchema, ZDocumentAuditLogEventDocumentSentSchema,
ZDocumentAuditLogEventDocumentTitleUpdatedSchema, ZDocumentAuditLogEventDocumentTitleUpdatedSchema,
ZDocumentAuditLogEventDocumentExternalIdUpdatedSchema, ZDocumentAuditLogEventDocumentExternalIdUpdatedSchema,

View File

@ -1,5 +1,6 @@
import type { I18n } from '@lingui/core'; import { type I18n, i18n } from '@lingui/core';
import { msg } from '@lingui/macro'; import { msg } from '@lingui/macro';
import { DateTime } from 'luxon';
import { match } from 'ts-pattern'; import { match } from 'ts-pattern';
import type { DocumentAuditLog, DocumentMeta, Field, Recipient } from '@documenso/prisma/client'; import type { DocumentAuditLog, DocumentMeta, Field, Recipient } from '@documenso/prisma/client';
@ -73,7 +74,7 @@ export const parseDocumentAuditLogData = (auditLog: DocumentAuditLog): TDocument
return data.data; return data.data;
}; };
type PartialRecipient = Pick<Recipient, 'email' | 'name' | 'role' | 'authOptions'>; type PartialRecipient = Pick<Recipient, 'email' | 'name' | 'role' | 'authOptions' | 'expired'>;
export const diffRecipientChanges = ( export const diffRecipientChanges = (
oldRecipient: PartialRecipient, oldRecipient: PartialRecipient,
@ -131,6 +132,18 @@ export const diffRecipientChanges = (
}); });
} }
if (oldRecipient.expired !== newRecipient.expired) {
diffs.push({
type: RECIPIENT_DIFF_TYPE.EXPIRY,
from: DateTime.fromJSDate(oldRecipient.expired!)
.setLocale(i18n.locales?.[0] || i18n.locale)
.toLocaleString(DateTime.DATETIME_FULL),
to: DateTime.fromJSDate(newRecipient.expired!)
.setLocale(i18n.locales?.[0] || i18n.locale)
.toLocaleString(DateTime.DATETIME_FULL),
});
}
return diffs; return diffs;
}; };
@ -349,7 +362,7 @@ export const formatDocumentAuditLogAction = (
identified: result, identified: result,
}; };
}) })
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, ({ data }) => { .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED }, () => {
const userName = prefix || _(msg`Recipient`); const userName = prefix || _(msg`Recipient`);
const result = msg`${userName} rejected the document`; const result = msg`${userName} rejected the document`;
@ -359,6 +372,16 @@ export const formatDocumentAuditLogAction = (
identified: result, identified: result,
}; };
}) })
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRED }, () => {
const userName = prefix || _(msg`Recipient`);
const result = msg`${userName}'s signing period has expired`;
return {
anonymous: result,
identified: result,
};
})
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => ({ .with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => ({
anonymous: data.isResending ? msg`Email resent` : msg`Email sent`, anonymous: data.isResending ? msg`Email resent` : msg`Email sent`,
identified: data.isResending identified: data.isResending

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "SigningStatus" ADD VALUE 'EXPIRED';

View File

@ -394,6 +394,7 @@ enum SigningStatus {
NOT_SIGNED NOT_SIGNED
SIGNED SIGNED
REJECTED REJECTED
EXPIRED
} }
enum RecipientRole { enum RecipientRole {

View File

@ -2,6 +2,7 @@ import { TRPCError } from '@trpc/server';
import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token'; import { completeDocumentWithToken } from '@documenso/lib/server-only/document/complete-document-with-token';
import { rejectDocumentWithToken } from '@documenso/lib/server-only/document/reject-document-with-token'; import { rejectDocumentWithToken } from '@documenso/lib/server-only/document/reject-document-with-token';
import { setRecipientExpiry } from '@documenso/lib/server-only/recipient/set-recipient-expiry';
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document'; import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
import { setRecipientsForTemplate } from '@documenso/lib/server-only/recipient/set-recipients-for-template'; import { setRecipientsForTemplate } from '@documenso/lib/server-only/recipient/set-recipients-for-template';
import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
@ -12,6 +13,7 @@ import {
ZAddTemplateSignersMutationSchema, ZAddTemplateSignersMutationSchema,
ZCompleteDocumentWithTokenMutationSchema, ZCompleteDocumentWithTokenMutationSchema,
ZRejectDocumentWithTokenMutationSchema, ZRejectDocumentWithTokenMutationSchema,
ZSetSignerExpirySchema,
} from './schema'; } from './schema';
export const recipientRouter = router({ export const recipientRouter = router({
@ -45,6 +47,30 @@ export const recipientRouter = router({
} }
}), }),
setSignerExpiry: authenticatedProcedure
.input(ZSetSignerExpirySchema)
.mutation(async ({ input, ctx }) => {
try {
const { documentId, signerId, expiry, teamId } = input;
return await setRecipientExpiry({
documentId,
recipientId: signerId,
expiry,
teamId,
userId: ctx.user.id,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (error) {
console.log(error);
throw new TRPCError({
code: 'BAD_REQUEST',
message: "We're unable to set the expiry for this signer. Please try again later.",
});
}
}),
addTemplateSigners: authenticatedProcedure addTemplateSigners: authenticatedProcedure
.input(ZAddTemplateSignersMutationSchema) .input(ZAddTemplateSignersMutationSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {

View File

@ -80,3 +80,12 @@ export const ZRejectDocumentWithTokenMutationSchema = z.object({
export type TRejectDocumentWithTokenMutationSchema = z.infer< export type TRejectDocumentWithTokenMutationSchema = z.infer<
typeof ZRejectDocumentWithTokenMutationSchema typeof ZRejectDocumentWithTokenMutationSchema
>; >;
export const ZSetSignerExpirySchema = z.object({
documentId: z.number(),
signerId: z.number(),
expiry: z.date().min(new Date(), { message: 'Expiry date must be in the future' }),
teamId: z.number().optional(),
});
export type TSetSignerExpirySchema = z.infer<typeof ZSetSignerExpirySchema>;

View File

@ -0,0 +1,16 @@
import { differenceInDays, differenceInMonths, differenceInWeeks } from 'date-fns';
export const calculatePeriod = (expiryDate: Date) => {
const now = new Date();
const daysDiff = differenceInDays(expiryDate, now);
const weeksDiff = differenceInWeeks(expiryDate, now);
const monthsDiff = differenceInMonths(expiryDate, now);
if (monthsDiff > 0) {
return { amount: monthsDiff, unit: 'months' as const };
} else if (weeksDiff > 0) {
return { amount: weeksDiff, unit: 'weeks' as const };
} else {
return { amount: daysDiff, unit: 'days' as const };
}
};

View File

@ -29,17 +29,25 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
nav_button_next: 'absolute right-1', nav_button_next: 'absolute right-1',
table: 'w-full border-collapse space-y-1', table: 'w-full border-collapse space-y-1',
head_row: 'flex', head_row: 'flex',
head_cell: 'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]', head_cell: 'text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]',
row: 'flex w-full mt-2', row: 'flex w-full mt-2',
cell: 'text-center text-sm p-0 relative [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20', cell: cn(
'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md',
props.mode === 'range'
? '[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md'
: '[&:has([aria-selected])]:rounded-md',
),
day: cn( day: cn(
buttonVariants({ variant: 'ghost' }), buttonVariants({ variant: 'ghost' }),
'h-9 w-9 p-0 font-normal aria-selected:opacity-100', 'h-8 w-8 p-0 font-normal aria-selected:opacity-100',
), ),
day_range_start: 'day-range-start',
day_range_end: 'day-range-end',
day_selected: day_selected:
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground', 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
day_today: 'bg-accent text-accent-foreground', day_today: 'bg-accent text-accent-foreground',
day_outside: 'text-muted-foreground opacity-50', day_outside:
'day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground',
day_disabled: 'text-muted-foreground opacity-50', day_disabled: 'text-muted-foreground opacity-50',
day_range_middle: 'aria-selected:bg-accent aria-selected:text-accent-foreground', day_range_middle: 'aria-selected:bg-accent aria-selected:text-accent-foreground',
day_hidden: 'invisible', day_hidden: 'invisible',

View File

@ -135,13 +135,13 @@ DialogDescription.displayName = DialogPrimitive.Description.displayName;
export { export {
Dialog, Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogOverlay,
DialogTitle,
DialogDescription,
DialogPortal,
DialogClose, DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}; };

View File

@ -8,7 +8,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro'; import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react'; import { useLingui } from '@lingui/react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { GripVerticalIcon, Plus, Trash } from 'lucide-react'; import { GripVerticalIcon, Plus } from 'lucide-react';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { prop, sortBy } from 'remeda'; import { prop, sortBy } from 'remeda';
@ -41,6 +41,7 @@ import {
DocumentFlowFormContainerStep, DocumentFlowFormContainerStep,
} from './document-flow-root'; } from './document-flow-root';
import { ShowFieldItem } from './show-field-item'; import { ShowFieldItem } from './show-field-item';
import { SignerActionDropdown } from './signer-action-dropdown';
import type { DocumentFlowStep } from './types'; import type { DocumentFlowStep } from './types';
export type AddSignersFormProps = { export type AddSignersFormProps = {
@ -51,6 +52,7 @@ export type AddSignersFormProps = {
isDocumentEnterprise: boolean; isDocumentEnterprise: boolean;
onSubmit: (_data: TAddSignersFormSchema) => void; onSubmit: (_data: TAddSignersFormSchema) => void;
isDocumentPdfLoaded: boolean; isDocumentPdfLoaded: boolean;
documentId: number;
}; };
export const AddSignersFormPartial = ({ export const AddSignersFormPartial = ({
@ -61,6 +63,7 @@ export const AddSignersFormPartial = ({
isDocumentEnterprise, isDocumentEnterprise,
onSubmit, onSubmit,
isDocumentPdfLoaded, isDocumentPdfLoaded,
documentId,
}: AddSignersFormProps) => { }: AddSignersFormProps) => {
const { _ } = useLingui(); const { _ } = useLingui();
const { toast } = useToast(); const { toast } = useToast();
@ -81,6 +84,7 @@ export const AddSignersFormPartial = ({
email: '', email: '',
role: RecipientRole.SIGNER, role: RecipientRole.SIGNER,
signingOrder: 1, signingOrder: 1,
expiry: undefined,
actionAuth: undefined, actionAuth: undefined,
}, },
]; ];
@ -97,6 +101,7 @@ export const AddSignersFormPartial = ({
name: recipient.name, name: recipient.name,
email: recipient.email, email: recipient.email,
role: recipient.role, role: recipient.role,
expiry: recipient.expired ?? undefined,
signingOrder: recipient.signingOrder ?? index + 1, signingOrder: recipient.signingOrder ?? index + 1,
actionAuth: actionAuth:
ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined, ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
@ -181,6 +186,7 @@ export const AddSignersFormPartial = ({
email: '', email: '',
role: RecipientRole.SIGNER, role: RecipientRole.SIGNER,
actionAuth: undefined, actionAuth: undefined,
expiry: undefined,
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1, signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
}); });
}; };
@ -215,6 +221,7 @@ export const AddSignersFormPartial = ({
email: user?.email ?? '', email: user?.email ?? '',
role: RecipientRole.SIGNER, role: RecipientRole.SIGNER,
actionAuth: undefined, actionAuth: undefined,
expiry: undefined,
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1, signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
}); });
} }
@ -252,6 +259,7 @@ export const AddSignersFormPartial = ({
'email', 'email',
'name', 'name',
'role', 'role',
'expiry',
'signingOrder', 'signingOrder',
'actionAuth', 'actionAuth',
]; ];
@ -628,24 +636,20 @@ export const AddSignersFormPartial = ({
)} )}
/> />
<button <SignerActionDropdown
type="button" className={cn({
className={cn( 'mb-6': form.formState.errors.signers?.[index],
'mt-auto inline-flex h-10 w-10 items-center justify-center hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50', })}
{ onDelete={() => onRemoveSigner(index)}
'mb-6': form.formState.errors.signers?.[index], signer={signer}
}, documentId={documentId}
)} deleteDisabled={
disabled={
snapshot.isDragging || snapshot.isDragging ||
isSubmitting || isSubmitting ||
!canRecipientBeModified(signer.nativeId) || !canRecipientBeModified(signer.nativeId) ||
signers.length === 1 signers.length === 1
} }
onClick={() => onRemoveSigner(index)} />
>
<Trash className="h-4 w-4" />
</button>
</div> </div>
</motion.fieldset> </motion.fieldset>
</div> </div>

View File

@ -6,24 +6,23 @@ import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-a
import { ZMapNegativeOneToUndefinedSchema } from './add-settings.types'; import { ZMapNegativeOneToUndefinedSchema } from './add-settings.types';
import { DocumentSigningOrder, RecipientRole } from '.prisma/client'; import { DocumentSigningOrder, RecipientRole } from '.prisma/client';
export const ZAddSignerSchema = z.object({
formId: z.string().min(1),
nativeId: z.number().optional(),
email: z
.string()
.email({ message: msg`Invalid email`.id })
.min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
expiry: z.date().min(new Date(), { message: 'Expiry date must be in the future' }).optional(),
signingOrder: z.number().optional(),
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(ZRecipientActionAuthTypesSchema.optional()),
});
export const ZAddSignersFormSchema = z export const ZAddSignersFormSchema = z
.object({ .object({
signers: z.array( signers: z.array(ZAddSignerSchema),
z.object({
formId: z.string().min(1),
nativeId: z.number().optional(),
email: z
.string()
.email({ message: msg`Invalid email`.id })
.min(1),
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
actionAuth: ZMapNegativeOneToUndefinedSchema.pipe(
ZRecipientActionAuthTypesSchema.optional(),
),
}),
),
signingOrder: z.nativeEnum(DocumentSigningOrder), signingOrder: z.nativeEnum(DocumentSigningOrder),
}) })
.refine( .refine(
@ -37,3 +36,4 @@ export const ZAddSignersFormSchema = z
); );
export type TAddSignersFormSchema = z.infer<typeof ZAddSignersFormSchema>; export type TAddSignersFormSchema = z.infer<typeof ZAddSignersFormSchema>;
export type TAddSignerSchema = z.infer<typeof ZAddSignerSchema>;

View File

@ -0,0 +1,335 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { addDays, addMonths, addWeeks, format } from 'date-fns';
import { Calendar as CalendarIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Calendar } from '@documenso/ui/primitives/calendar';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { calculatePeriod } from '../../lib/calculate-period';
import { cn } from '../../lib/utils';
import { useToast } from '../use-toast';
import type { TAddSignerSchema as Signer } from './add-signers.types';
const dateFormSchema = z.object({
expiry: z.date({
required_error: 'Please select an expiry date.',
}),
});
const periodFormSchema = z.object({
amount: z.number().min(1, 'Please enter a number greater than 0.'),
unit: z.enum(['days', 'weeks', 'months']),
});
type DocumentExpiryDialogProps = {
open: boolean;
onOpenChange: (_open: boolean) => void;
signer: Signer;
documentId: number;
};
export function DocumentExpiryDialog({
open,
onOpenChange,
signer,
documentId,
}: DocumentExpiryDialogProps) {
const { _ } = useLingui();
const router = useRouter();
const { toast } = useToast();
const [activeTab, setActiveTab] = useState<'date' | 'period'>('date');
const dateForm = useForm<z.infer<typeof dateFormSchema>>({
resolver: zodResolver(dateFormSchema),
defaultValues: {
expiry: signer.expiry,
},
});
const periodForm = useForm<z.infer<typeof periodFormSchema>>({
resolver: zodResolver(periodFormSchema),
defaultValues: signer.expiry
? calculatePeriod(signer.expiry)
: {
amount: undefined,
unit: undefined,
},
});
const watchAmount = periodForm.watch('amount');
const watchUnit = periodForm.watch('unit');
const { mutateAsync: setSignerExpiry, isLoading } = trpc.recipient.setSignerExpiry.useMutation({
onSuccess: (updatedRecipient) => {
router.refresh();
periodForm.reset(
updatedRecipient?.expired
? calculatePeriod(updatedRecipient.expired)
: {
amount: undefined,
unit: undefined,
},
);
dateForm.reset(
{
expiry: updatedRecipient?.expired ?? undefined,
},
{
keepValues: false,
},
);
toast({
title: _(msg`Signer Expiry Set`),
description: _(msg`The expiry date for the signer has been set.`),
duration: 5000,
});
onOpenChange(false);
},
onError: (error) => {
toast({
title: _(msg`Error`),
description: error.message || _(msg`An error occurred while setting the expiry date.`),
variant: 'destructive',
duration: 7500,
});
},
});
const onSetExpiry = async (
values: z.infer<typeof dateFormSchema> | z.infer<typeof periodFormSchema>,
) => {
if (!signer.nativeId) {
return toast({
title: _(msg`Error`),
description: _(msg`An error occurred while setting the expiry date.`),
variant: 'destructive',
duration: 7500,
});
}
let expiryDate: Date;
if ('expiry' in values) {
expiryDate = values.expiry;
} else {
const now = new Date();
switch (values.unit) {
case 'days':
expiryDate = addDays(now, values.amount);
break;
case 'weeks':
expiryDate = addWeeks(now, values.amount);
break;
case 'months':
expiryDate = addMonths(now, values.amount);
break;
default:
throw new Error(`Invalid unit: ${values.unit}`);
}
}
await setSignerExpiry({
documentId,
signerId: signer.nativeId,
expiry: expiryDate,
});
// TODO: Duncan => Implement logic to update expiry when resending document
// This should be handled on the server-side when a document is resent
// TODO: Duncan => Implement logic to mark recipients as expired
// This should be a scheduled task or part of the completion process on the server
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[450px]">
<DialogHeader>
<DialogTitle>Set Recipient Expiry</DialogTitle>
<DialogDescription>
Set the expiry date for the document signing recipient. The recipient will not be able
to sign the document after this date.
</DialogDescription>
</DialogHeader>
<Tabs
value={activeTab}
onValueChange={(value) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return setActiveTab(value as 'date' | 'period');
}}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="date">Specific Date</TabsTrigger>
<TabsTrigger value="period">Time Period</TabsTrigger>
</TabsList>
<TabsContent value="date">
<Form {...dateForm}>
<form onSubmit={dateForm.handleSubmit(onSetExpiry)} className="space-y-8">
<FormField
control={dateForm.control}
name="expiry"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Expiry Date</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant={'outline'}
className={cn(
'w-[240px] pl-3 text-left font-normal',
!field.value && 'text-muted-foreground',
)}
>
{field.value ? format(field.value, 'PPP') : <span>Pick a date</span>}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="z-[1100] w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
disabled={(date) => date < new Date() || date < new Date('1900-01-01')}
initialFocus
/>
</PopoverContent>
</Popover>
<FormDescription>
The document will expire at 11:59 PM on the selected date.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button type="submit" loading={isLoading}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</TabsContent>
<TabsContent value="period">
<Form {...periodForm}>
<form onSubmit={periodForm.handleSubmit(onSetExpiry)} className="space-y-8">
<div className="flex space-x-4">
<FormField
control={periodForm.control}
name="amount"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Amount</FormLabel>
<FormControl>
<Select
onValueChange={(value) => field.onChange(parseInt(value, 10))}
value={watchAmount?.toString()}
>
<SelectTrigger>
<SelectValue placeholder="Select amount" />
</SelectTrigger>
<SelectContent>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => (
<SelectItem key={num} value={num.toString()}>
{num}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={periodForm.control}
name="unit"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Unit</FormLabel>
<FormControl>
<Select onValueChange={field.onChange} value={watchUnit}>
{' '}
<SelectTrigger>
<SelectValue placeholder="Select unit" />
</SelectTrigger>
<SelectContent>
<SelectItem value="days">Days</SelectItem>
<SelectItem value="weeks">Weeks</SelectItem>
<SelectItem value="months">Months</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormDescription>
The document will expire after the selected time period from now.
</FormDescription>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button type="submit" loading={isLoading}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}

View File

@ -36,7 +36,7 @@ export const ShowFieldItem = ({ field, recipients }: ShowFieldItemProps) => {
return createPortal( return createPortal(
<div <div
className={cn('pointer-events-none absolute z-10 opacity-75')} className={cn('pointer-events-none absolute opacity-75')}
style={{ style={{
top: `${coords.y}px`, top: `${coords.y}px`,
left: `${coords.x}px`, left: `${coords.x}px`,

View File

@ -0,0 +1,66 @@
'use client';
import { useState } from 'react';
import { MoreHorizontal, Timer, Trash } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { cn } from '../../lib/utils';
import type { TAddSignerSchema as Signer } from './add-signers.types';
import { DocumentExpiryDialog } from './document-expiry-dialog';
type SignerActionDropdownProps = {
onDelete: () => void;
deleteDisabled?: boolean;
className?: string;
signer: Signer;
documentId: number;
};
export function SignerActionDropdown({
deleteDisabled,
className,
signer,
documentId,
onDelete,
}: SignerActionDropdownProps) {
const [isExpiryDialogOpen, setExpiryDialogOpen] = useState(false);
return (
<>
<div className={cn('flex items-center justify-center', className)}>
<DropdownMenu>
<DropdownMenuTrigger>
<MoreHorizontal className="text-muted-foreground h-5 w-5" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuGroup>
<DropdownMenuItem className="gap-x-2" onClick={() => setExpiryDialogOpen(true)}>
<Timer className="h-4 w-4" />
Expiry
</DropdownMenuItem>
<DropdownMenuItem disabled={deleteDisabled} className="gap-x-2" onClick={onDelete}>
<Trash className="h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<DocumentExpiryDialog
open={isExpiryDialogOpen}
onOpenChange={setExpiryDialogOpen}
signer={signer}
documentId={documentId}
/>
</>
);
}

View File

@ -92,4 +92,4 @@ const PopoverHover = ({ trigger, children, contentProps }: PopoverHoverProps) =>
); );
}; };
export { Popover, PopoverTrigger, PopoverContent, PopoverHover }; export { Popover, PopoverContent, PopoverHover, PopoverTrigger };