feat: expiry links

This commit is contained in:
Ephraim Atta-Duncan
2025-08-18 14:22:43 +00:00
parent ea7a2c2712
commit e24d00e23e
32 changed files with 935 additions and 6 deletions

View File

@ -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,
}, },
}); });

View File

@ -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 && (

View File

@ -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;
} }

View File

@ -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">

View File

@ -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 },
() => ( () => (

View File

@ -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}`}>

View File

@ -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`);
} }

View 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>
);
}

View File

@ -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;
} }

View File

@ -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,
}; };

View File

@ -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) {

View File

@ -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) => ({

View File

@ -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,
}), }),
}, },
}, },

View File

@ -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,

View File

@ -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,

View File

@ -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`);
} }

View 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,
},
});
};

View File

@ -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(),
}; };
}), }),

View File

@ -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,

View File

@ -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'>;
}; };

View 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');
};

View File

@ -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;

View File

@ -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)

View File

@ -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,
}); });
}), }),

View File

@ -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({

View File

@ -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,
}); });
} }

View File

@ -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(),
}); });

View 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>
);
};

View File

@ -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>

View File

@ -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(),
}), }),
}); });

View 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();
}
};

View 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>
);
};