Compare commits

...

10 Commits

46 changed files with 1765 additions and 7 deletions

View File

@ -223,6 +223,8 @@ export const DocumentEditForm = ({
meta: {
allowDictateNextSigner: data.allowDictateNextSigner,
signingOrder: data.signingOrder,
expiryAmount: data.meta.expiryAmount,
expiryUnit: data.meta.expiryUnit,
},
}),
@ -247,6 +249,8 @@ export const DocumentEditForm = ({
meta: {
allowDictateNextSigner: data.allowDictateNextSigner,
signingOrder: data.signingOrder,
expiryAmount: data.meta.expiryAmount,
expiryUnit: data.meta.expiryUnit,
},
}),
@ -476,6 +480,17 @@ export const DocumentEditForm = ({
recipients={recipients}
signingOrder={document.documentMeta?.signingOrder}
allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner}
expiryAmount={document.documentMeta?.expiryAmount}
expiryUnit={
document.documentMeta?.expiryUnit as
| 'minutes'
| 'hours'
| 'days'
| 'weeks'
| 'months'
| null
| undefined
}
fields={fields}
onSubmit={onAddSignersFormSubmit}
onAutoSave={onAddSignersFormAutoSave}

View File

@ -156,6 +156,14 @@ export const DocumentPageViewRecipients = ({
</PopoverHover>
)}
{envelope.status !== DocumentStatus.DRAFT &&
recipient.signingStatus === SigningStatus.EXPIRED && (
<Badge variant="warning">
<Clock className="mr-1 h-3 w-3" />
<Trans>Expired</Trans>
</Badge>
)}
{envelope.status === DocumentStatus.PENDING &&
recipient.signingStatus === SigningStatus.NOT_SIGNED &&
recipient.role !== RecipientRole.CC && (

View File

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

View File

@ -48,13 +48,20 @@ export const StackAvatarsWithTooltip = ({
(recipient) => getRecipientType(recipient) === RecipientStatusType.REJECTED,
);
const expiredRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) === RecipientStatusType.EXPIRED,
);
const sortedRecipients = useMemo(() => {
const otherRecipients = recipients.filter(
(recipient) => getRecipientType(recipient) !== RecipientStatusType.REJECTED,
(recipient) =>
getRecipientType(recipient) !== RecipientStatusType.REJECTED &&
getRecipientType(recipient) !== RecipientStatusType.EXPIRED,
);
return [
...rejectedRecipients.sort((a, b) => a.id - b.id),
...expiredRecipients.sort((a, b) => a.id - b.id),
...otherRecipients.sort((a, b) => {
return a.id - b.id;
}),
@ -117,6 +124,30 @@ export const StackAvatarsWithTooltip = ({
</div>
)}
{expiredRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">
<Trans>Expired</Trans>
</h1>
{expiredRecipients.map((recipient: Recipient) => (
<div key={recipient.id} className="my-1 flex items-center gap-2">
<StackAvatar
first={true}
key={recipient.id}
type={getRecipientType(recipient)}
fallbackText={recipientAbbreviation(recipient)}
/>
<div>
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
</div>
))}
</div>
)}
{waitingRecipients.length > 0 && (
<div>
<h1 className="text-base font-medium">

View File

@ -2,7 +2,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
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 { match } from 'ts-pattern';
@ -36,6 +36,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
const isPending = row.status === DocumentStatus.PENDING;
const isComplete = isDocumentCompleted(row.status);
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isExpired = recipient?.signingStatus === SigningStatus.EXPIRED;
const role = recipient?.role;
const isCurrentTeamDocument = team && row.team?.url === team.url;
@ -87,8 +88,15 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
isPending,
isComplete,
isSigned,
isExpired,
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(
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 { DocumentStatus as DocumentStatusEnum } 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 { Link, useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
@ -193,6 +193,7 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
const isPending = row.status === DocumentStatusEnum.PENDING;
const isComplete = isDocumentCompleted(row.status);
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isExpired = recipient?.signingStatus === SigningStatus.EXPIRED;
const role = recipient?.role;
if (!recipient) {
@ -230,7 +231,14 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
isPending,
isComplete,
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 }, () => (
<Button className="w-32" asChild>
<Link to={`/sign/${recipient?.token}`}>

View File

@ -16,6 +16,7 @@ import { getEnvelopeForRecipientSigning } from '@documenso/lib/server-only/envel
import { getEnvelopeRequiredAccessData } from '@documenso/lib/server-only/envelope/get-envelope-required-access-data';
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 { expireRecipient } from '@documenso/lib/server-only/recipient/expire-recipient';
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 { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
@ -25,6 +26,7 @@ import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settin
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { isRecipientExpired } from '@documenso/lib/utils/expiry';
import { prisma } from '@documenso/prisma';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
@ -136,6 +138,13 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
const { documentMeta } = document;
if (isRecipientExpired(recipient)) {
const expiredRecipient = await expireRecipient({ recipientId: recipient.id });
if (expiredRecipient) {
throw redirect(`/sign/${token}/expired`);
}
}
if (recipient.signingStatus === SigningStatus.REJECTED) {
throw redirect(`/sign/${token}/rejected`);
}
@ -239,6 +248,13 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
recipientAccessAuth: derivedRecipientAccessAuth,
}).catch(() => null);
if (isRecipientExpired(recipient)) {
const expiredRecipient = await expireRecipient({ recipientId: recipient.id });
if (expiredRecipient) {
throw redirect(`/sign/${token}/expired`);
}
}
if (isRejected) {
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

@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/consistent-type-assertions */
import { RefObject, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
/**
* Calculate the width and height of a text element.

View File

@ -13,6 +13,7 @@ export enum RecipientStatusType {
WAITING = 'waiting',
UNSIGNED = 'unsigned',
REJECTED = 'rejected',
EXPIRED = 'expired',
}
export const getRecipientType = (
@ -27,6 +28,10 @@ export const getRecipientType = (
return RecipientStatusType.REJECTED;
}
if (recipient.signingStatus === SigningStatus.EXPIRED) {
return RecipientStatusType.EXPIRED;
}
if (
recipient.readStatus === ReadStatus.OPENED &&
recipient.signingStatus === SigningStatus.NOT_SIGNED
@ -52,6 +57,10 @@ export const getExtraRecipientsType = (extraRecipients: Recipient[]) => {
return RecipientStatusType.UNSIGNED;
}
if (types.includes(RecipientStatusType.EXPIRED)) {
return RecipientStatusType.EXPIRED;
}
if (types.includes(RecipientStatusType.OPENED)) {
return RecipientStatusType.OPENED;
}

View File

@ -15,6 +15,7 @@ export const getRecipientsStats = async () => {
[SigningStatus.SIGNED]: 0,
[SigningStatus.NOT_SIGNED]: 0,
[SigningStatus.REJECTED]: 0,
[SigningStatus.EXPIRED]: 0,
[SendStatus.SENT]: 0,
[SendStatus.NOT_SENT]: 0,
};

View File

@ -10,6 +10,7 @@ import {
createDocumentAuditLogData,
diffDocumentMetaChanges,
} from '@documenso/lib/utils/document-audit-logs';
import { calculateRecipientExpiry } from '@documenso/lib/utils/expiry';
import { prisma } from '@documenso/prisma';
import type { SupportedLanguageCodes } from '../../constants/i18n';
@ -37,6 +38,8 @@ export type CreateDocumentMetaOptions = {
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
language?: SupportedLanguageCodes;
expiryAmount?: number;
expiryUnit?: string;
requestMetadata: ApiRequestMetadata;
};
@ -59,6 +62,8 @@ export const updateDocumentMeta = async ({
uploadSignatureEnabled,
drawSignatureEnabled,
language,
expiryAmount,
expiryUnit,
requestMetadata,
}: CreateDocumentMetaOptions) => {
const { envelopeWhereInput, team } = await getEnvelopeWhereInput({
@ -120,9 +125,30 @@ export const updateDocumentMeta = async ({
uploadSignatureEnabled,
drawSignatureEnabled,
language,
expiryAmount,
expiryUnit,
},
});
if (expiryAmount !== undefined || expiryUnit !== undefined) {
const newExpiryDate = calculateRecipientExpiry(
upsertedDocumentMeta.expiryAmount,
upsertedDocumentMeta.expiryUnit,
new Date(),
);
await tx.recipient.updateMany({
where: {
envelopeId: envelope.id,
signingStatus: { not: 'SIGNED' },
role: { not: 'CC' },
},
data: {
expired: newExpiryDate,
},
});
}
const changes = diffDocumentMetaChanges(originalDocumentMeta ?? {}, upsertedDocumentMeta);
// Create audit logs only for document type envelopes.

View File

@ -0,0 +1,292 @@
import type { DocumentVisibility, TemplateMeta } from '@prisma/client';
import {
DocumentSource,
FolderType,
RecipientRole,
SendStatus,
SigningStatus,
WebhookTriggerEvents,
} from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { nanoid, prefixedId } from '@documenso/lib/universal/id';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import type { TCreateDocumentTemporaryRequest } from '@documenso/trpc/server/document-router/create-document-temporary.types';
import type { TDocumentAccessAuthTypes, TDocumentActionAuthTypes } from '../../types/document-auth';
import type { TDocumentFormValues } from '../../types/document-form-values';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { extractDerivedDocumentMeta } from '../../utils/document';
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
import { determineDocumentVisibility } from '../../utils/document-visibility';
import { calculateRecipientExpiry } from '../../utils/expiry';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getMemberRoles } from '../team/get-member-roles';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type CreateDocumentOptions = {
userId: number;
teamId: number;
documentDataId: string;
normalizePdf?: boolean;
data: {
title: string;
externalId?: string;
visibility?: DocumentVisibility;
globalAccessAuth?: TDocumentAccessAuthTypes[];
globalActionAuth?: TDocumentActionAuthTypes[];
formValues?: TDocumentFormValues;
recipients: TCreateDocumentTemporaryRequest['recipients'];
folderId?: string;
expiryAmount?: number;
expiryUnit?: string;
};
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
requestMetadata: ApiRequestMetadata;
};
export const createDocumentV2 = async ({
userId,
teamId,
documentDataId,
normalizePdf,
data,
meta,
requestMetadata,
}: CreateDocumentOptions) => {
const { title, formValues, folderId } = data;
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({ teamId, userId }),
include: {
organisation: {
select: {
organisationClaim: true,
},
},
},
});
if (!team) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Team not found',
});
}
if (folderId) {
const folder = await prisma.folder.findUnique({
where: {
id: folderId,
type: FolderType.DOCUMENT,
team: buildTeamWhereQuery({ teamId, userId }),
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
}
const settings = await getTeamSettings({
userId,
teamId,
});
if (normalizePdf) {
const documentData = await prisma.documentData.findFirst({
where: {
id: documentDataId,
},
});
if (documentData) {
const buffer = await getFileServerSide(documentData);
const normalizedPdf = await makeNormalizedPdf(Buffer.from(buffer));
const newDocumentData = await putPdfFileServerSide({
name: title.endsWith('.pdf') ? title : `${title}.pdf`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(normalizedPdf),
});
// eslint-disable-next-line require-atomic-updates
documentDataId = newDocumentData.id;
}
}
const authOptions = createDocumentAuthOptions({
globalAccessAuth: data?.globalAccessAuth || [],
globalActionAuth: data?.globalActionAuth || [],
});
const recipientsHaveActionAuth = data.recipients?.some(
(recipient) => recipient.actionAuth && recipient.actionAuth.length > 0,
);
// Check if user has permission to set the global action auth.
if (
(authOptions.globalActionAuth.length > 0 || recipientsHaveActionAuth) &&
!team.organisation.organisationClaim.flags.cfr21
) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to set the action auth',
});
}
const { teamRole } = await getMemberRoles({
teamId,
reference: {
type: 'User',
id: userId,
},
});
const visibility = determineDocumentVisibility(settings.documentVisibility, teamRole);
const emailId = meta?.emailId;
// Validate that the email ID belongs to the organisation.
if (emailId) {
const email = await prisma.organisationEmail.findFirst({
where: {
id: emailId,
organisationId: team.organisationId,
},
});
if (!email) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Email not found',
});
}
}
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: {
title,
qrToken: prefixedId('qr'),
externalId: data.externalId,
documentDataId,
userId,
teamId,
authOptions,
visibility,
folderId,
formValues,
source: DocumentSource.DOCUMENT,
documentMeta: {
create: extractDerivedDocumentMeta(settings, {
...meta,
expiryAmount: data.expiryAmount,
expiryUnit: data.expiryUnit,
}),
},
},
});
await Promise.all(
(data.recipients || []).map(async (recipient) => {
const recipientAuthOptions = createRecipientAuthOptions({
accessAuth: recipient.accessAuth ?? [],
actionAuth: recipient.actionAuth ?? [],
});
const expiryDate = calculateRecipientExpiry(
data.expiryAmount ?? null,
data.expiryUnit ?? null,
new Date(), // Calculate from current time
);
await tx.recipient.create({
data: {
documentId: document.id,
name: recipient.name,
email: recipient.email,
role: recipient.role,
signingOrder: recipient.signingOrder,
token: nanoid(),
sendStatus: recipient.role === RecipientRole.CC ? SendStatus.SENT : SendStatus.NOT_SENT,
signingStatus:
recipient.role === RecipientRole.CC ? SigningStatus.SIGNED : SigningStatus.NOT_SIGNED,
authOptions: recipientAuthOptions,
expired: expiryDate,
fields: {
createMany: {
data: (recipient.fields || []).map((field) => ({
documentId: document.id,
type: field.type,
page: field.pageNumber,
positionX: field.pageX,
positionY: field.pageY,
width: field.width,
height: field.height,
customText: '',
inserted: false,
fieldMeta: field.fieldMeta,
})),
},
},
},
});
}),
);
// Todo: Is it necessary to create a full audit logs with all fields and recipients audit logs?
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
documentId: document.id,
metadata: requestMetadata,
data: {
title,
source: {
type: DocumentSource.DOCUMENT,
},
},
}),
});
const createdDocument = await tx.document.findFirst({
where: {
id: document.id,
},
include: {
documentData: true,
documentMeta: true,
recipients: true,
fields: true,
folder: true,
},
});
if (!createdDocument) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document not found',
});
}
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(createdDocument)),
userId,
teamId,
});
return createdDocument;
});
};

View File

@ -0,0 +1,177 @@
import { DocumentSource, WebhookTriggerEvents } from '@prisma/client';
import type { DocumentVisibility } from '@prisma/client';
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import {
ZWebhookDocumentSchema,
mapDocumentToWebhookDocumentPayload,
} from '../../types/webhook-payload';
import { prefixedId } from '../../universal/id';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { extractDerivedDocumentMeta } from '../../utils/document';
import { determineDocumentVisibility } from '../../utils/document-visibility';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getTeamById } from '../team/get-team';
import { getTeamSettings } from '../team/get-team-settings';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
export type CreateDocumentOptions = {
title: string;
externalId?: string | null;
userId: number;
teamId: number;
documentDataId: string;
formValues?: Record<string, string | number | boolean>;
normalizePdf?: boolean;
timezone?: string;
userTimezone?: string;
requestMetadata: ApiRequestMetadata;
folderId?: string;
expiryAmount?: number;
expiryUnit?: string;
};
export const createDocument = async ({
userId,
title,
externalId,
documentDataId,
teamId,
normalizePdf,
formValues,
requestMetadata,
timezone,
userTimezone,
folderId,
expiryAmount,
expiryUnit,
}: CreateDocumentOptions) => {
const team = await getTeamById({ userId, teamId });
const settings = await getTeamSettings({
userId,
teamId,
});
let folderVisibility: DocumentVisibility | undefined;
if (folderId) {
const folder = await prisma.folder.findFirst({
where: {
id: folderId,
team: buildTeamWhereQuery({
teamId,
userId,
}),
},
select: {
visibility: true,
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
folderVisibility = folder.visibility;
}
if (normalizePdf) {
const documentData = await prisma.documentData.findFirst({
where: {
id: documentDataId,
},
});
if (documentData) {
const buffer = await getFileServerSide(documentData);
const normalizedPdf = await makeNormalizedPdf(Buffer.from(buffer));
const newDocumentData = await putPdfFileServerSide({
name: title.endsWith('.pdf') ? title : `${title}.pdf`,
type: 'application/pdf',
arrayBuffer: async () => Promise.resolve(normalizedPdf),
});
// eslint-disable-next-line require-atomic-updates
documentDataId = newDocumentData.id;
}
}
// userTimezone is last because it's always passed in regardless of the organisation/team settings
// for uploads from the frontend
const timezoneToUse = timezone || settings.documentTimezone || userTimezone;
return await prisma.$transaction(async (tx) => {
const document = await tx.document.create({
data: {
title,
qrToken: prefixedId('qr'),
externalId,
documentDataId,
userId,
teamId,
folderId,
visibility:
folderVisibility ??
determineDocumentVisibility(settings.documentVisibility, team.currentTeamRole),
formValues,
source: DocumentSource.DOCUMENT,
documentMeta: {
create: extractDerivedDocumentMeta(settings, {
timezone: timezoneToUse,
expiryAmount,
expiryUnit,
}),
},
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED,
documentId: document.id,
metadata: requestMetadata,
data: {
title,
source: {
type: DocumentSource.DOCUMENT,
},
},
}),
});
const createdDocument = await tx.document.findFirst({
where: {
id: document.id,
},
include: {
documentMeta: true,
recipients: true,
},
});
if (!createdDocument) {
throw new Error('Document not found');
}
await triggerWebhook({
event: WebhookTriggerEvents.DOCUMENT_CREATED,
data: ZWebhookDocumentSchema.parse(mapDocumentToWebhookDocumentPayload(createdDocument)),
userId,
teamId,
});
return createdDocument;
});
};

View File

@ -26,6 +26,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { extractDerivedDocumentEmailSettings } from '../../types/document-email';
import { isDocumentCompleted } from '../../utils/document';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { calculateRecipientExpiry } from '../../utils/expiry';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
import { getEmailContext } from '../email/get-email-context';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
@ -210,6 +211,39 @@ export const resendDocument = async ({
text,
});
if (envelope.documentMeta?.expiryAmount && envelope.documentMeta?.expiryUnit) {
const previousExpiryDate = recipient.expired;
const newExpiryDate = calculateRecipientExpiry(
envelope.documentMeta.expiryAmount,
envelope.documentMeta.expiryUnit,
new Date(),
);
await tx.recipient.update({
where: {
id: recipient.id,
},
data: {
expired: newExpiryDate,
},
});
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRY_EXTENDED,
envelopeId: envelope.id,
metadata: requestMetadata,
data: {
recipientId: recipient.id,
recipientName: recipient.name,
recipientEmail: recipient.email,
previousExpiryDate,
newExpiryDate,
},
}),
});
}
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,

View File

@ -24,6 +24,7 @@ import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { isDocumentCompleted } from '../../utils/document';
import { type EnvelopeIdOptions, mapSecondaryIdToDocumentId } from '../../utils/envelope';
import { calculateRecipientExpiry } from '../../utils/expiry';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { insertFormValuesInPdf } from '../pdf/insert-form-values-in-pdf';
import { triggerWebhook } from '../webhooks/trigger/trigger-webhook';
@ -177,6 +178,24 @@ export const sendDocument = async ({
});
}
if (envelope.documentMeta?.expiryAmount && envelope.documentMeta?.expiryUnit) {
const expiryDate = calculateRecipientExpiry(
envelope.documentMeta.expiryAmount,
envelope.documentMeta.expiryUnit,
new Date(), // Calculate from current time
);
await tx.recipient.updateMany({
where: {
envelopeId: envelope.id,
expired: null,
},
data: {
expired: expiryDate,
},
});
}
return await tx.envelope.update({
where: {
id: envelope.id,

View File

@ -25,7 +25,9 @@ import {
} from '../../types/field-meta';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
import { isRecipientExpired } from '../../utils/expiry';
import { validateFieldAuth } from '../document/validate-field-auth';
import { expireRecipient } from '../recipient/expire-recipient';
export type SignFieldWithTokenOptions = {
token: string;
@ -115,6 +117,11 @@ export const signFieldWithToken = async ({
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) {
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

@ -52,6 +52,7 @@ import {
} from '../../utils/document-auth';
import type { EnvelopeIdOptions } from '../../utils/envelope';
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { calculateRecipientExpiry } from '../../utils/expiry';
import { buildTeamWhereQuery } from '../../utils/teams';
import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id';
import { incrementDocumentId } from '../envelope/increment-id';
@ -110,6 +111,8 @@ export type CreateDocumentFromTemplateOptions = {
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
expiryAmount?: number;
expiryUnit?: string;
};
requestMetadata: ApiRequestMetadata;
};
@ -508,6 +511,16 @@ export const createDocumentFromTemplate = async ({
data: finalRecipients.map((recipient) => {
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient?.authOptions);
// Calculate expiry date based on override
// Note: Templates no longer have default expiry settings (TemplateMeta removed)
const expiryAmount = override?.expiryAmount ?? null;
const expiryUnit = override?.expiryUnit ?? null;
const recipientExpiryDate = calculateRecipientExpiry(
expiryAmount,
expiryUnit,
new Date(), // Calculate from current time
);
return {
email: recipient.email,
name: recipient.name,
@ -523,6 +536,7 @@ export const createDocumentFromTemplate = async ({
? SigningStatus.SIGNED
: SigningStatus.NOT_SIGNED,
signingOrder: recipient.signingOrder,
expired: recipientExpiryDate,
token: recipient.token,
};
}),

View File

@ -267,6 +267,11 @@ msgstr "{prefix} hat das Dokument erstellt"
msgid "{prefix} deleted the document"
msgstr "{prefix} hat das Dokument gelöscht"
#. placeholder {0}: data.data.recipientEmail
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} extended expiry for {0}"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} moved the document to team"
msgstr "{prefix} hat das Dokument ins Team verschoben"
@ -1705,6 +1710,10 @@ msgctxt "Recipient role progressive verb"
msgid "Assisting"
msgstr ""
#: packages/ui/primitives/date-time-picker.tsx
msgid "at"
msgstr ""
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
@ -4076,9 +4085,18 @@ msgid "Exceeded timeout"
msgstr "Zeitüberschreitung überschritten"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/documents-table-action-button.tsx
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
msgid "Expired"
msgstr "Abgelaufen"
#. placeholder {0}: new Date(recipient.expired).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Expired on: {0}"
msgstr ""
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
msgid "Expires in {0}"
@ -4958,6 +4976,11 @@ msgstr "Link läuft in 1 Stunde ab."
msgid "Link expires in 30 minutes."
msgstr ""
#: packages/ui/primitives/expiry-settings-picker.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
msgid "Link Expiry"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
msgid "Link template"
msgstr "Vorlage verlinken"
@ -4979,6 +5002,11 @@ msgstr ""
msgid "Links Generated"
msgstr "Links generiert"
#. placeholder {0}: formatExpiryDate(calculatedExpiryDate)
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Links will expire on: {0}"
msgstr ""
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
msgid "Listening to {0}"
@ -5967,6 +5995,10 @@ msgstr "Persönliches Konto"
msgid "Personal Inbox"
msgstr "Persönlicher Posteingang"
#: packages/ui/primitives/date-time-picker.tsx
msgid "Pick a date"
msgstr ""
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
msgid "Pick a number"
@ -6381,6 +6413,10 @@ msgstr "Empfänger"
msgid "Recipient action authentication"
msgstr "Empfängeraktion Authentifizierung"
#: packages/lib/utils/document-audit-logs.ts
msgid "Recipient expiry extended"
msgstr ""
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Recipient removed email"
msgstr "E-Mail des entfernten Empfängers"
@ -7095,6 +7131,10 @@ msgstr "Sitzungen wurden widerrufen"
msgid "Set a password"
msgstr "Ein Passwort festlegen"
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Set an expiry duration for signing links (leave empty to disable)"
msgstr ""
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
msgid "Set up your document properties and recipient information"
msgstr "Richten Sie Ihre Dokumenteigenschaften und Empfängerinformationen ein"
@ -7393,6 +7433,10 @@ msgstr "Unterzeichne für"
msgid "Signing in..."
msgstr "Anmeldung..."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Signing Link Expired"
msgstr ""
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
msgid "Signing Links"
@ -8267,6 +8311,10 @@ msgstr "Der Name des Unterzeichners"
msgid "The signing link has been copied to your clipboard."
msgstr "Der Signierlink wurde in die Zwischenablage kopiert."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "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."
msgstr ""
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
msgstr "Das Seitenbanner ist eine Nachricht, die oben auf der Seite angezeigt wird. Es kann verwendet werden, um Ihren Nutzern wichtige Informationen anzuzeigen."
@ -8585,6 +8633,10 @@ msgstr "Diese Sitzung ist abgelaufen. Bitte versuchen Sie es erneut."
msgid "This signer has already signed the document."
msgstr "Dieser Unterzeichner hat das Dokument bereits unterschrieben."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "This signing link is no longer valid"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
msgstr "Dieses Team und alle zugehörigen Daten, ausgenommen Rechnungen, werden permanent gelöscht."

View File

@ -262,6 +262,11 @@ msgstr "{prefix} created the document"
msgid "{prefix} deleted the document"
msgstr "{prefix} deleted the document"
#. placeholder {0}: data.data.recipientEmail
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} extended expiry for {0}"
msgstr "{prefix} extended expiry for {0}"
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} moved the document to team"
msgstr "{prefix} moved the document to team"
@ -1700,6 +1705,10 @@ msgctxt "Recipient role progressive verb"
msgid "Assisting"
msgstr "Assisting"
#: packages/ui/primitives/date-time-picker.tsx
msgid "at"
msgstr "at"
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
@ -4071,9 +4080,18 @@ msgid "Exceeded timeout"
msgstr "Exceeded timeout"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/documents-table-action-button.tsx
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
msgid "Expired"
msgstr "Expired"
#. placeholder {0}: new Date(recipient.expired).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Expired on: {0}"
msgstr "Expired on: {0}"
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
msgid "Expires in {0}"
@ -4953,6 +4971,11 @@ msgstr "Link expires in 1 hour."
msgid "Link expires in 30 minutes."
msgstr "Link expires in 30 minutes."
#: packages/ui/primitives/expiry-settings-picker.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
msgid "Link Expiry"
msgstr "Link Expiry"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
msgid "Link template"
msgstr "Link template"
@ -4974,6 +4997,11 @@ msgstr "Linked At"
msgid "Links Generated"
msgstr "Links Generated"
#. placeholder {0}: formatExpiryDate(calculatedExpiryDate)
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Links will expire on: {0}"
msgstr "Links will expire on: {0}"
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
msgid "Listening to {0}"
@ -5962,6 +5990,10 @@ msgstr "Personal Account"
msgid "Personal Inbox"
msgstr "Personal Inbox"
#: packages/ui/primitives/date-time-picker.tsx
msgid "Pick a date"
msgstr "Pick a date"
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
msgid "Pick a number"
@ -6376,6 +6408,10 @@ msgstr "Recipient"
msgid "Recipient action authentication"
msgstr "Recipient action authentication"
#: packages/lib/utils/document-audit-logs.ts
msgid "Recipient expiry extended"
msgstr "Recipient expiry extended"
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Recipient removed email"
msgstr "Recipient removed email"
@ -7090,6 +7126,10 @@ msgstr "Sessions have been revoked"
msgid "Set a password"
msgstr "Set a password"
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Set an expiry duration for signing links (leave empty to disable)"
msgstr "Set an expiry duration for signing links (leave empty to disable)"
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
msgid "Set up your document properties and recipient information"
msgstr "Set up your document properties and recipient information"
@ -7388,6 +7428,10 @@ msgstr "Signing for"
msgid "Signing in..."
msgstr "Signing in..."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Signing Link Expired"
msgstr "Signing Link Expired"
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
msgid "Signing Links"
@ -8272,6 +8316,10 @@ msgstr "The signer's name"
msgid "The signing link has been copied to your clipboard."
msgstr "The signing link has been copied to your clipboard."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "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."
msgstr "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."
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
msgstr "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
@ -8600,6 +8648,10 @@ msgstr "This session has expired. Please try again."
msgid "This signer has already signed the document."
msgstr "This signer has already signed the document."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "This signing link is no longer valid"
msgstr "This signing link is no longer valid"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
msgstr "This team, and any associated data excluding billing invoices will be permanently deleted."

View File

@ -267,6 +267,11 @@ msgstr "{prefix} creó el documento"
msgid "{prefix} deleted the document"
msgstr "{prefix} eliminó el documento"
#. placeholder {0}: data.data.recipientEmail
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} extended expiry for {0}"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} moved the document to team"
msgstr "{prefix} movió el documento al equipo"
@ -1705,6 +1710,10 @@ msgctxt "Recipient role progressive verb"
msgid "Assisting"
msgstr ""
#: packages/ui/primitives/date-time-picker.tsx
msgid "at"
msgstr ""
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
@ -4076,9 +4085,18 @@ msgid "Exceeded timeout"
msgstr "Tiempo de espera excedido"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/documents-table-action-button.tsx
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
msgid "Expired"
msgstr "Expirado"
#. placeholder {0}: new Date(recipient.expired).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Expired on: {0}"
msgstr ""
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
msgid "Expires in {0}"
@ -4958,6 +4976,11 @@ msgstr "El enlace expira en 1 hora."
msgid "Link expires in 30 minutes."
msgstr ""
#: packages/ui/primitives/expiry-settings-picker.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
msgid "Link Expiry"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
msgid "Link template"
msgstr "Enlace de plantilla"
@ -4979,6 +5002,11 @@ msgstr ""
msgid "Links Generated"
msgstr "Enlaces generados"
#. placeholder {0}: formatExpiryDate(calculatedExpiryDate)
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Links will expire on: {0}"
msgstr ""
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
msgid "Listening to {0}"
@ -5967,6 +5995,10 @@ msgstr "Cuenta personal"
msgid "Personal Inbox"
msgstr "Bandeja de entrada personal"
#: packages/ui/primitives/date-time-picker.tsx
msgid "Pick a date"
msgstr ""
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
msgid "Pick a number"
@ -6381,6 +6413,10 @@ msgstr "Destinatario"
msgid "Recipient action authentication"
msgstr "Autenticación de acción de destinatario"
#: packages/lib/utils/document-audit-logs.ts
msgid "Recipient expiry extended"
msgstr ""
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Recipient removed email"
msgstr "Correo electrónico de destinatario eliminado"
@ -7095,6 +7131,10 @@ msgstr "Las sesiones han sido revocadas"
msgid "Set a password"
msgstr "Establecer una contraseña"
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Set an expiry duration for signing links (leave empty to disable)"
msgstr ""
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
msgid "Set up your document properties and recipient information"
msgstr "Configura las propiedades de tu documento y la información del destinatario"
@ -7393,6 +7433,10 @@ msgstr "Firmando para"
msgid "Signing in..."
msgstr "Iniciando sesión..."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Signing Link Expired"
msgstr ""
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
msgid "Signing Links"
@ -8267,6 +8311,10 @@ msgstr "El nombre del firmante"
msgid "The signing link has been copied to your clipboard."
msgstr "El enlace de firma ha sido copiado a tu portapapeles."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "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."
msgstr ""
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
msgstr "El banner del sitio es un mensaje que se muestra en la parte superior del sitio. Se puede usar para mostrar información importante a tus usuarios."
@ -8587,6 +8635,10 @@ msgstr "Esta sesión ha expirado. Por favor, inténtalo de nuevo."
msgid "This signer has already signed the document."
msgstr "Este firmante ya ha firmado el documento."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "This signing link is no longer valid"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
msgstr "Este equipo, y cualquier dato asociado, excluyendo las facturas de facturación, serán eliminados permanentemente."

View File

@ -267,6 +267,11 @@ msgstr "{prefix} a créé le document"
msgid "{prefix} deleted the document"
msgstr "{prefix} a supprimé le document"
#. placeholder {0}: data.data.recipientEmail
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} extended expiry for {0}"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} moved the document to team"
msgstr "{prefix} a déplacé le document vers l'équipe"
@ -1705,6 +1710,10 @@ msgctxt "Recipient role progressive verb"
msgid "Assisting"
msgstr ""
#: packages/ui/primitives/date-time-picker.tsx
msgid "at"
msgstr ""
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
@ -4076,9 +4085,18 @@ msgid "Exceeded timeout"
msgstr "Délai dépassé"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/documents-table-action-button.tsx
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
msgid "Expired"
msgstr "Expiré"
#. placeholder {0}: new Date(recipient.expired).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Expired on: {0}"
msgstr ""
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
msgid "Expires in {0}"
@ -4958,6 +4976,11 @@ msgstr "Le lien expire dans 1 heure."
msgid "Link expires in 30 minutes."
msgstr ""
#: packages/ui/primitives/expiry-settings-picker.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
msgid "Link Expiry"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
msgid "Link template"
msgstr "Modèle de lien"
@ -4979,6 +5002,11 @@ msgstr ""
msgid "Links Generated"
msgstr "Liens générés"
#. placeholder {0}: formatExpiryDate(calculatedExpiryDate)
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Links will expire on: {0}"
msgstr ""
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
msgid "Listening to {0}"
@ -5967,6 +5995,10 @@ msgstr "Compte personnel"
msgid "Personal Inbox"
msgstr "Boîte de réception personnelle"
#: packages/ui/primitives/date-time-picker.tsx
msgid "Pick a date"
msgstr ""
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
msgid "Pick a number"
@ -6381,6 +6413,10 @@ msgstr "Destinataire"
msgid "Recipient action authentication"
msgstr "Authentification d'action de destinataire"
#: packages/lib/utils/document-audit-logs.ts
msgid "Recipient expiry extended"
msgstr ""
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Recipient removed email"
msgstr "E-mail de destinataire supprimé"
@ -7095,6 +7131,10 @@ msgstr "Les sessions ont été révoquées"
msgid "Set a password"
msgstr "Définir un mot de passe"
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Set an expiry duration for signing links (leave empty to disable)"
msgstr ""
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
msgid "Set up your document properties and recipient information"
msgstr "Configurez les propriétés de votre document et les informations du destinataire"
@ -7393,6 +7433,10 @@ msgstr "Signé pour"
msgid "Signing in..."
msgstr "Connexion en cours..."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Signing Link Expired"
msgstr ""
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
msgid "Signing Links"
@ -8267,6 +8311,10 @@ msgstr "Le nom du signataire"
msgid "The signing link has been copied to your clipboard."
msgstr "Le lien de signature a été copié dans votre presse-papiers."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "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."
msgstr ""
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
msgstr "La bannière du site est un message affiché en haut du site. Elle peut être utilisée pour afficher des informations importantes à vos utilisateurs."
@ -8585,6 +8633,10 @@ msgstr "Cette session a expiré. Veuillez réessayer."
msgid "This signer has already signed the document."
msgstr "Ce signataire a déjà signé le document."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "This signing link is no longer valid"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
msgstr "Cette équipe, et toutes les données associées à l'exception des factures de facturation, seront définitivement supprimées."

View File

@ -267,6 +267,11 @@ msgstr "{prefix} ha creato il documento"
msgid "{prefix} deleted the document"
msgstr "{prefix} ha eliminato il documento"
#. placeholder {0}: data.data.recipientEmail
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} extended expiry for {0}"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} moved the document to team"
msgstr "{prefix} ha spostato il documento al team"
@ -1705,6 +1710,10 @@ msgctxt "Recipient role progressive verb"
msgid "Assisting"
msgstr ""
#: packages/ui/primitives/date-time-picker.tsx
msgid "at"
msgstr ""
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
@ -4076,9 +4085,18 @@ msgid "Exceeded timeout"
msgstr "Tempo scaduto"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/documents-table-action-button.tsx
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
msgid "Expired"
msgstr "Scaduto"
#. placeholder {0}: new Date(recipient.expired).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Expired on: {0}"
msgstr ""
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
msgid "Expires in {0}"
@ -4958,6 +4976,11 @@ msgstr "Il link scade tra 1 ora."
msgid "Link expires in 30 minutes."
msgstr ""
#: packages/ui/primitives/expiry-settings-picker.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
msgid "Link Expiry"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
msgid "Link template"
msgstr "Collega modello"
@ -4979,6 +5002,11 @@ msgstr ""
msgid "Links Generated"
msgstr "Link Generati"
#. placeholder {0}: formatExpiryDate(calculatedExpiryDate)
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Links will expire on: {0}"
msgstr ""
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
msgid "Listening to {0}"
@ -5967,6 +5995,10 @@ msgstr "Account personale"
msgid "Personal Inbox"
msgstr "Posta in arrivo personale"
#: packages/ui/primitives/date-time-picker.tsx
msgid "Pick a date"
msgstr ""
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
msgid "Pick a number"
@ -6381,6 +6413,10 @@ msgstr "Destinatario"
msgid "Recipient action authentication"
msgstr "Autenticazione azione destinatario"
#: packages/lib/utils/document-audit-logs.ts
msgid "Recipient expiry extended"
msgstr ""
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Recipient removed email"
msgstr "Email destinatario rimosso"
@ -7095,6 +7131,10 @@ msgstr "Le sessioni sono state revocate"
msgid "Set a password"
msgstr "Imposta una password"
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Set an expiry duration for signing links (leave empty to disable)"
msgstr ""
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
msgid "Set up your document properties and recipient information"
msgstr "Configura le proprietà del documento e le informazioni sui destinatari"
@ -7393,6 +7433,10 @@ msgstr "Firma per"
msgid "Signing in..."
msgstr "Accesso in corso..."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Signing Link Expired"
msgstr ""
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
msgid "Signing Links"
@ -8275,6 +8319,10 @@ msgstr "Il nome del firmatario"
msgid "The signing link has been copied to your clipboard."
msgstr "Il link di firma è stato copiato negli appunti."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "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."
msgstr ""
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
msgstr "Il banner del sito è un messaggio che viene mostrato in cima al sito. Può essere utilizzato per visualizzare informazioni importanti ai tuoi utenti."
@ -8601,6 +8649,10 @@ msgstr "Questa sessione è scaduta. Per favore prova di nuovo."
msgid "This signer has already signed the document."
msgstr "Questo firmatario ha già firmato il documento."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "This signing link is no longer valid"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
msgstr "Questo team e tutti i dati associati, escluse le fatture di fatturazione, verranno eliminati definitivamente."

View File

@ -267,6 +267,11 @@ msgstr "Użytkownik {prefix} utworzył dokument"
msgid "{prefix} deleted the document"
msgstr "Użytkownik {prefix} usunął dokument"
#. placeholder {0}: data.data.recipientEmail
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} extended expiry for {0}"
msgstr ""
#: packages/lib/utils/document-audit-logs.ts
msgid "{prefix} moved the document to team"
msgstr "Użytkownik {prefix} przeniósł dokument do zespołu"
@ -1705,6 +1710,10 @@ msgctxt "Recipient role progressive verb"
msgid "Assisting"
msgstr ""
#: packages/ui/primitives/date-time-picker.tsx
msgid "at"
msgstr ""
#: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: packages/ui/primitives/template-flow/add-template-settings.types.tsx
@ -4076,9 +4085,18 @@ msgid "Exceeded timeout"
msgstr "Przekroczono limit czasu"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/documents-table-action-button.tsx
#: apps/remix/app/components/general/stack-avatars-with-tooltip.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
msgid "Expired"
msgstr "Wygasł"
#. placeholder {0}: new Date(recipient.expired).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', })
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Expired on: {0}"
msgstr ""
#. placeholder {0}: DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( 'mm:ss', )
#: apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx
msgid "Expires in {0}"
@ -4958,6 +4976,11 @@ msgstr "Link wygaśnie za 1 godzinę."
msgid "Link expires in 30 minutes."
msgstr ""
#: packages/ui/primitives/expiry-settings-picker.tsx
#: packages/ui/primitives/document-flow/add-settings.tsx
msgid "Link Expiry"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
msgid "Link template"
msgstr "Szablon linku"
@ -4979,6 +5002,11 @@ msgstr ""
msgid "Links Generated"
msgstr "Wygenerowane linki"
#. placeholder {0}: formatExpiryDate(calculatedExpiryDate)
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Links will expire on: {0}"
msgstr ""
#. placeholder {0}: webhook.eventTriggers .map((trigger) => toFriendlyWebhookEventName(trigger)) .join(', ')
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx
msgid "Listening to {0}"
@ -5967,6 +5995,10 @@ msgstr "Konto osobiste"
msgid "Personal Inbox"
msgstr "Skrzynka odbiorcza osobista"
#: packages/ui/primitives/date-time-picker.tsx
msgid "Pick a date"
msgstr ""
#: apps/remix/app/components/forms/editor/editor-field-checkbox-form.tsx
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
msgid "Pick a number"
@ -6381,6 +6413,10 @@ msgstr "Odbiorca"
msgid "Recipient action authentication"
msgstr "Uwierzytelnianie odbiorcy"
#: packages/lib/utils/document-audit-logs.ts
msgid "Recipient expiry extended"
msgstr ""
#: packages/ui/components/document/document-email-checkboxes.tsx
msgid "Recipient removed email"
msgstr "Wiadomość o usuniętym odbiorcy"
@ -7095,6 +7131,10 @@ msgstr "Sesje zostały odwołane"
msgid "Set a password"
msgstr "Ustaw hasło"
#: packages/ui/primitives/expiry-settings-picker.tsx
msgid "Set an expiry duration for signing links (leave empty to disable)"
msgstr ""
#: apps/remix/app/components/embed/authoring/configure-document-view.tsx
msgid "Set up your document properties and recipient information"
msgstr "Skonfiguruj właściwości dokumentu i informacje o odbiorcach"
@ -7393,6 +7433,10 @@ msgstr "Podpis w imieniu"
msgid "Signing in..."
msgstr "Logowanie..."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "Signing Link Expired"
msgstr ""
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
msgid "Signing Links"
@ -8267,6 +8311,10 @@ msgstr "Nazwa podpisującego"
msgid "The signing link has been copied to your clipboard."
msgstr "Link do podpisu został skopiowany do schowka."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "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."
msgstr ""
#: apps/remix/app/routes/_authenticated+/admin+/site-settings.tsx
msgid "The site banner is a message that is shown at the top of the site. It can be used to display important information to your users."
msgstr "Baner strony to wiadomość, która jest wyświetlana u góry strony. Może być używany do wyświetlania ważnych informacji użytkownikom."
@ -8585,6 +8633,10 @@ msgstr "Ta sesja wygasła. Proszę spróbować ponownie."
msgid "This signer has already signed the document."
msgstr "Ten sygnatariusz już podpisał dokument."
#: apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
msgid "This signing link is no longer valid"
msgstr ""
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx
msgid "This team, and any associated data excluding billing invoices will be permanently deleted."
msgstr "Ten zespół oraz wszelkie powiązane dane, z wyjątkiem faktur, zostaną trwale usunięte."

View File

@ -40,6 +40,7 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
'DOCUMENT_TITLE_UPDATED', // When the document title is updated.
'DOCUMENT_EXTERNAL_ID_UPDATED', // When the document external ID is updated.
'DOCUMENT_MOVED_TO_TEAM', // When the document is moved to a team.
'DOCUMENT_RECIPIENT_EXPIRY_EXTENDED', // When a recipient's expiry is extended via resend.
// ACCESS AUTH 2FA events.
'DOCUMENT_ACCESS_AUTH_2FA_REQUESTED', // When ACCESS AUTH 2FA is requested.
@ -639,6 +640,20 @@ export const ZDocumentAuditLogEventDocumentMovedToTeamSchema = z.object({
}),
});
/**
* Event: Recipient expiry extended.
*/
export const ZDocumentAuditLogEventRecipientExpiryExtendedSchema = z.object({
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRY_EXTENDED),
data: z.object({
recipientId: z.number(),
recipientName: z.string().optional(),
recipientEmail: z.string(),
previousExpiryDate: z.date().nullable(),
newExpiryDate: z.date().nullable(),
}),
});
export const ZDocumentAuditLogBaseSchema = z.object({
id: z.string(),
createdAt: z.date(),
@ -680,6 +695,7 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
ZDocumentAuditLogEventRecipientAddedSchema,
ZDocumentAuditLogEventRecipientUpdatedSchema,
ZDocumentAuditLogEventRecipientRemovedSchema,
ZDocumentAuditLogEventRecipientExpiryExtendedSchema,
]),
);

View File

@ -107,6 +107,16 @@ export const ZDocumentMetaUploadSignatureEnabledSchema = z
.boolean()
.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").');
/**
* Note: Any updates to this will cause public API changes. You will need to update
* all corresponding areas where this is used (some places that use this needs to pass
@ -128,6 +138,8 @@ export const ZDocumentMetaCreateSchema = z.object({
emailId: z.string().nullish(),
emailReplyTo: z.string().email().nullish(),
emailSettings: ZDocumentEmailSettingsSchema.nullish(),
expiryAmount: ZDocumentExpiryAmountSchema.optional(),
expiryUnit: ZDocumentExpiryUnitSchema.optional(),
});
export type TDocumentMetaCreate = z.infer<typeof ZDocumentMetaCreateSchema>;

View File

@ -69,6 +69,8 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
emailSettings: true,
emailId: true,
emailReplyTo: true,
expiryAmount: true,
expiryUnit: true,
}).extend({
password: z.string().nullable().default(null),
documentId: z.number().default(-1).optional(),

View File

@ -515,6 +515,10 @@ export const formatDocumentAuditLogAction = (
context: `Audit log format`,
}),
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_EXPIRY_EXTENDED }, (data) => ({
anonymous: msg`Recipient expiry extended`,
identified: msg`${prefix} extended expiry for ${data.data.recipientEmail}`,
}))
.exhaustive();
return {

View File

@ -20,6 +20,26 @@ export const isDocumentCompleted = (document: Pick<Envelope, 'status'> | Documen
return status === DocumentStatus.COMPLETED || status === DocumentStatus.REJECTED;
};
const getExpiryAmount = (meta: Partial<DocumentMeta> | undefined | null): number | null => {
if (!meta) return null;
if ('expiryAmount' in meta && meta.expiryAmount !== undefined) {
return meta.expiryAmount;
}
return null;
};
const getExpiryUnit = (meta: Partial<DocumentMeta> | undefined | null): string | null => {
if (!meta) return null;
if ('expiryUnit' in meta && meta.expiryUnit !== undefined) {
return meta.expiryUnit;
}
return null;
};
/**
* Extracts the derived document meta which should be used when creating a document
* from scratch, or from a template.
@ -62,6 +82,10 @@ export const extractDerivedDocumentMeta = (
emailReplyTo: meta.emailReplyTo ?? settings.emailReplyTo,
emailSettings:
meta.emailSettings || settings.emailDocumentSettings || DEFAULT_DOCUMENT_EMAIL_SETTINGS,
// Expiry settings.
expiryAmount: getExpiryAmount(meta),
expiryUnit: getExpiryUnit(meta),
} satisfies Omit<DocumentMeta, 'id'>;
};

View File

@ -0,0 +1,72 @@
import type { Recipient } from '@prisma/client';
import { DateTime } from 'luxon';
export interface DurationValue {
amount: number;
unit: string;
}
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 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();
}
};
export const formatExpiryDate = (date: Date): string => {
return DateTime.fromJSDate(date).toFormat('MMM dd, yyyy HH:mm');
};

View File

@ -0,0 +1,6 @@
-- AlterEnum
ALTER TYPE "SigningStatus" ADD VALUE 'EXPIRED';
-- AlterTable
ALTER TABLE "DocumentMeta" ADD COLUMN "expiryAmount" INTEGER,
ADD COLUMN "expiryUnit" TEXT;

View File

@ -505,6 +505,9 @@ model DocumentMeta {
emailReplyTo String?
emailId String?
expiryAmount Int?
expiryUnit String?
envelope Envelope?
}
@ -522,6 +525,7 @@ enum SigningStatus {
NOT_SIGNED
SIGNED
REJECTED
EXPIRED
}
enum RecipientRole {

View File

@ -6,6 +6,7 @@ import { createDocumentData } from '@documenso/lib/server-only/document-data/cre
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { isValidExpirySettings } from '@documenso/lib/utils/expiry';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure } from '../trpc';
@ -37,8 +38,17 @@ export const createDocumentTemporaryRoute = authenticatedProcedure
recipients,
meta,
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.',
});
}
const { remaining } = await getServerLimits({ userId: user.id, teamId });
if (remaining.documents <= 0) {
@ -89,6 +99,8 @@ export const createDocumentTemporaryRoute = authenticatedProcedure
meta: {
...meta,
emailSettings: meta?.emailSettings ?? undefined,
expiryAmount,
expiryUnit,
},
requestMetadata: ctx.metadata,
});

View File

@ -19,6 +19,8 @@ import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { ZCreateRecipientSchema } from '../recipient-router/schema';
import type { TrpcRouteMeta } from '../trpc';
import {
ZDocumentExpiryAmountSchema,
ZDocumentExpiryUnitSchema,
ZDocumentExternalIdSchema,
ZDocumentTitleSchema,
ZDocumentVisibilitySchema,
@ -51,6 +53,8 @@ export const ZCreateDocumentTemporaryRequestSchema = z.object({
'The ID of the folder to create the document in. If not provided, the document will be created in the root folder.',
)
.optional(),
expiryAmount: ZDocumentExpiryAmountSchema.optional(),
expiryUnit: ZDocumentExpiryUnitSchema.optional(),
recipients: z
.array(
ZCreateRecipientSchema.extend({

View File

@ -4,6 +4,7 @@ import { getServerLimits } from '@documenso/ee/server-only/limits/server';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createEnvelope } from '@documenso/lib/server-only/envelope/create-envelope';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { isValidExpirySettings } from '@documenso/lib/utils/expiry';
import { authenticatedProcedure } from '../trpc';
import {
@ -16,7 +17,14 @@ export const createDocumentRoute = authenticatedProcedure
.output(ZCreateDocumentResponseSchema)
.mutation(async ({ input, 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({
input: {
@ -48,6 +56,10 @@ export const createDocumentRoute = authenticatedProcedure
},
],
},
meta: {
expiryAmount,
expiryUnit,
},
normalizePdf: true,
requestMetadata: ctx.metadata,
});

View File

@ -1,6 +1,10 @@
import { z } from 'zod';
import { ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta';
import {
ZDocumentExpiryAmountSchema,
ZDocumentExpiryUnitSchema,
ZDocumentMetaTimezoneSchema,
} from '@documenso/lib/types/document-meta';
import { ZDocumentTitleSchema } from './schema';
@ -19,6 +23,8 @@ export const ZCreateDocumentRequestSchema = z.object({
documentDataId: z.string().min(1),
timezone: ZDocumentMetaTimezoneSchema.optional(),
folderId: z.string().describe('The ID of the folder to create the document in').optional(),
expiryAmount: ZDocumentExpiryAmountSchema.optional(),
expiryUnit: ZDocumentExpiryUnitSchema.optional(),
});
export const ZCreateDocumentResponseSchema = z.object({

View File

@ -1,6 +1,11 @@
import { DocumentVisibility } from '@prisma/client';
import { z } from 'zod';
import {
ZDocumentExpiryAmountSchema,
ZDocumentExpiryUnitSchema,
} from '@documenso/lib/types/document-meta';
/**
* Required for empty responses since we currently can't 201 requests for our openapi setup.
*
@ -30,3 +35,6 @@ export const ZDocumentExternalIdSchema = z
export const ZDocumentVisibilitySchema = z
.nativeEnum(DocumentVisibility)
.describe('The visibility of the document.');
// Re-export expiry schemas for convenience
export { ZDocumentExpiryAmountSchema, ZDocumentExpiryUnitSchema };

View File

@ -1,5 +1,7 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { updateEnvelope } from '@documenso/lib/server-only/envelope/update-envelope';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { isValidExpirySettings } from '@documenso/lib/utils/expiry';
import { authenticatedProcedure } from '../trpc';
import {
@ -27,6 +29,15 @@ export const updateDocumentRoute = authenticatedProcedure
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.',
});
}
const envelope = await updateEnvelope({
userId,
teamId,

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,
} from '@prisma/client';
import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useForm, useWatch } from 'react-hook-form';
import { match } from 'ts-pattern';
import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave';
@ -57,6 +57,7 @@ import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combo
import { DocumentSignatureSettingsTooltip } from '../../components/document/document-signature-settings-tooltip';
import { Combobox } from '../combobox';
import { ExpirySettingsPicker } from '../expiry-settings-picker';
import { Input } from '../input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
import { useStep } from '../stepper';
@ -72,6 +73,18 @@ import {
} from './document-flow-root';
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 = {
documentFlow: DocumentFlowStep;
recipients: Recipient[];
@ -101,6 +114,9 @@ export const AddSettingsFormPartial = ({
documentAuth: document.authOptions,
});
const documentExpiryUnit = document.documentMeta?.expiryUnit;
const initialExpiryUnit = isExpiryUnit(documentExpiryUnit) ? documentExpiryUnit : undefined;
const form = useForm<TAddSettingsFormSchema>({
resolver: zodResolver(ZAddSettingsFormSchema),
defaultValues: {
@ -120,6 +136,8 @@ export const AddSettingsFormPartial = ({
redirectUrl: document.documentMeta?.redirectUrl ?? '',
language: document.documentMeta?.language ?? 'en',
signatureTypes: extractTeamSignatureSettings(document.documentMeta),
expiryAmount: document.documentMeta?.expiryAmount ?? undefined,
expiryUnit: initialExpiryUnit,
},
},
});
@ -130,6 +148,9 @@ export const AddSettingsFormPartial = ({
(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)
.with(TeamMemberRole.ADMIN, () => true)
.with(
@ -522,6 +543,33 @@ export const AddSettingsFormPartial = ({
</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>
</AccordionContent>
</AccordionItem>

View File

@ -46,6 +46,8 @@ export const ZAddSettingsFormSchema = z.object({
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(1, {
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

@ -63,6 +63,8 @@ export type AddSignersFormProps = {
fields: Field[];
signingOrder?: DocumentSigningOrder | null;
allowDictateNextSigner?: boolean;
expiryAmount?: number | null;
expiryUnit?: 'minutes' | 'hours' | 'days' | 'weeks' | 'months' | null;
onSubmit: (_data: TAddSignersFormSchema) => void;
onAutoSave: (_data: TAddSignersFormSchema) => Promise<AutoSaveResponse>;
isDocumentPdfLoaded: boolean;
@ -74,6 +76,8 @@ export const AddSignersFormPartial = ({
fields,
signingOrder,
allowDictateNextSigner,
expiryAmount,
expiryUnit,
onSubmit,
onAutoSave,
isDocumentPdfLoaded,
@ -138,6 +142,10 @@ export const AddSignersFormPartial = ({
: defaultRecipients,
signingOrder: signingOrder || DocumentSigningOrder.PARALLEL,
allowDictateNextSigner: allowDictateNextSigner ?? false,
meta: {
expiryAmount: expiryAmount ?? undefined,
expiryUnit: expiryUnit ?? undefined,
},
},
});

View File

@ -3,6 +3,10 @@ import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { z } from 'zod';
import { ZRecipientActionAuthTypesSchema } from '@documenso/lib/types/document-auth';
import {
ZDocumentExpiryAmountSchema,
ZDocumentExpiryUnitSchema,
} from '@documenso/lib/types/document-meta';
export const ZAddSignersFormSchema = z.object({
signers: z.array(
@ -21,6 +25,10 @@ export const ZAddSignersFormSchema = z.object({
),
signingOrder: z.nativeEnum(DocumentSigningOrder),
allowDictateNextSigner: z.boolean().default(false),
meta: z.object({
expiryAmount: ZDocumentExpiryAmountSchema.optional(),
expiryUnit: ZDocumentExpiryUnitSchema.optional(),
}),
});
export type TAddSignersFormSchema = z.infer<typeof ZAddSignersFormSchema>;

View File

@ -0,0 +1,79 @@
'use client';
import React from 'react';
import type { DurationValue } from '@documenso/lib/utils/expiry';
import { cn } from '../lib/utils';
import { Input } from './input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './select';
export interface DurationSelectorProps {
value?: DurationValue;
onChange?: (value: DurationValue) => void;
disabled?: boolean;
className?: string;
minAmount?: number;
maxAmount?: number;
}
const TIME_UNITS: Array<{ value: string; 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 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: string) => {
onChange?.({ ...value, unit });
};
const getUnitLabel = (unit: string, 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>
);
};

View File

@ -0,0 +1,132 @@
'use client';
import React from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { calculateExpiryDate, formatExpiryDate } from '@documenso/lib/utils/expiry';
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 form = useForm<ExpirySettings>({
resolver: zodResolver(ZExpirySettingsSchema),
defaultValues,
mode: 'onChange',
});
const { watch, setValue, getValues } = form;
const expiryDuration = watch('expiryDuration');
const calculatedExpiryDate = React.useMemo(() => {
if (expiryDuration?.amount && expiryDuration?.unit) {
return calculateExpiryDate(expiryDuration);
}
return null;
}, [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>
{calculatedExpiryDate && (
<FormDescription>
<Trans>Links will expire on: {formatExpiryDate(calculatedExpiryDate)}</Trans>
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
</Form>
</div>
);
};