mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
13 Commits
v1.12.0
...
expiry-lin
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b1b042097 | |||
| 31dc403500 | |||
| cfb57c8c27 | |||
| 2d7988f484 | |||
| ba627e22c5 | |||
| 6e9d17f8ea | |||
| 8491c69e8c | |||
| c422317566 | |||
| 316dbee446 | |||
| 79d0cd7de5 | |||
| e31a10a943 | |||
| ca2b6bea95 | |||
| 63830fb257 |
@ -12,13 +12,14 @@ import {
|
||||
MailOpenIcon,
|
||||
PenIcon,
|
||||
PlusIcon,
|
||||
Timer,
|
||||
} from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } 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 { SignatureIcon } from '@documenso/ui/icons/signature';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
@ -132,6 +133,14 @@ export const DocumentPageViewRecipients = ({
|
||||
</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 &&
|
||||
recipient.signingStatus === SigningStatus.REJECTED && (
|
||||
<PopoverHover
|
||||
|
||||
@ -15,9 +15,8 @@ import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/g
|
||||
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { DocumentStatus } 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 { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
@ -218,7 +217,7 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
||||
<DocumentPageViewDropdown document={documentWithRecipients} team={team} />
|
||||
</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)
|
||||
.with(DocumentStatus.COMPLETED, () => (
|
||||
<Trans>This document has been signed by all recipients</Trans>
|
||||
@ -228,8 +227,52 @@ export const DocumentPageView = async ({ params, team }: DocumentPageViewProps)
|
||||
))
|
||||
.with(DocumentStatus.PENDING, () => {
|
||||
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 (
|
||||
<Plural
|
||||
|
||||
@ -419,6 +419,8 @@ export const EditDocumentForm = ({
|
||||
isDocumentEnterprise={isDocumentEnterprise}
|
||||
onSubmit={onAddSignersFormSubmit}
|
||||
isDocumentPdfLoaded={isDocumentPdfLoaded}
|
||||
documentId={document.id}
|
||||
// teamId={team?.id}
|
||||
/>
|
||||
|
||||
<AddFieldsFormPartial
|
||||
|
||||
@ -100,7 +100,7 @@ export const ResendDocumentActionItem = ({
|
||||
});
|
||||
|
||||
setIsOpen(false);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`This document could not be re-sent at this time. Please try again.`),
|
||||
@ -177,12 +177,7 @@ export const ResendDocumentActionItem = ({
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
||||
variant="secondary"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Button type="button" className="flex-1" variant="secondary" disabled={isSubmitting}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
99
apps/web/src/app/(signing)/sign/[token]/expired/page.tsx
Normal file
99
apps/web/src/app/(signing)/sign/[token]/expired/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
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 { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||
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 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([
|
||||
getDocumentAndSenderByToken({
|
||||
token,
|
||||
@ -63,12 +74,6 @@ export default async function SigningPage({ params: { token } }: SigningPageProp
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token });
|
||||
|
||||
if (!isRecipientsTurn) {
|
||||
return redirect(`/sign/${token}/waiting`);
|
||||
}
|
||||
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: document.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
|
||||
@ -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 { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
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 { FieldType } from '@documenso/prisma/client';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
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 [fields, recipient] = await Promise.all([
|
||||
getFieldsForToken({ token }),
|
||||
getRecipientByToken({ token }).catch(() => null),
|
||||
]);
|
||||
const recipient = await getRecipientByToken({ token }).catch(() => null);
|
||||
|
||||
if (!recipient) {
|
||||
return notFound();
|
||||
@ -64,11 +59,6 @@ export default async function RejectedSigningPage({ params: { token } }: Rejecte
|
||||
return <SigningAuthPageView email={recipient.email} />;
|
||||
}
|
||||
|
||||
const recipientName =
|
||||
recipient.name ||
|
||||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
|
||||
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">
|
||||
|
||||
@ -39,8 +39,10 @@ export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAva
|
||||
classes = 'bg-documenso-200 text-documenso-800';
|
||||
break;
|
||||
case RecipientStatusType.REJECTED:
|
||||
case RecipientStatusType.EXPIRED:
|
||||
classes = 'bg-red-200 text-red-800';
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@ -50,6 +50,10 @@ export const StackAvatarsWithTooltip = ({
|
||||
(recipient) => getRecipientType(recipient) === RecipientStatusType.REJECTED,
|
||||
);
|
||||
|
||||
const expiredRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) === RecipientStatusType.EXPIRED,
|
||||
);
|
||||
|
||||
const sortedRecipients = useMemo(() => {
|
||||
const otherRecipients = recipients.filter(
|
||||
(recipient) => getRecipientType(recipient) !== RecipientStatusType.REJECTED,
|
||||
@ -119,6 +123,30 @@ export const StackAvatarsWithTooltip = ({
|
||||
</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 && (
|
||||
<div>
|
||||
<h1 className="text-base font-medium">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -4,7 +4,7 @@ import { useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
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 {
|
||||
@ -75,6 +75,9 @@ export const DocumentReadOnlyFields = ({
|
||||
variant={
|
||||
field.Recipient.signingStatus === SigningStatus.SIGNED
|
||||
? 'default'
|
||||
: field.Recipient.signingStatus === SigningStatus.REJECTED ||
|
||||
field.Recipient.signingStatus === SigningStatus.EXPIRED
|
||||
? 'destructive'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
@ -83,6 +86,16 @@ export const DocumentReadOnlyFields = ({
|
||||
<SignatureIcon className="mr-1 h-3 w-3" />
|
||||
<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" />
|
||||
|
||||
@ -7,6 +7,7 @@ export enum RecipientStatusType {
|
||||
WAITING = 'waiting',
|
||||
UNSIGNED = 'unsigned',
|
||||
REJECTED = 'rejected',
|
||||
EXPIRED = 'expired',
|
||||
}
|
||||
|
||||
export const getRecipientType = (recipient: Recipient) => {
|
||||
@ -36,6 +37,10 @@ export const getRecipientType = (recipient: Recipient) => {
|
||||
return RecipientStatusType.WAITING;
|
||||
}
|
||||
|
||||
if (recipient.signingStatus === SigningStatus.EXPIRED) {
|
||||
return RecipientStatusType.EXPIRED;
|
||||
}
|
||||
|
||||
return RecipientStatusType.UNSIGNED;
|
||||
};
|
||||
|
||||
@ -54,5 +59,9 @@ export const getExtraRecipientsType = (extraRecipients: Recipient[]) => {
|
||||
return RecipientStatusType.WAITING;
|
||||
}
|
||||
|
||||
if (types.includes(RecipientStatusType.EXPIRED)) {
|
||||
return RecipientStatusType.EXPIRED;
|
||||
}
|
||||
|
||||
return RecipientStatusType.COMPLETED;
|
||||
};
|
||||
|
||||
@ -8,14 +8,15 @@ import {
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
WebhookTriggerEvents,
|
||||
} from '@documenso/prisma/client';
|
||||
import { WebhookTriggerEvents } from '@documenso/prisma/client';
|
||||
|
||||
import { jobs } from '../../jobs/client';
|
||||
import type { TRecipientActionAuth } from '../../types/document-auth';
|
||||
import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn';
|
||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||
import { sendPendingEmail } from './send-pending-email';
|
||||
import { updateExpiredRecipients } from './update-expired-recipients';
|
||||
|
||||
export type CompleteDocumentWithTokenOptions = {
|
||||
token: string;
|
||||
@ -61,12 +62,22 @@ export const completeDocumentWithToken = async ({
|
||||
throw new Error(`Document ${document.id} has no recipient with token ${token}`);
|
||||
}
|
||||
|
||||
await updateExpiredRecipients(documentId);
|
||||
|
||||
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) {
|
||||
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) {
|
||||
const isRecipientsTurn = await getIsRecipientsTurnToSign({ token: recipient.token });
|
||||
|
||||
|
||||
@ -14,8 +14,8 @@ import type { RequestMetadata } from '@documenso/lib/universal/extract-request-m
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { renderCustomEmailTemplate } from '@documenso/lib/utils/render-custom-email-template';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, RecipientRole, SigningStatus } 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 { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||
@ -166,6 +166,22 @@ export const resendDocument = async ({
|
||||
|
||||
await prisma.$transaction(
|
||||
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([
|
||||
renderEmailWithI18N(template, {
|
||||
lang: document.documentMeta?.language,
|
||||
|
||||
@ -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;
|
||||
};
|
||||
36
packages/lib/server-only/recipient/is-recipient-expired.ts
Normal file
36
packages/lib/server-only/recipient/is-recipient-expired.ts
Normal 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;
|
||||
};
|
||||
112
packages/lib/server-only/recipient/set-recipient-expiry.ts
Normal file
112
packages/lib/server-only/recipient/set-recipient-expiry.ts
Normal 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;
|
||||
};
|
||||
@ -34,6 +34,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
|
||||
'DOCUMENT_META_UPDATED', // When the document meta data is updated.
|
||||
'DOCUMENT_OPENED', // When the document is opened by a recipient.
|
||||
'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_SENT', // When the document transitions from DRAFT to PENDING.
|
||||
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
|
||||
@ -65,6 +66,7 @@ export const ZRecipientDiffTypeSchema = z.enum([
|
||||
'EMAIL',
|
||||
'ACCESS_AUTH',
|
||||
'ACTION_AUTH',
|
||||
'EXPIRY',
|
||||
]);
|
||||
|
||||
export const DOCUMENT_AUDIT_LOG_TYPE = ZDocumentAuditLogTypeSchema.Enum;
|
||||
@ -146,12 +148,17 @@ export const ZRecipientDiffEmailSchema = ZGenericFromToSchema.extend({
|
||||
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', [
|
||||
ZRecipientDiffActionAuthSchema,
|
||||
ZRecipientDiffAccessAuthSchema,
|
||||
ZRecipientDiffNameSchema,
|
||||
ZRecipientDiffRoleSchema,
|
||||
ZRecipientDiffEmailSchema,
|
||||
ZRecipientDiffExpirySchema,
|
||||
]);
|
||||
|
||||
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({
|
||||
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.
|
||||
*/
|
||||
@ -499,6 +514,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
|
||||
ZDocumentAuditLogEventDocumentOpenedSchema,
|
||||
ZDocumentAuditLogEventDocumentRecipientCompleteSchema,
|
||||
ZDocumentAuditLogEventDocumentRecipientRejectedSchema,
|
||||
ZDocumentAuditLogEventDocumentRecipientExpiredSchema,
|
||||
ZDocumentAuditLogEventDocumentSentSchema,
|
||||
ZDocumentAuditLogEventDocumentTitleUpdatedSchema,
|
||||
ZDocumentAuditLogEventDocumentExternalIdUpdatedSchema,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { I18n } from '@lingui/core';
|
||||
import { type I18n, i18n } from '@lingui/core';
|
||||
import { msg } from '@lingui/macro';
|
||||
import { DateTime } from 'luxon';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import type { DocumentAuditLog, DocumentMeta, Field, Recipient } from '@documenso/prisma/client';
|
||||
@ -73,7 +74,7 @@ export const parseDocumentAuditLogData = (auditLog: DocumentAuditLog): TDocument
|
||||
return data.data;
|
||||
};
|
||||
|
||||
type PartialRecipient = Pick<Recipient, 'email' | 'name' | 'role' | 'authOptions'>;
|
||||
type PartialRecipient = Pick<Recipient, 'email' | 'name' | 'role' | 'authOptions' | 'expired'>;
|
||||
|
||||
export const diffRecipientChanges = (
|
||||
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;
|
||||
};
|
||||
|
||||
@ -349,7 +362,7 @@ export const formatDocumentAuditLogAction = (
|
||||
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 result = msg`${userName} rejected the document`;
|
||||
@ -359,6 +372,16 @@ export const formatDocumentAuditLogAction = (
|
||||
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 }) => ({
|
||||
anonymous: data.isResending ? msg`Email resent` : msg`Email sent`,
|
||||
identified: data.isResending
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "SigningStatus" ADD VALUE 'EXPIRED';
|
||||
@ -394,6 +394,7 @@ enum SigningStatus {
|
||||
NOT_SIGNED
|
||||
SIGNED
|
||||
REJECTED
|
||||
EXPIRED
|
||||
}
|
||||
|
||||
enum RecipientRole {
|
||||
|
||||
@ -2,6 +2,7 @@ import { TRPCError } from '@trpc/server';
|
||||
|
||||
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 { setRecipientExpiry } from '@documenso/lib/server-only/recipient/set-recipient-expiry';
|
||||
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 { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
@ -12,6 +13,7 @@ import {
|
||||
ZAddTemplateSignersMutationSchema,
|
||||
ZCompleteDocumentWithTokenMutationSchema,
|
||||
ZRejectDocumentWithTokenMutationSchema,
|
||||
ZSetSignerExpirySchema,
|
||||
} from './schema';
|
||||
|
||||
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
|
||||
.input(ZAddTemplateSignersMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
|
||||
@ -80,3 +80,12 @@ export const ZRejectDocumentWithTokenMutationSchema = z.object({
|
||||
export type TRejectDocumentWithTokenMutationSchema = z.infer<
|
||||
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>;
|
||||
|
||||
16
packages/ui/lib/calculate-period.ts
Normal file
16
packages/ui/lib/calculate-period.ts
Normal 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 };
|
||||
}
|
||||
};
|
||||
@ -29,17 +29,25 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
|
||||
nav_button_next: 'absolute right-1',
|
||||
table: 'w-full border-collapse space-y-1',
|
||||
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',
|
||||
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(
|
||||
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:
|
||||
'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_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_range_middle: 'aria-selected:bg-accent aria-selected:text-accent-foreground',
|
||||
day_hidden: 'invisible',
|
||||
|
||||
@ -135,13 +135,13 @@ DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogOverlay,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogPortal,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
|
||||
@ -8,7 +8,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, msg } from '@lingui/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
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 { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { prop, sortBy } from 'remeda';
|
||||
@ -41,6 +41,7 @@ import {
|
||||
DocumentFlowFormContainerStep,
|
||||
} from './document-flow-root';
|
||||
import { ShowFieldItem } from './show-field-item';
|
||||
import { SignerActionDropdown } from './signer-action-dropdown';
|
||||
import type { DocumentFlowStep } from './types';
|
||||
|
||||
export type AddSignersFormProps = {
|
||||
@ -51,6 +52,7 @@ export type AddSignersFormProps = {
|
||||
isDocumentEnterprise: boolean;
|
||||
onSubmit: (_data: TAddSignersFormSchema) => void;
|
||||
isDocumentPdfLoaded: boolean;
|
||||
documentId: number;
|
||||
};
|
||||
|
||||
export const AddSignersFormPartial = ({
|
||||
@ -61,6 +63,7 @@ export const AddSignersFormPartial = ({
|
||||
isDocumentEnterprise,
|
||||
onSubmit,
|
||||
isDocumentPdfLoaded,
|
||||
documentId,
|
||||
}: AddSignersFormProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
@ -81,6 +84,7 @@ export const AddSignersFormPartial = ({
|
||||
email: '',
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder: 1,
|
||||
expiry: undefined,
|
||||
actionAuth: undefined,
|
||||
},
|
||||
];
|
||||
@ -97,6 +101,7 @@ export const AddSignersFormPartial = ({
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
role: recipient.role,
|
||||
expiry: recipient.expired ?? undefined,
|
||||
signingOrder: recipient.signingOrder ?? index + 1,
|
||||
actionAuth:
|
||||
ZRecipientAuthOptionsSchema.parse(recipient.authOptions)?.actionAuth ?? undefined,
|
||||
@ -181,6 +186,7 @@ export const AddSignersFormPartial = ({
|
||||
email: '',
|
||||
role: RecipientRole.SIGNER,
|
||||
actionAuth: undefined,
|
||||
expiry: undefined,
|
||||
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
|
||||
});
|
||||
};
|
||||
@ -215,6 +221,7 @@ export const AddSignersFormPartial = ({
|
||||
email: user?.email ?? '',
|
||||
role: RecipientRole.SIGNER,
|
||||
actionAuth: undefined,
|
||||
expiry: undefined,
|
||||
signingOrder: signers.length > 0 ? (signers[signers.length - 1]?.signingOrder ?? 0) + 1 : 1,
|
||||
});
|
||||
}
|
||||
@ -252,6 +259,7 @@ export const AddSignersFormPartial = ({
|
||||
'email',
|
||||
'name',
|
||||
'role',
|
||||
'expiry',
|
||||
'signingOrder',
|
||||
'actionAuth',
|
||||
];
|
||||
@ -628,24 +636,20 @@ export const AddSignersFormPartial = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'mt-auto inline-flex h-10 w-10 items-center justify-center hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
{
|
||||
'mb-6': form.formState.errors.signers?.[index],
|
||||
},
|
||||
)}
|
||||
disabled={
|
||||
<SignerActionDropdown
|
||||
className={cn({
|
||||
'mb-6': form.formState.errors.signers?.[index],
|
||||
})}
|
||||
onDelete={() => onRemoveSigner(index)}
|
||||
signer={signer}
|
||||
documentId={documentId}
|
||||
deleteDisabled={
|
||||
snapshot.isDragging ||
|
||||
isSubmitting ||
|
||||
!canRecipientBeModified(signer.nativeId) ||
|
||||
signers.length === 1
|
||||
}
|
||||
onClick={() => onRemoveSigner(index)}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</motion.fieldset>
|
||||
</div>
|
||||
|
||||
@ -6,24 +6,23 @@ import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-a
|
||||
import { ZMapNegativeOneToUndefinedSchema } from './add-settings.types';
|
||||
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
|
||||
.object({
|
||||
signers: z.array(
|
||||
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(),
|
||||
),
|
||||
}),
|
||||
),
|
||||
signers: z.array(ZAddSignerSchema),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder),
|
||||
})
|
||||
.refine(
|
||||
@ -37,3 +36,4 @@ export const ZAddSignersFormSchema = z
|
||||
);
|
||||
|
||||
export type TAddSignersFormSchema = z.infer<typeof ZAddSignersFormSchema>;
|
||||
export type TAddSignerSchema = z.infer<typeof ZAddSignerSchema>;
|
||||
|
||||
335
packages/ui/primitives/document-flow/document-expiry-dialog.tsx
Normal file
335
packages/ui/primitives/document-flow/document-expiry-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -36,7 +36,7 @@ export const ShowFieldItem = ({ field, recipients }: ShowFieldItemProps) => {
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={cn('pointer-events-none absolute z-10 opacity-75')}
|
||||
className={cn('pointer-events-none absolute opacity-75')}
|
||||
style={{
|
||||
top: `${coords.y}px`,
|
||||
left: `${coords.x}px`,
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -92,4 +92,4 @@ const PopoverHover = ({ trigger, children, contentProps }: PopoverHoverProps) =>
|
||||
);
|
||||
};
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverHover };
|
||||
export { Popover, PopoverContent, PopoverHover, PopoverTrigger };
|
||||
|
||||
Reference in New Issue
Block a user