mirror of
https://github.com/documenso/documenso.git
synced 2025-11-15 09:12:02 +10:00
feat: expiry links
This commit is contained in:
@ -198,6 +198,8 @@ export const DocumentEditForm = ({
|
|||||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
|
expiryAmount: data.meta.expiryAmount,
|
||||||
|
expiryUnit: data.meta.expiryUnit,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -158,6 +158,14 @@ export const DocumentPageViewRecipients = ({
|
|||||||
</PopoverHover>
|
</PopoverHover>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{document.status !== DocumentStatus.DRAFT &&
|
||||||
|
recipient.signingStatus === SigningStatus.EXPIRED && (
|
||||||
|
<Badge variant="warning">
|
||||||
|
<Clock className="mr-1 h-3 w-3" />
|
||||||
|
<Trans>Expired</Trans>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
{document.status === DocumentStatus.PENDING &&
|
{document.status === DocumentStatus.PENDING &&
|
||||||
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
|
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
|
||||||
recipient.role !== RecipientRole.CC && (
|
recipient.role !== RecipientRole.CC && (
|
||||||
|
|||||||
@ -41,6 +41,9 @@ export const StackAvatar = ({ first, zIndex, fallbackText = '', type }: StackAva
|
|||||||
case RecipientStatusType.REJECTED:
|
case RecipientStatusType.REJECTED:
|
||||||
classes = 'bg-red-200 text-red-800';
|
classes = 'bg-red-200 text-red-800';
|
||||||
break;
|
break;
|
||||||
|
case RecipientStatusType.EXPIRED:
|
||||||
|
classes = 'bg-orange-200 text-orange-800';
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,13 +48,20 @@ 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 &&
|
||||||
|
getRecipientType(recipient) !== RecipientStatusType.EXPIRED,
|
||||||
);
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...rejectedRecipients.sort((a, b) => a.id - b.id),
|
...rejectedRecipients.sort((a, b) => a.id - b.id),
|
||||||
|
...expiredRecipients.sort((a, b) => a.id - b.id),
|
||||||
...otherRecipients.sort((a, b) => {
|
...otherRecipients.sort((a, b) => {
|
||||||
return a.id - b.id;
|
return a.id - b.id;
|
||||||
}),
|
}),
|
||||||
@ -117,6 +124,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">
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { msg } from '@lingui/core/macro';
|
|||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
|
import { CheckCircle, Clock, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@ -36,6 +36,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
|||||||
const isPending = row.status === DocumentStatus.PENDING;
|
const isPending = row.status === DocumentStatus.PENDING;
|
||||||
const isComplete = isDocumentCompleted(row.status);
|
const isComplete = isDocumentCompleted(row.status);
|
||||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
|
const isExpired = recipient?.signingStatus === SigningStatus.EXPIRED;
|
||||||
const role = recipient?.role;
|
const role = recipient?.role;
|
||||||
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
const isCurrentTeamDocument = team && row.team?.url === team.url;
|
||||||
|
|
||||||
@ -87,8 +88,15 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
|||||||
isPending,
|
isPending,
|
||||||
isComplete,
|
isComplete,
|
||||||
isSigned,
|
isSigned,
|
||||||
|
isExpired,
|
||||||
isCurrentTeamDocument,
|
isCurrentTeamDocument,
|
||||||
})
|
})
|
||||||
|
.with({ isRecipient: true, isExpired: true }, () => (
|
||||||
|
<Button className="w-32 bg-orange-100 text-orange-600 hover:bg-orange-200" disabled={true}>
|
||||||
|
<Clock className="-ml-1 mr-2 h-4 w-4" />
|
||||||
|
<Trans>Expired</Trans>
|
||||||
|
</Button>
|
||||||
|
))
|
||||||
.with(
|
.with(
|
||||||
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
|
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
|
||||||
() => (
|
() => (
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { useLingui } from '@lingui/react';
|
|||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus as DocumentStatusEnum } from '@prisma/client';
|
import { DocumentStatus as DocumentStatusEnum } from '@prisma/client';
|
||||||
import { RecipientRole, SigningStatus } from '@prisma/client';
|
import { RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
import { CheckCircleIcon, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
|
import { CheckCircleIcon, Clock, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { Link, useSearchParams } from 'react-router';
|
import { Link, useSearchParams } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
@ -194,6 +194,7 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
|
|||||||
const isPending = row.status === DocumentStatusEnum.PENDING;
|
const isPending = row.status === DocumentStatusEnum.PENDING;
|
||||||
const isComplete = isDocumentCompleted(row.status);
|
const isComplete = isDocumentCompleted(row.status);
|
||||||
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
|
||||||
|
const isExpired = recipient?.signingStatus === SigningStatus.EXPIRED;
|
||||||
const role = recipient?.role;
|
const role = recipient?.role;
|
||||||
|
|
||||||
if (!recipient) {
|
if (!recipient) {
|
||||||
@ -231,7 +232,14 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
|
|||||||
isPending,
|
isPending,
|
||||||
isComplete,
|
isComplete,
|
||||||
isSigned,
|
isSigned,
|
||||||
|
isExpired,
|
||||||
})
|
})
|
||||||
|
.with({ isExpired: true }, () => (
|
||||||
|
<Button className="w-32 bg-orange-100 text-orange-600 hover:bg-orange-200" disabled={true}>
|
||||||
|
<Clock className="-ml-1 mr-2 h-4 w-4" />
|
||||||
|
<Trans>Expired</Trans>
|
||||||
|
</Button>
|
||||||
|
))
|
||||||
.with({ isPending: true, isSigned: false }, () => (
|
.with({ isPending: true, isSigned: false }, () => (
|
||||||
<Button className="w-32" asChild>
|
<Button className="w-32" asChild>
|
||||||
<Link to={`/sign/${recipient?.token}`}>
|
<Link to={`/sign/${recipient?.token}`}>
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-re
|
|||||||
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
|
||||||
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
|
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
|
||||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||||
|
import { expireRecipient } from '@documenso/lib/server-only/recipient/expire-recipient';
|
||||||
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 { getNextPendingRecipient } from '@documenso/lib/server-only/recipient/get-next-pending-recipient';
|
import { getNextPendingRecipient } from '@documenso/lib/server-only/recipient/get-next-pending-recipient';
|
||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
@ -20,6 +21,7 @@ import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/
|
|||||||
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
|
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
|
||||||
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 { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
|
import { isRecipientExpired } from '@documenso/lib/utils/expiry';
|
||||||
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||||
|
|
||||||
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
|
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
|
||||||
@ -127,6 +129,11 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
|||||||
|
|
||||||
const { documentMeta } = document;
|
const { documentMeta } = document;
|
||||||
|
|
||||||
|
if (isRecipientExpired(recipient)) {
|
||||||
|
await expireRecipient({ recipientId: recipient.id });
|
||||||
|
throw redirect(`/sign/${token}/expired`);
|
||||||
|
}
|
||||||
|
|
||||||
if (recipient.signingStatus === SigningStatus.REJECTED) {
|
if (recipient.signingStatus === SigningStatus.REJECTED) {
|
||||||
throw redirect(`/sign/${token}/rejected`);
|
throw redirect(`/sign/${token}/rejected`);
|
||||||
}
|
}
|
||||||
|
|||||||
141
apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
Normal file
141
apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { FieldType } from '@prisma/client';
|
||||||
|
import { Clock8 } from 'lucide-react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { useOptionalSession } from '@documenso/lib/client-only/providers/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 { isRecipientExpired } from '@documenso/lib/utils/expiry';
|
||||||
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
|
||||||
|
import { truncateTitle } from '~/utils/truncate-title';
|
||||||
|
|
||||||
|
import type { Route } from './+types/expired';
|
||||||
|
|
||||||
|
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||||
|
const { user } = await getOptionalSession(request);
|
||||||
|
|
||||||
|
const { token } = params;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = await getDocumentAndSenderByToken({
|
||||||
|
token,
|
||||||
|
requireAccessAuth: false,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!document) {
|
||||||
|
throw new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncatedTitle = truncateTitle(document.title);
|
||||||
|
|
||||||
|
const [fields, recipient] = await Promise.all([
|
||||||
|
getFieldsForToken({ token }),
|
||||||
|
getRecipientByToken({ token }).catch(() => null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
throw new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecipientExpired(recipient)) {
|
||||||
|
throw new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDocumentAccessValid = await isRecipientAuthorized({
|
||||||
|
type: 'ACCESS',
|
||||||
|
documentAuthOptions: document.authOptions,
|
||||||
|
recipient,
|
||||||
|
userId: user?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recipientReference =
|
||||||
|
recipient.name ||
|
||||||
|
fields.find((field) => field.type === FieldType.NAME)?.customText ||
|
||||||
|
recipient.email;
|
||||||
|
|
||||||
|
if (isDocumentAccessValid) {
|
||||||
|
return {
|
||||||
|
isDocumentAccessValid: true,
|
||||||
|
recipientReference,
|
||||||
|
truncatedTitle,
|
||||||
|
recipient,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't leak data if access is denied.
|
||||||
|
return {
|
||||||
|
isDocumentAccessValid: false,
|
||||||
|
recipientReference,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SigningExpiredPage({ loaderData }: Route.ComponentProps) {
|
||||||
|
const { sessionData } = useOptionalSession();
|
||||||
|
const user = sessionData?.user;
|
||||||
|
|
||||||
|
const { isDocumentAccessValid, recipientReference, truncatedTitle, recipient } = loaderData;
|
||||||
|
|
||||||
|
if (!isDocumentAccessValid) {
|
||||||
|
return <DocumentSigningAuthPageView email={recipientReference} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
<Clock8 className="h-10 w-10 text-orange-500" />
|
||||||
|
|
||||||
|
<h2 className="max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||||
|
<Trans>Signing Link Expired</Trans>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center text-center text-sm text-orange-600">
|
||||||
|
<Trans>This signing link is no longer valid</Trans>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-6 max-w-[60ch] text-center text-sm">
|
||||||
|
<Trans>
|
||||||
|
The signing link has expired and can no longer be used to sign the document. Please
|
||||||
|
contact the document sender if you need a new signing link.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{recipient?.expired && (
|
||||||
|
<p className="text-muted-foreground mt-2 max-w-[60ch] text-center text-sm">
|
||||||
|
<Trans>
|
||||||
|
Expired on:{' '}
|
||||||
|
{new Date(recipient.expired).toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<Button className="mt-6" asChild>
|
||||||
|
<Link to={`/`}>Return Home</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -13,6 +13,7 @@ export enum RecipientStatusType {
|
|||||||
WAITING = 'waiting',
|
WAITING = 'waiting',
|
||||||
UNSIGNED = 'unsigned',
|
UNSIGNED = 'unsigned',
|
||||||
REJECTED = 'rejected',
|
REJECTED = 'rejected',
|
||||||
|
EXPIRED = 'expired',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getRecipientType = (
|
export const getRecipientType = (
|
||||||
@ -27,6 +28,10 @@ export const getRecipientType = (
|
|||||||
return RecipientStatusType.REJECTED;
|
return RecipientStatusType.REJECTED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (recipient.signingStatus === SigningStatus.EXPIRED) {
|
||||||
|
return RecipientStatusType.EXPIRED;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
recipient.readStatus === ReadStatus.OPENED &&
|
recipient.readStatus === ReadStatus.OPENED &&
|
||||||
recipient.signingStatus === SigningStatus.NOT_SIGNED
|
recipient.signingStatus === SigningStatus.NOT_SIGNED
|
||||||
@ -52,6 +57,10 @@ export const getExtraRecipientsType = (extraRecipients: Recipient[]) => {
|
|||||||
return RecipientStatusType.UNSIGNED;
|
return RecipientStatusType.UNSIGNED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (types.includes(RecipientStatusType.EXPIRED)) {
|
||||||
|
return RecipientStatusType.EXPIRED;
|
||||||
|
}
|
||||||
|
|
||||||
if (types.includes(RecipientStatusType.OPENED)) {
|
if (types.includes(RecipientStatusType.OPENED)) {
|
||||||
return RecipientStatusType.OPENED;
|
return RecipientStatusType.OPENED;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ export const getRecipientsStats = async () => {
|
|||||||
[SigningStatus.SIGNED]: 0,
|
[SigningStatus.SIGNED]: 0,
|
||||||
[SigningStatus.NOT_SIGNED]: 0,
|
[SigningStatus.NOT_SIGNED]: 0,
|
||||||
[SigningStatus.REJECTED]: 0,
|
[SigningStatus.REJECTED]: 0,
|
||||||
|
[SigningStatus.EXPIRED]: 0,
|
||||||
[SendStatus.SENT]: 0,
|
[SendStatus.SENT]: 0,
|
||||||
[SendStatus.NOT_SENT]: 0,
|
[SendStatus.NOT_SENT]: 0,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
createDocumentAuditLogData,
|
createDocumentAuditLogData,
|
||||||
diffDocumentMetaChanges,
|
diffDocumentMetaChanges,
|
||||||
} from '@documenso/lib/utils/document-audit-logs';
|
} from '@documenso/lib/utils/document-audit-logs';
|
||||||
|
import { calculateRecipientExpiry } from '@documenso/lib/utils/expiry';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import type { SupportedLanguageCodes } from '../../constants/i18n';
|
import type { SupportedLanguageCodes } from '../../constants/i18n';
|
||||||
@ -33,6 +34,8 @@ export type CreateDocumentMetaOptions = {
|
|||||||
uploadSignatureEnabled?: boolean;
|
uploadSignatureEnabled?: boolean;
|
||||||
drawSignatureEnabled?: boolean;
|
drawSignatureEnabled?: boolean;
|
||||||
language?: SupportedLanguageCodes;
|
language?: SupportedLanguageCodes;
|
||||||
|
expiryAmount?: number;
|
||||||
|
expiryUnit?: string;
|
||||||
requestMetadata: ApiRequestMetadata;
|
requestMetadata: ApiRequestMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -56,6 +59,8 @@ export const upsertDocumentMeta = async ({
|
|||||||
uploadSignatureEnabled,
|
uploadSignatureEnabled,
|
||||||
drawSignatureEnabled,
|
drawSignatureEnabled,
|
||||||
language,
|
language,
|
||||||
|
expiryAmount,
|
||||||
|
expiryUnit,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
}: CreateDocumentMetaOptions) => {
|
}: CreateDocumentMetaOptions) => {
|
||||||
const { documentWhereInput, team } = await getDocumentWhereInput({
|
const { documentWhereInput, team } = await getDocumentWhereInput({
|
||||||
@ -118,6 +123,8 @@ export const upsertDocumentMeta = async ({
|
|||||||
uploadSignatureEnabled,
|
uploadSignatureEnabled,
|
||||||
drawSignatureEnabled,
|
drawSignatureEnabled,
|
||||||
language,
|
language,
|
||||||
|
expiryAmount,
|
||||||
|
expiryUnit,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
subject,
|
subject,
|
||||||
@ -136,9 +143,30 @@ export const upsertDocumentMeta = async ({
|
|||||||
uploadSignatureEnabled,
|
uploadSignatureEnabled,
|
||||||
drawSignatureEnabled,
|
drawSignatureEnabled,
|
||||||
language,
|
language,
|
||||||
|
expiryAmount,
|
||||||
|
expiryUnit,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (expiryAmount !== undefined || expiryUnit !== undefined) {
|
||||||
|
const newExpiryDate = calculateRecipientExpiry(
|
||||||
|
upsertedDocumentMeta.expiryAmount,
|
||||||
|
upsertedDocumentMeta.expiryUnit,
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tx.recipient.updateMany({
|
||||||
|
where: {
|
||||||
|
documentId,
|
||||||
|
signingStatus: { not: 'SIGNED' },
|
||||||
|
role: { not: 'CC' },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
expired: newExpiryDate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const changes = diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta);
|
const changes = diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta);
|
||||||
|
|
||||||
if (changes.length > 0) {
|
if (changes.length > 0) {
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
|||||||
import { extractDerivedDocumentMeta } from '../../utils/document';
|
import { extractDerivedDocumentMeta } from '../../utils/document';
|
||||||
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
|
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
|
||||||
import { determineDocumentVisibility } from '../../utils/document-visibility';
|
import { determineDocumentVisibility } from '../../utils/document-visibility';
|
||||||
|
import { calculateRecipientExpiry } from '../../utils/expiry';
|
||||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||||
import { getMemberRoles } from '../team/get-member-roles';
|
import { getMemberRoles } from '../team/get-member-roles';
|
||||||
import { getTeamSettings } from '../team/get-team-settings';
|
import { getTeamSettings } from '../team/get-team-settings';
|
||||||
@ -45,6 +46,8 @@ export type CreateDocumentOptions = {
|
|||||||
globalActionAuth?: TDocumentActionAuthTypes[];
|
globalActionAuth?: TDocumentActionAuthTypes[];
|
||||||
formValues?: TDocumentFormValues;
|
formValues?: TDocumentFormValues;
|
||||||
recipients: TCreateDocumentV2Request['recipients'];
|
recipients: TCreateDocumentV2Request['recipients'];
|
||||||
|
expiryAmount?: number;
|
||||||
|
expiryUnit?: string;
|
||||||
};
|
};
|
||||||
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
|
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
|
||||||
requestMetadata: ApiRequestMetadata;
|
requestMetadata: ApiRequestMetadata;
|
||||||
@ -167,7 +170,11 @@ export const createDocumentV2 = async ({
|
|||||||
formValues,
|
formValues,
|
||||||
source: DocumentSource.DOCUMENT,
|
source: DocumentSource.DOCUMENT,
|
||||||
documentMeta: {
|
documentMeta: {
|
||||||
create: extractDerivedDocumentMeta(settings, meta),
|
create: extractDerivedDocumentMeta(settings, {
|
||||||
|
...meta,
|
||||||
|
expiryAmount: data.expiryAmount,
|
||||||
|
expiryUnit: data.expiryUnit,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -179,6 +186,12 @@ export const createDocumentV2 = async ({
|
|||||||
actionAuth: recipient.actionAuth ?? [],
|
actionAuth: recipient.actionAuth ?? [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const expiryDate = calculateRecipientExpiry(
|
||||||
|
data.expiryAmount ?? null,
|
||||||
|
data.expiryUnit ?? null,
|
||||||
|
new Date(), // Calculate from current time
|
||||||
|
);
|
||||||
|
|
||||||
await tx.recipient.create({
|
await tx.recipient.create({
|
||||||
data: {
|
data: {
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
@ -191,6 +204,7 @@ export const createDocumentV2 = async ({
|
|||||||
signingStatus:
|
signingStatus:
|
||||||
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
|
||||||
authOptions: recipientAuthOptions,
|
authOptions: recipientAuthOptions,
|
||||||
|
expired: expiryDate,
|
||||||
fields: {
|
fields: {
|
||||||
createMany: {
|
createMany: {
|
||||||
data: (recipient.fields || []).map((field) => ({
|
data: (recipient.fields || []).map((field) => ({
|
||||||
|
|||||||
@ -34,6 +34,8 @@ export type CreateDocumentOptions = {
|
|||||||
userTimezone?: string;
|
userTimezone?: string;
|
||||||
requestMetadata: ApiRequestMetadata;
|
requestMetadata: ApiRequestMetadata;
|
||||||
folderId?: string;
|
folderId?: string;
|
||||||
|
expiryAmount?: number;
|
||||||
|
expiryUnit?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createDocument = async ({
|
export const createDocument = async ({
|
||||||
@ -48,6 +50,8 @@ export const createDocument = async ({
|
|||||||
timezone,
|
timezone,
|
||||||
userTimezone,
|
userTimezone,
|
||||||
folderId,
|
folderId,
|
||||||
|
expiryAmount,
|
||||||
|
expiryUnit,
|
||||||
}: CreateDocumentOptions) => {
|
}: CreateDocumentOptions) => {
|
||||||
const team = await getTeamById({ userId, teamId });
|
const team = await getTeamById({ userId, teamId });
|
||||||
|
|
||||||
@ -126,6 +130,8 @@ export const createDocument = async ({
|
|||||||
documentMeta: {
|
documentMeta: {
|
||||||
create: extractDerivedDocumentMeta(settings, {
|
create: extractDerivedDocumentMeta(settings, {
|
||||||
timezone: timezoneToUse,
|
timezone: timezoneToUse,
|
||||||
|
expiryAmount,
|
||||||
|
expiryUnit,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { getI18nInstance } from '../../client-only/providers/i18n-server';
|
|||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||||
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
|
||||||
import { isDocumentCompleted } from '../../utils/document';
|
import { isDocumentCompleted } from '../../utils/document';
|
||||||
|
import { calculateRecipientExpiry } from '../../utils/expiry';
|
||||||
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
|
||||||
import { getEmailContext } from '../email/get-email-context';
|
import { getEmailContext } from '../email/get-email-context';
|
||||||
import { getDocumentWhereInput } from './get-document-by-id';
|
import { getDocumentWhereInput } from './get-document-by-id';
|
||||||
@ -199,6 +200,23 @@ export const resendDocument = async ({
|
|||||||
text,
|
text,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (document.documentMeta?.expiryAmount && document.documentMeta?.expiryUnit) {
|
||||||
|
const newExpiryDate = calculateRecipientExpiry(
|
||||||
|
document.documentMeta.expiryAmount,
|
||||||
|
document.documentMeta.expiryUnit,
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tx.recipient.update({
|
||||||
|
where: {
|
||||||
|
id: recipient.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
expired: newExpiryDate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await tx.documentAuditLog.create({
|
await tx.documentAuditLog.create({
|
||||||
data: createDocumentAuditLogData({
|
data: createDocumentAuditLogData({
|
||||||
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import {
|
|||||||
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
import { getFileServerSide } from '../../universal/upload/get-file.server';
|
||||||
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||||
import { isDocumentCompleted } from '../../utils/document';
|
import { isDocumentCompleted } from '../../utils/document';
|
||||||
|
import { calculateRecipientExpiry } from '../../utils/expiry';
|
||||||
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
|
||||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
import { getDocumentWhereInput } from './get-document-by-id';
|
import { getDocumentWhereInput } from './get-document-by-id';
|
||||||
@ -213,6 +214,24 @@ export const sendDocument = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (document.documentMeta?.expiryAmount && document.documentMeta?.expiryUnit) {
|
||||||
|
const expiryDate = calculateRecipientExpiry(
|
||||||
|
document.documentMeta.expiryAmount,
|
||||||
|
document.documentMeta.expiryUnit,
|
||||||
|
new Date(), // Calculate from current time
|
||||||
|
);
|
||||||
|
|
||||||
|
await tx.recipient.updateMany({
|
||||||
|
where: {
|
||||||
|
documentId: document.id,
|
||||||
|
expired: null,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
expired: expiryDate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return await tx.document.update({
|
return await tx.document.update({
|
||||||
where: {
|
where: {
|
||||||
id: documentId,
|
id: documentId,
|
||||||
|
|||||||
@ -25,7 +25,9 @@ import {
|
|||||||
} from '../../types/field-meta';
|
} from '../../types/field-meta';
|
||||||
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
import type { RequestMetadata } from '../../universal/extract-request-metadata';
|
||||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||||
|
import { isRecipientExpired } from '../../utils/expiry';
|
||||||
import { validateFieldAuth } from '../document/validate-field-auth';
|
import { validateFieldAuth } from '../document/validate-field-auth';
|
||||||
|
import { expireRecipient } from '../recipient/expire-recipient';
|
||||||
|
|
||||||
export type SignFieldWithTokenOptions = {
|
export type SignFieldWithTokenOptions = {
|
||||||
token: string;
|
token: string;
|
||||||
@ -115,6 +117,11 @@ export const signFieldWithToken = async ({
|
|||||||
throw new Error(`Recipient ${recipient.id} has already signed`);
|
throw new Error(`Recipient ${recipient.id} has already signed`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isRecipientExpired(recipient)) {
|
||||||
|
await expireRecipient({ recipientId: recipient.id });
|
||||||
|
throw new Error(`Signing link has expired`);
|
||||||
|
}
|
||||||
|
|
||||||
if (field.inserted) {
|
if (field.inserted) {
|
||||||
throw new Error(`Field ${fieldId} has already been inserted`);
|
throw new Error(`Field ${fieldId} has already been inserted`);
|
||||||
}
|
}
|
||||||
|
|||||||
36
packages/lib/server-only/recipient/expire-recipient.ts
Normal file
36
packages/lib/server-only/recipient/expire-recipient.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { SigningStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
export type ExpireRecipientOptions = {
|
||||||
|
recipientId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expireRecipient = async ({ recipientId }: ExpireRecipientOptions) => {
|
||||||
|
const recipient = await prisma.recipient.findFirst({
|
||||||
|
where: {
|
||||||
|
id: recipientId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
signingStatus: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipient.signingStatus === SigningStatus.EXPIRED) {
|
||||||
|
return recipient;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await prisma.recipient.update({
|
||||||
|
where: {
|
||||||
|
id: recipientId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
signingStatus: SigningStatus.EXPIRED,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -46,6 +46,7 @@ import {
|
|||||||
createRecipientAuthOptions,
|
createRecipientAuthOptions,
|
||||||
extractDocumentAuthMethods,
|
extractDocumentAuthMethods,
|
||||||
} from '../../utils/document-auth';
|
} from '../../utils/document-auth';
|
||||||
|
import { calculateRecipientExpiry } from '../../utils/expiry';
|
||||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||||
import { getTeamSettings } from '../team/get-team-settings';
|
import { getTeamSettings } from '../team/get-team-settings';
|
||||||
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
|
||||||
@ -91,6 +92,8 @@ export type CreateDocumentFromTemplateOptions = {
|
|||||||
typedSignatureEnabled?: boolean;
|
typedSignatureEnabled?: boolean;
|
||||||
uploadSignatureEnabled?: boolean;
|
uploadSignatureEnabled?: boolean;
|
||||||
drawSignatureEnabled?: boolean;
|
drawSignatureEnabled?: boolean;
|
||||||
|
expiryAmount?: number;
|
||||||
|
expiryUnit?: string;
|
||||||
};
|
};
|
||||||
requestMetadata: ApiRequestMetadata;
|
requestMetadata: ApiRequestMetadata;
|
||||||
};
|
};
|
||||||
@ -399,6 +402,9 @@ export const createDocumentFromTemplate = async ({
|
|||||||
override?.drawSignatureEnabled ?? template.templateMeta?.drawSignatureEnabled,
|
override?.drawSignatureEnabled ?? template.templateMeta?.drawSignatureEnabled,
|
||||||
allowDictateNextSigner:
|
allowDictateNextSigner:
|
||||||
override?.allowDictateNextSigner ?? template.templateMeta?.allowDictateNextSigner,
|
override?.allowDictateNextSigner ?? template.templateMeta?.allowDictateNextSigner,
|
||||||
|
defaultExpiryAmount:
|
||||||
|
override?.expiryAmount ?? template.templateMeta?.defaultExpiryAmount,
|
||||||
|
defaultExpiryUnit: override?.expiryUnit ?? template.templateMeta?.defaultExpiryUnit,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
recipients: {
|
recipients: {
|
||||||
@ -406,6 +412,17 @@ export const createDocumentFromTemplate = async ({
|
|||||||
data: finalRecipients.map((recipient) => {
|
data: finalRecipients.map((recipient) => {
|
||||||
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient?.authOptions);
|
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient?.authOptions);
|
||||||
|
|
||||||
|
// Calculate expiry date based on template defaults
|
||||||
|
const expiryAmount =
|
||||||
|
override?.expiryAmount ?? template.templateMeta?.defaultExpiryAmount ?? null;
|
||||||
|
const expiryUnit =
|
||||||
|
override?.expiryUnit ?? template.templateMeta?.defaultExpiryUnit ?? null;
|
||||||
|
const recipientExpiryDate = calculateRecipientExpiry(
|
||||||
|
expiryAmount,
|
||||||
|
expiryUnit,
|
||||||
|
new Date(), // Calculate from current time
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
email: recipient.email,
|
email: recipient.email,
|
||||||
name: recipient.name,
|
name: recipient.name,
|
||||||
@ -421,6 +438,7 @@ export const createDocumentFromTemplate = async ({
|
|||||||
? SigningStatus.SIGNED
|
? SigningStatus.SIGNED
|
||||||
: SigningStatus.NOT_SIGNED,
|
: SigningStatus.NOT_SIGNED,
|
||||||
signingOrder: recipient.signingOrder,
|
signingOrder: recipient.signingOrder,
|
||||||
|
expired: recipientExpiryDate,
|
||||||
token: nanoid(),
|
token: nanoid(),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -60,6 +60,8 @@ export const ZDocumentSchema = DocumentSchema.pick({
|
|||||||
emailSettings: true,
|
emailSettings: true,
|
||||||
emailId: true,
|
emailId: true,
|
||||||
emailReplyTo: true,
|
emailReplyTo: true,
|
||||||
|
expiryAmount: true,
|
||||||
|
expiryUnit: true,
|
||||||
}).nullable(),
|
}).nullable(),
|
||||||
folder: FolderSchema.pick({
|
folder: FolderSchema.pick({
|
||||||
id: true,
|
id: true,
|
||||||
|
|||||||
@ -15,6 +15,38 @@ export const isDocumentCompleted = (document: Pick<Document, 'status'> | Documen
|
|||||||
return status === DocumentStatus.COMPLETED || status === DocumentStatus.REJECTED;
|
return status === DocumentStatus.COMPLETED || status === DocumentStatus.REJECTED;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getExpiryAmount = (
|
||||||
|
meta: Partial<DocumentMeta | TemplateMeta> | undefined | null,
|
||||||
|
): number | null => {
|
||||||
|
if (!meta) return null;
|
||||||
|
|
||||||
|
if ('expiryAmount' in meta && meta.expiryAmount !== undefined) {
|
||||||
|
return meta.expiryAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('defaultExpiryAmount' in meta && meta.defaultExpiryAmount !== undefined) {
|
||||||
|
return meta.defaultExpiryAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getExpiryUnit = (
|
||||||
|
meta: Partial<DocumentMeta | TemplateMeta> | undefined | null,
|
||||||
|
): string | null => {
|
||||||
|
if (!meta) return null;
|
||||||
|
|
||||||
|
if ('expiryUnit' in meta && meta.expiryUnit !== undefined) {
|
||||||
|
return meta.expiryUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('defaultExpiryUnit' in meta && meta.defaultExpiryUnit !== undefined) {
|
||||||
|
return meta.defaultExpiryUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts the derived document meta which should be used when creating a document
|
* Extracts the derived document meta which should be used when creating a document
|
||||||
* from scratch, or from a template.
|
* from scratch, or from a template.
|
||||||
@ -58,5 +90,9 @@ export const extractDerivedDocumentMeta = (
|
|||||||
emailReplyTo: meta.emailReplyTo ?? settings.emailReplyTo,
|
emailReplyTo: meta.emailReplyTo ?? settings.emailReplyTo,
|
||||||
emailSettings:
|
emailSettings:
|
||||||
meta.emailSettings || settings.emailDocumentSettings || DEFAULT_DOCUMENT_EMAIL_SETTINGS,
|
meta.emailSettings || settings.emailDocumentSettings || DEFAULT_DOCUMENT_EMAIL_SETTINGS,
|
||||||
|
|
||||||
|
// Expiry settings.
|
||||||
|
expiryAmount: getExpiryAmount(meta),
|
||||||
|
expiryUnit: getExpiryUnit(meta),
|
||||||
} satisfies Omit<DocumentMeta, 'id' | 'documentId'>;
|
} satisfies Omit<DocumentMeta, 'id' | 'documentId'>;
|
||||||
};
|
};
|
||||||
|
|||||||
50
packages/lib/utils/expiry.ts
Normal file
50
packages/lib/utils/expiry.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import type { Recipient } from '@prisma/client';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
export const calculateRecipientExpiry = (
|
||||||
|
documentExpiryAmount?: number | null,
|
||||||
|
documentExpiryUnit?: string | null,
|
||||||
|
fromDate: Date = new Date(),
|
||||||
|
): Date | null => {
|
||||||
|
if (!documentExpiryAmount || !documentExpiryUnit) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (documentExpiryUnit) {
|
||||||
|
case 'minutes':
|
||||||
|
return DateTime.fromJSDate(fromDate).plus({ minutes: documentExpiryAmount }).toJSDate();
|
||||||
|
case 'hours':
|
||||||
|
return DateTime.fromJSDate(fromDate).plus({ hours: documentExpiryAmount }).toJSDate();
|
||||||
|
case 'days':
|
||||||
|
return DateTime.fromJSDate(fromDate).plus({ days: documentExpiryAmount }).toJSDate();
|
||||||
|
case 'weeks':
|
||||||
|
return DateTime.fromJSDate(fromDate).plus({ weeks: documentExpiryAmount }).toJSDate();
|
||||||
|
case 'months':
|
||||||
|
return DateTime.fromJSDate(fromDate).plus({ months: documentExpiryAmount }).toJSDate();
|
||||||
|
default:
|
||||||
|
return DateTime.fromJSDate(fromDate).plus({ days: documentExpiryAmount }).toJSDate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isRecipientExpired = (recipient: Recipient): boolean => {
|
||||||
|
if (!recipient.expired) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DateTime.now() > DateTime.fromJSDate(recipient.expired);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isValidExpirySettings = (
|
||||||
|
expiryAmount?: number | null,
|
||||||
|
expiryUnit?: string | null,
|
||||||
|
): boolean => {
|
||||||
|
if (!expiryAmount || !expiryUnit) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return expiryAmount > 0 && ['minutes', 'hours', 'days', 'weeks', 'months'].includes(expiryUnit);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatExpiryDate = (date: Date): string => {
|
||||||
|
return DateTime.fromJSDate(date).toFormat('MMM dd, yyyy HH:mm');
|
||||||
|
};
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "SigningStatus" ADD VALUE 'EXPIRED';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "DocumentMeta" ADD COLUMN "expiryAmount" INTEGER,
|
||||||
|
ADD COLUMN "expiryUnit" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "TemplateMeta" ADD COLUMN "defaultExpiryAmount" INTEGER,
|
||||||
|
ADD COLUMN "defaultExpiryUnit" TEXT;
|
||||||
@ -472,6 +472,9 @@ model DocumentMeta {
|
|||||||
emailSettings Json? /// [DocumentEmailSettings] @zod.custom.use(ZDocumentEmailSettingsSchema)
|
emailSettings Json? /// [DocumentEmailSettings] @zod.custom.use(ZDocumentEmailSettingsSchema)
|
||||||
emailReplyTo String?
|
emailReplyTo String?
|
||||||
emailId String?
|
emailId String?
|
||||||
|
|
||||||
|
expiryAmount Int?
|
||||||
|
expiryUnit String?
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ReadStatus {
|
enum ReadStatus {
|
||||||
@ -488,6 +491,7 @@ enum SigningStatus {
|
|||||||
NOT_SIGNED
|
NOT_SIGNED
|
||||||
SIGNED
|
SIGNED
|
||||||
REJECTED
|
REJECTED
|
||||||
|
EXPIRED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RecipientRole {
|
enum RecipientRole {
|
||||||
@ -854,6 +858,10 @@ model TemplateMeta {
|
|||||||
allowDictateNextSigner Boolean @default(false)
|
allowDictateNextSigner Boolean @default(false)
|
||||||
distributionMethod DocumentDistributionMethod @default(EMAIL)
|
distributionMethod DocumentDistributionMethod @default(EMAIL)
|
||||||
|
|
||||||
|
// Default expiry settings
|
||||||
|
defaultExpiryAmount Int?
|
||||||
|
defaultExpiryUnit String?
|
||||||
|
|
||||||
typedSignatureEnabled Boolean @default(true)
|
typedSignatureEnabled Boolean @default(true)
|
||||||
uploadSignatureEnabled Boolean @default(true)
|
uploadSignatureEnabled Boolean @default(true)
|
||||||
drawSignatureEnabled Boolean @default(true)
|
drawSignatureEnabled Boolean @default(true)
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import { sendDocument } from '@documenso/lib/server-only/document/send-document'
|
|||||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||||
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
|
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
|
import { isValidExpirySettings } from '@documenso/lib/utils/expiry';
|
||||||
|
|
||||||
import { authenticatedProcedure, procedure, router } from '../trpc';
|
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||||
import { downloadDocumentRoute } from './download-document';
|
import { downloadDocumentRoute } from './download-document';
|
||||||
@ -284,8 +285,16 @@ export const documentRouter = router({
|
|||||||
globalActionAuth,
|
globalActionAuth,
|
||||||
recipients,
|
recipients,
|
||||||
meta,
|
meta,
|
||||||
|
expiryAmount,
|
||||||
|
expiryUnit,
|
||||||
} = input;
|
} = input;
|
||||||
|
|
||||||
|
if ((expiryAmount || expiryUnit) && !isValidExpirySettings(expiryAmount, expiryUnit)) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'Invalid expiry settings. Please check your expiry configuration.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const { remaining } = await getServerLimits({ userId: user.id, teamId });
|
const { remaining } = await getServerLimits({ userId: user.id, teamId });
|
||||||
|
|
||||||
if (remaining.documents <= 0) {
|
if (remaining.documents <= 0) {
|
||||||
@ -316,6 +325,8 @@ export const documentRouter = router({
|
|||||||
globalAccessAuth,
|
globalAccessAuth,
|
||||||
globalActionAuth,
|
globalActionAuth,
|
||||||
recipients,
|
recipients,
|
||||||
|
expiryAmount,
|
||||||
|
expiryUnit,
|
||||||
},
|
},
|
||||||
meta,
|
meta,
|
||||||
requestMetadata: ctx.metadata,
|
requestMetadata: ctx.metadata,
|
||||||
@ -345,7 +356,14 @@ export const documentRouter = router({
|
|||||||
.input(ZCreateDocumentRequestSchema)
|
.input(ZCreateDocumentRequestSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { user, teamId } = ctx;
|
const { user, teamId } = ctx;
|
||||||
const { title, documentDataId, timezone, folderId } = input;
|
const { title, documentDataId, timezone, folderId, expiryAmount, expiryUnit } = input;
|
||||||
|
|
||||||
|
// Validate expiry settings
|
||||||
|
if ((expiryAmount || expiryUnit) && !isValidExpirySettings(expiryAmount, expiryUnit)) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'Invalid expiry settings. Please check your expiry configuration.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
ctx.logger.info({
|
ctx.logger.info({
|
||||||
input: {
|
input: {
|
||||||
@ -371,6 +389,8 @@ export const documentRouter = router({
|
|||||||
userTimezone: timezone,
|
userTimezone: timezone,
|
||||||
requestMetadata: ctx.metadata,
|
requestMetadata: ctx.metadata,
|
||||||
folderId,
|
folderId,
|
||||||
|
expiryAmount,
|
||||||
|
expiryUnit,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@ -117,6 +117,16 @@ export const ZDocumentMetaUploadSignatureEnabledSchema = z
|
|||||||
.boolean()
|
.boolean()
|
||||||
.describe('Whether to allow recipients to sign using an uploaded signature.');
|
.describe('Whether to allow recipients to sign using an uploaded signature.');
|
||||||
|
|
||||||
|
export const ZDocumentExpiryAmountSchema = z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.describe('The amount for expiry duration (e.g., 3 for "3 days").');
|
||||||
|
|
||||||
|
export const ZDocumentExpiryUnitSchema = z
|
||||||
|
.enum(['minutes', 'hours', 'days', 'weeks', 'months'])
|
||||||
|
.describe('The unit for expiry duration (e.g., "days" for "3 days").');
|
||||||
|
|
||||||
export const ZFindDocumentsRequestSchema = ZFindSearchParamsSchema.extend({
|
export const ZFindDocumentsRequestSchema = ZFindSearchParamsSchema.extend({
|
||||||
templateId: z
|
templateId: z
|
||||||
.number()
|
.number()
|
||||||
@ -200,6 +210,8 @@ export const ZCreateDocumentRequestSchema = z.object({
|
|||||||
documentDataId: z.string().min(1),
|
documentDataId: z.string().min(1),
|
||||||
timezone: ZDocumentMetaTimezoneSchema.optional(),
|
timezone: ZDocumentMetaTimezoneSchema.optional(),
|
||||||
folderId: z.string().describe('The ID of the folder to create the document in').optional(),
|
folderId: z.string().describe('The ID of the folder to create the document in').optional(),
|
||||||
|
expiryAmount: ZDocumentExpiryAmountSchema.optional(),
|
||||||
|
expiryUnit: ZDocumentExpiryUnitSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ZCreateDocumentV2RequestSchema = z.object({
|
export const ZCreateDocumentV2RequestSchema = z.object({
|
||||||
@ -209,6 +221,8 @@ export const ZCreateDocumentV2RequestSchema = z.object({
|
|||||||
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
|
globalAccessAuth: z.array(ZDocumentAccessAuthTypesSchema).optional(),
|
||||||
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
|
globalActionAuth: z.array(ZDocumentActionAuthTypesSchema).optional(),
|
||||||
formValues: ZDocumentFormValuesSchema.optional(),
|
formValues: ZDocumentFormValuesSchema.optional(),
|
||||||
|
expiryAmount: ZDocumentExpiryAmountSchema.optional(),
|
||||||
|
expiryUnit: ZDocumentExpiryUnitSchema.optional(),
|
||||||
recipients: z
|
recipients: z
|
||||||
.array(
|
.array(
|
||||||
ZCreateRecipientSchema.extend({
|
ZCreateRecipientSchema.extend({
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
|
import { upsertDocumentMeta } from '@documenso/lib/server-only/document-meta/upsert-document-meta';
|
||||||
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
|
import { updateDocument } from '@documenso/lib/server-only/document/update-document';
|
||||||
|
import { isValidExpirySettings } from '@documenso/lib/utils/expiry';
|
||||||
|
|
||||||
import { authenticatedProcedure } from '../trpc';
|
import { authenticatedProcedure } from '../trpc';
|
||||||
import {
|
import {
|
||||||
@ -27,6 +29,15 @@ export const updateDocumentRoute = authenticatedProcedure
|
|||||||
|
|
||||||
const userId = ctx.user.id;
|
const userId = ctx.user.id;
|
||||||
|
|
||||||
|
if (
|
||||||
|
(meta.expiryAmount || meta.expiryUnit) &&
|
||||||
|
!isValidExpirySettings(meta.expiryAmount, meta.expiryUnit)
|
||||||
|
) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'Invalid expiry settings. Please check your expiry configuration.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.values(meta).length > 0) {
|
if (Object.values(meta).length > 0) {
|
||||||
await upsertDocumentMeta({
|
await upsertDocumentMeta({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
@ -47,6 +58,8 @@ export const updateDocumentRoute = authenticatedProcedure
|
|||||||
emailId: meta.emailId,
|
emailId: meta.emailId,
|
||||||
emailReplyTo: meta.emailReplyTo,
|
emailReplyTo: meta.emailReplyTo,
|
||||||
emailSettings: meta.emailSettings,
|
emailSettings: meta.emailSettings,
|
||||||
|
expiryAmount: meta.expiryAmount,
|
||||||
|
expiryUnit: meta.expiryUnit,
|
||||||
requestMetadata: ctx.metadata,
|
requestMetadata: ctx.metadata,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,8 @@ import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-emai
|
|||||||
|
|
||||||
import type { TrpcRouteMeta } from '../trpc';
|
import type { TrpcRouteMeta } from '../trpc';
|
||||||
import {
|
import {
|
||||||
|
ZDocumentExpiryAmountSchema,
|
||||||
|
ZDocumentExpiryUnitSchema,
|
||||||
ZDocumentExternalIdSchema,
|
ZDocumentExternalIdSchema,
|
||||||
ZDocumentMetaDateFormatSchema,
|
ZDocumentMetaDateFormatSchema,
|
||||||
ZDocumentMetaDistributionMethodSchema,
|
ZDocumentMetaDistributionMethodSchema,
|
||||||
@ -64,6 +66,8 @@ export const ZUpdateDocumentRequestSchema = z.object({
|
|||||||
emailId: z.string().nullish(),
|
emailId: z.string().nullish(),
|
||||||
emailReplyTo: z.string().email().nullish(),
|
emailReplyTo: z.string().email().nullish(),
|
||||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||||
|
expiryAmount: ZDocumentExpiryAmountSchema.optional(),
|
||||||
|
expiryUnit: ZDocumentExpiryUnitSchema.optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
131
packages/ui/primitives/date-time-picker.tsx
Normal file
131
packages/ui/primitives/date-time-picker.tsx
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { msg } from '@lingui/core/macro';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { CalendarIcon } from 'lucide-react';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
import { Button } from './button';
|
||||||
|
import { Calendar } from './calendar';
|
||||||
|
import { Input } from './input';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||||
|
|
||||||
|
export interface DateTimePickerProps {
|
||||||
|
value?: Date;
|
||||||
|
onChange?: (date: Date | undefined) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
minDate?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DateTimePicker = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
minDate = new Date(),
|
||||||
|
}: DateTimePickerProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const handleDateSelect = (selectedDate: Date | undefined) => {
|
||||||
|
if (!selectedDate) {
|
||||||
|
onChange?.(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
const existingTime = DateTime.fromJSDate(value);
|
||||||
|
const newDateTime = DateTime.fromJSDate(selectedDate).set({
|
||||||
|
hour: existingTime.hour,
|
||||||
|
minute: existingTime.minute,
|
||||||
|
});
|
||||||
|
onChange?.(newDateTime.toJSDate());
|
||||||
|
} else {
|
||||||
|
const now = DateTime.now();
|
||||||
|
const newDateTime = DateTime.fromJSDate(selectedDate).set({
|
||||||
|
hour: now.hour,
|
||||||
|
minute: now.minute,
|
||||||
|
});
|
||||||
|
onChange?.(newDateTime.toJSDate());
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const timeValue = event.target.value;
|
||||||
|
if (!timeValue || !value) return;
|
||||||
|
|
||||||
|
const [hours, minutes] = timeValue.split(':').map(Number);
|
||||||
|
const newDateTime = DateTime.fromJSDate(value).set({
|
||||||
|
hour: hours,
|
||||||
|
minute: minutes,
|
||||||
|
});
|
||||||
|
|
||||||
|
onChange?.(newDateTime.toJSDate());
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateTime = (date: Date) => {
|
||||||
|
return DateTime.fromJSDate(date).toFormat('MMM dd, yyyy');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (date: Date) => {
|
||||||
|
return DateTime.fromJSDate(date).toFormat('HH:mm');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex gap-2', className)}>
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
'w-[200px] justify-start text-left font-normal',
|
||||||
|
!value && 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
|
{value ? formatDateTime(value) : <span>{placeholder || _(msg`Pick a date`)}</span>}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={value}
|
||||||
|
onSelect={handleDateSelect}
|
||||||
|
disabled={
|
||||||
|
disabled
|
||||||
|
? true
|
||||||
|
: (date) => {
|
||||||
|
return date < minDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
initialFocus
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{value && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
<Trans>at</Trans>
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
type="time"
|
||||||
|
value={formatTime(value)}
|
||||||
|
onChange={handleTimeChange}
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-[120px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -11,7 +11,7 @@ import {
|
|||||||
TeamMemberRole,
|
TeamMemberRole,
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import { InfoIcon } from 'lucide-react';
|
import { InfoIcon } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
@ -56,6 +56,7 @@ import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combo
|
|||||||
|
|
||||||
import { DocumentSignatureSettingsTooltip } from '../../components/document/document-signature-settings-tooltip';
|
import { DocumentSignatureSettingsTooltip } from '../../components/document/document-signature-settings-tooltip';
|
||||||
import { Combobox } from '../combobox';
|
import { Combobox } from '../combobox';
|
||||||
|
import { ExpirySettingsPicker } from '../expiry-settings-picker';
|
||||||
import { Input } from '../input';
|
import { Input } from '../input';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
|
||||||
import { useStep } from '../stepper';
|
import { useStep } from '../stepper';
|
||||||
@ -71,6 +72,18 @@ import {
|
|||||||
} from './document-flow-root';
|
} from './document-flow-root';
|
||||||
import type { DocumentFlowStep } from './types';
|
import type { DocumentFlowStep } from './types';
|
||||||
|
|
||||||
|
const isExpiryUnit = (
|
||||||
|
value: unknown,
|
||||||
|
): value is 'minutes' | 'hours' | 'days' | 'weeks' | 'months' => {
|
||||||
|
return (
|
||||||
|
value === 'minutes' ||
|
||||||
|
value === 'hours' ||
|
||||||
|
value === 'days' ||
|
||||||
|
value === 'weeks' ||
|
||||||
|
value === 'months'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export type AddSettingsFormProps = {
|
export type AddSettingsFormProps = {
|
||||||
documentFlow: DocumentFlowStep;
|
documentFlow: DocumentFlowStep;
|
||||||
recipients: Recipient[];
|
recipients: Recipient[];
|
||||||
@ -98,6 +111,9 @@ export const AddSettingsFormPartial = ({
|
|||||||
documentAuth: document.authOptions,
|
documentAuth: document.authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const documentExpiryUnit = document.documentMeta?.expiryUnit;
|
||||||
|
const initialExpiryUnit = isExpiryUnit(documentExpiryUnit) ? documentExpiryUnit : undefined;
|
||||||
|
|
||||||
const form = useForm<TAddSettingsFormSchema>({
|
const form = useForm<TAddSettingsFormSchema>({
|
||||||
resolver: zodResolver(ZAddSettingsFormSchema),
|
resolver: zodResolver(ZAddSettingsFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@ -117,6 +133,8 @@ export const AddSettingsFormPartial = ({
|
|||||||
redirectUrl: document.documentMeta?.redirectUrl ?? '',
|
redirectUrl: document.documentMeta?.redirectUrl ?? '',
|
||||||
language: document.documentMeta?.language ?? 'en',
|
language: document.documentMeta?.language ?? 'en',
|
||||||
signatureTypes: extractTeamSignatureSettings(document.documentMeta),
|
signatureTypes: extractTeamSignatureSettings(document.documentMeta),
|
||||||
|
expiryAmount: document.documentMeta?.expiryAmount ?? undefined,
|
||||||
|
expiryUnit: initialExpiryUnit,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -127,6 +145,9 @@ export const AddSettingsFormPartial = ({
|
|||||||
(recipient) => recipient.sendStatus === SendStatus.SENT,
|
(recipient) => recipient.sendStatus === SendStatus.SENT,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const expiryAmount = useWatch({ control: form.control, name: 'meta.expiryAmount' });
|
||||||
|
const expiryUnit = useWatch({ control: form.control, name: 'meta.expiryUnit' });
|
||||||
|
|
||||||
const canUpdateVisibility = match(currentTeamMemberRole)
|
const canUpdateVisibility = match(currentTeamMemberRole)
|
||||||
.with(TeamMemberRole.ADMIN, () => true)
|
.with(TeamMemberRole.ADMIN, () => true)
|
||||||
.with(
|
.with(
|
||||||
@ -469,6 +490,33 @@ export const AddSettingsFormPartial = ({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FormLabel className="mb-4 block">
|
||||||
|
<Trans>Link Expiry</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<ExpirySettingsPicker
|
||||||
|
value={{
|
||||||
|
expiryDuration:
|
||||||
|
expiryAmount && expiryUnit
|
||||||
|
? {
|
||||||
|
amount: expiryAmount,
|
||||||
|
unit: expiryUnit,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
disabled={documentHasBeenSent}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value.expiryDuration) {
|
||||||
|
form.setValue('meta.expiryAmount', value.expiryDuration.amount);
|
||||||
|
form.setValue('meta.expiryUnit', value.expiryDuration.unit);
|
||||||
|
} else {
|
||||||
|
form.setValue('meta.expiryAmount', undefined);
|
||||||
|
form.setValue('meta.expiryUnit', undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|||||||
@ -46,6 +46,8 @@ export const ZAddSettingsFormSchema = z.object({
|
|||||||
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
|
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
|
||||||
message: msg`At least one signature type must be enabled`.id,
|
message: msg`At least one signature type must be enabled`.id,
|
||||||
}),
|
}),
|
||||||
|
expiryAmount: z.number().int().min(1).optional(),
|
||||||
|
expiryUnit: z.enum(['minutes', 'hours', 'days', 'weeks', 'months']).optional(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
106
packages/ui/primitives/duration-selector.tsx
Normal file
106
packages/ui/primitives/duration-selector.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
import { Input } from './input';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select';
|
||||||
|
|
||||||
|
export type TimeUnit = 'minutes' | 'hours' | 'days' | 'weeks' | 'months';
|
||||||
|
|
||||||
|
export interface DurationValue {
|
||||||
|
amount: number;
|
||||||
|
unit: TimeUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DurationSelectorProps {
|
||||||
|
value?: DurationValue;
|
||||||
|
onChange?: (value: DurationValue) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
minAmount?: number;
|
||||||
|
maxAmount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIME_UNITS: Array<{ value: TimeUnit; label: string; labelPlural: string }> = [
|
||||||
|
{ value: 'minutes', label: 'Minute', labelPlural: 'Minutes' },
|
||||||
|
{ value: 'hours', label: 'Hour', labelPlural: 'Hours' },
|
||||||
|
{ value: 'days', label: 'Day', labelPlural: 'Days' },
|
||||||
|
{ value: 'weeks', label: 'Week', labelPlural: 'Weeks' },
|
||||||
|
{ value: 'months', label: 'Month', labelPlural: 'Months' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DurationSelector = ({
|
||||||
|
value = { amount: 1, unit: 'days' },
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
minAmount = 1,
|
||||||
|
maxAmount = 365,
|
||||||
|
}: DurationSelectorProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const handleAmountChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const amount = parseInt(event.target.value, 10);
|
||||||
|
if (!isNaN(amount) && amount >= minAmount && amount <= maxAmount) {
|
||||||
|
onChange?.({ ...value, amount });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnitChange = (unit: TimeUnit) => {
|
||||||
|
onChange?.({ ...value, unit });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUnitLabel = (unit: TimeUnit, amount: number) => {
|
||||||
|
const unitConfig = TIME_UNITS.find((u) => u.value === unit);
|
||||||
|
if (!unitConfig) return unit;
|
||||||
|
|
||||||
|
return amount === 1 ? unitConfig.label : unitConfig.labelPlural;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center gap-2', className)}>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={value.amount}
|
||||||
|
onChange={handleAmountChange}
|
||||||
|
disabled={disabled}
|
||||||
|
min={minAmount}
|
||||||
|
max={maxAmount}
|
||||||
|
className="w-20"
|
||||||
|
/>
|
||||||
|
<Select value={value.unit} onValueChange={handleUnitChange} disabled={disabled}>
|
||||||
|
<SelectTrigger className="w-24">
|
||||||
|
<SelectValue>{getUnitLabel(value.unit, value.amount)}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{TIME_UNITS.map((unit) => (
|
||||||
|
<SelectItem key={unit.value} value={unit.value}>
|
||||||
|
{getUnitLabel(unit.value, value.amount)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const calculateExpiryDate = (duration: DurationValue, fromDate: Date = new Date()): Date => {
|
||||||
|
switch (duration.unit) {
|
||||||
|
case 'minutes':
|
||||||
|
return DateTime.fromJSDate(fromDate).plus({ minutes: duration.amount }).toJSDate();
|
||||||
|
case 'hours':
|
||||||
|
return DateTime.fromJSDate(fromDate).plus({ hours: duration.amount }).toJSDate();
|
||||||
|
case 'days':
|
||||||
|
return DateTime.fromJSDate(fromDate).plus({ days: duration.amount }).toJSDate();
|
||||||
|
case 'weeks':
|
||||||
|
return DateTime.fromJSDate(fromDate).plus({ weeks: duration.amount }).toJSDate();
|
||||||
|
case 'months':
|
||||||
|
return DateTime.fromJSDate(fromDate).plus({ months: duration.amount }).toJSDate();
|
||||||
|
default:
|
||||||
|
return DateTime.fromJSDate(fromDate).plus({ days: duration.amount }).toJSDate();
|
||||||
|
}
|
||||||
|
};
|
||||||
121
packages/ui/primitives/expiry-settings-picker.tsx
Normal file
121
packages/ui/primitives/expiry-settings-picker.tsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
import { Trans } from '@lingui/react/macro';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
import { DurationSelector } from './duration-selector';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from './form/form';
|
||||||
|
|
||||||
|
const ZExpirySettingsSchema = z.object({
|
||||||
|
expiryDuration: z
|
||||||
|
.object({
|
||||||
|
amount: z.number().int().min(1),
|
||||||
|
unit: z.enum(['minutes', 'hours', 'days', 'weeks', 'months']),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ExpirySettings = z.infer<typeof ZExpirySettingsSchema>;
|
||||||
|
|
||||||
|
export interface ExpirySettingsPickerProps {
|
||||||
|
className?: string;
|
||||||
|
defaultValues?: Partial<ExpirySettings>;
|
||||||
|
disabled?: boolean;
|
||||||
|
onValueChange?: (value: ExpirySettings) => void;
|
||||||
|
value?: ExpirySettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ExpirySettingsPicker = ({
|
||||||
|
className,
|
||||||
|
defaultValues = {
|
||||||
|
expiryDuration: undefined,
|
||||||
|
},
|
||||||
|
disabled = false,
|
||||||
|
onValueChange,
|
||||||
|
value,
|
||||||
|
}: ExpirySettingsPickerProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const form = useForm<ExpirySettings>({
|
||||||
|
resolver: zodResolver(ZExpirySettingsSchema),
|
||||||
|
defaultValues,
|
||||||
|
mode: 'onChange',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { watch, setValue, getValues } = form;
|
||||||
|
const expiryDuration = watch('expiryDuration');
|
||||||
|
|
||||||
|
// Call onValueChange when form values change
|
||||||
|
React.useEffect(() => {
|
||||||
|
const subscription = watch((value) => {
|
||||||
|
if (onValueChange) {
|
||||||
|
onValueChange(value as ExpirySettings);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
|
}, [watch, onValueChange]);
|
||||||
|
|
||||||
|
// Keep internal form state in sync when a controlled value is provided
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (value === undefined) return;
|
||||||
|
|
||||||
|
const current = getValues('expiryDuration');
|
||||||
|
const next = value.expiryDuration;
|
||||||
|
|
||||||
|
const amountsDiffer = (current?.amount ?? null) !== (next?.amount ?? null);
|
||||||
|
const unitsDiffer = (current?.unit ?? null) !== (next?.unit ?? null);
|
||||||
|
|
||||||
|
if (amountsDiffer || unitsDiffer) {
|
||||||
|
setValue('expiryDuration', next, {
|
||||||
|
shouldDirty: false,
|
||||||
|
shouldTouch: false,
|
||||||
|
shouldValidate: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [value, getValues, setValue]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-4', className)}>
|
||||||
|
<Form {...form}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="expiryDuration"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
<Trans>Link Expiry</Trans>
|
||||||
|
</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
<Trans>Set an expiry duration for signing links (leave empty to disable)</Trans>
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<DurationSelector
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
minAmount={1}
|
||||||
|
maxAmount={365}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user