mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
10 Commits
1650c55b19
...
feat/expir
| Author | SHA1 | Date | |
|---|---|---|---|
| bdd4e5ae2e | |||
| 0942a9234d | |||
| 53ee4342c7 | |||
| eb5be386ce | |||
| 40d7527036 | |||
| e9f4d0065e | |||
| b1102c8ba4 | |||
| d6bc4bd0ba | |||
| 262d9efdd5 | |||
| e24d00e23e |
@ -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}
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 },
|
||||
() => (
|
||||
|
||||
@ -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}`}>
|
||||
|
||||
@ -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`);
|
||||
}
|
||||
|
||||
141
apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
Normal file
141
apps/remix/app/routes/_recipient+/sign.$token+/expired.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { FieldType } from '@prisma/client';
|
||||
import { Clock8 } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
|
||||
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
import { isRecipientExpired } from '@documenso/lib/utils/expiry';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
|
||||
import { truncateTitle } from '~/utils/truncate-title';
|
||||
|
||||
import type { Route } from './+types/expired';
|
||||
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
const { user } = await getOptionalSession(request);
|
||||
|
||||
const { token } = params;
|
||||
|
||||
if (!token) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const document = await getDocumentAndSenderByToken({
|
||||
token,
|
||||
requireAccessAuth: false,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!document) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const truncatedTitle = truncateTitle(document.title);
|
||||
|
||||
const [fields, recipient] = await Promise.all([
|
||||
getFieldsForToken({ token }),
|
||||
getRecipientByToken({ token }).catch(() => null),
|
||||
]);
|
||||
|
||||
if (!recipient) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
if (!isRecipientExpired(recipient)) {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const isDocumentAccessValid = await isRecipientAuthorized({
|
||||
type: 'ACCESS',
|
||||
documentAuthOptions: document.authOptions,
|
||||
recipient,
|
||||
userId: user?.id,
|
||||
});
|
||||
|
||||
const recipientReference =
|
||||
recipient.name ||
|
||||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
|
||||
recipient.email;
|
||||
|
||||
if (isDocumentAccessValid) {
|
||||
return {
|
||||
isDocumentAccessValid: true,
|
||||
recipientReference,
|
||||
truncatedTitle,
|
||||
recipient,
|
||||
};
|
||||
}
|
||||
|
||||
// Don't leak data if access is denied.
|
||||
return {
|
||||
isDocumentAccessValid: false,
|
||||
recipientReference,
|
||||
};
|
||||
}
|
||||
|
||||
export default function SigningExpiredPage({ loaderData }: Route.ComponentProps) {
|
||||
const { sessionData } = useOptionalSession();
|
||||
const user = sessionData?.user;
|
||||
|
||||
const { isDocumentAccessValid, recipientReference, truncatedTitle, recipient } = loaderData;
|
||||
|
||||
if (!isDocumentAccessValid) {
|
||||
return <DocumentSigningAuthPageView email={recipientReference} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
|
||||
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
|
||||
{truncatedTitle}
|
||||
</Badge>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Clock8 className="h-10 w-10 text-orange-500" />
|
||||
|
||||
<h2 className="max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
|
||||
<Trans>Signing Link Expired</Trans>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center text-center text-sm text-orange-600">
|
||||
<Trans>This signing link is no longer valid</Trans>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mt-6 max-w-[60ch] text-center text-sm">
|
||||
<Trans>
|
||||
The signing link has expired and can no longer be used to sign the document. Please
|
||||
contact the document sender if you need a new signing link.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
{recipient?.expired && (
|
||||
<p className="text-muted-foreground mt-2 max-w-[60ch] text-center text-sm">
|
||||
<Trans>
|
||||
Expired on:{' '}
|
||||
{new Date(recipient.expired).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{user && (
|
||||
<Button className="mt-6" asChild>
|
||||
<Link to={`/`}>Return Home</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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.
|
||||
|
||||
292
packages/lib/server-only/document/create-document-v2.ts
Normal file
292
packages/lib/server-only/document/create-document-v2.ts
Normal 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;
|
||||
});
|
||||
};
|
||||
177
packages/lib/server-only/document/create-document.ts
Normal file
177
packages/lib/server-only/document/create-document.ts
Normal 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;
|
||||
});
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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`);
|
||||
}
|
||||
|
||||
36
packages/lib/server-only/recipient/expire-recipient.ts
Normal file
36
packages/lib/server-only/recipient/expire-recipient.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { SigningStatus } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type ExpireRecipientOptions = {
|
||||
recipientId: number;
|
||||
};
|
||||
|
||||
export const expireRecipient = async ({ recipientId }: ExpireRecipientOptions) => {
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
id: recipientId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
signingStatus: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (recipient.signingStatus === SigningStatus.EXPIRED) {
|
||||
return recipient;
|
||||
}
|
||||
|
||||
return await prisma.recipient.update({
|
||||
where: {
|
||||
id: recipientId,
|
||||
},
|
||||
data: {
|
||||
signingStatus: SigningStatus.EXPIRED,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
}),
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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,
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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'>;
|
||||
};
|
||||
|
||||
|
||||
72
packages/lib/utils/expiry.ts
Normal file
72
packages/lib/utils/expiry.ts
Normal 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');
|
||||
};
|
||||
@ -0,0 +1,6 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "SigningStatus" ADD VALUE 'EXPIRED';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "DocumentMeta" ADD COLUMN "expiryAmount" INTEGER,
|
||||
ADD COLUMN "expiryUnit" TEXT;
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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,
|
||||
|
||||
131
packages/ui/primitives/date-time-picker.tsx
Normal file
131
packages/ui/primitives/date-time-picker.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './button';
|
||||
import { Calendar } from './calendar';
|
||||
import { Input } from './input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||
|
||||
export interface DateTimePickerProps {
|
||||
value?: Date;
|
||||
onChange?: (date: Date | undefined) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
minDate?: Date;
|
||||
}
|
||||
|
||||
export const DateTimePicker = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
className,
|
||||
minDate = new Date(),
|
||||
}: DateTimePickerProps) => {
|
||||
const { _ } = useLingui();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const handleDateSelect = (selectedDate: Date | undefined) => {
|
||||
if (!selectedDate) {
|
||||
onChange?.(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (value) {
|
||||
const existingTime = DateTime.fromJSDate(value);
|
||||
const newDateTime = DateTime.fromJSDate(selectedDate).set({
|
||||
hour: existingTime.hour,
|
||||
minute: existingTime.minute,
|
||||
});
|
||||
onChange?.(newDateTime.toJSDate());
|
||||
} else {
|
||||
const now = DateTime.now();
|
||||
const newDateTime = DateTime.fromJSDate(selectedDate).set({
|
||||
hour: now.hour,
|
||||
minute: now.minute,
|
||||
});
|
||||
onChange?.(newDateTime.toJSDate());
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleTimeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const timeValue = event.target.value;
|
||||
if (!timeValue || !value) return;
|
||||
|
||||
const [hours, minutes] = timeValue.split(':').map(Number);
|
||||
const newDateTime = DateTime.fromJSDate(value).set({
|
||||
hour: hours,
|
||||
minute: minutes,
|
||||
});
|
||||
|
||||
onChange?.(newDateTime.toJSDate());
|
||||
};
|
||||
|
||||
const formatDateTime = (date: Date) => {
|
||||
return DateTime.fromJSDate(date).toFormat('MMM dd, yyyy');
|
||||
};
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return DateTime.fromJSDate(date).toFormat('HH:mm');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex gap-2', className)}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'w-[200px] justify-start text-left font-normal',
|
||||
!value && 'text-muted-foreground',
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{value ? formatDateTime(value) : <span>{placeholder || _(msg`Pick a date`)}</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={value}
|
||||
onSelect={handleDateSelect}
|
||||
disabled={
|
||||
disabled
|
||||
? true
|
||||
: (date) => {
|
||||
return date < minDate;
|
||||
}
|
||||
}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{value && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
<Trans>at</Trans>
|
||||
</span>
|
||||
<Input
|
||||
type="time"
|
||||
value={formatTime(value)}
|
||||
onChange={handleTimeChange}
|
||||
disabled={disabled}
|
||||
className="w-[120px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -11,7 +11,7 @@ import {
|
||||
TeamMemberRole,
|
||||
} 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>
|
||||
|
||||
@ -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(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -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>;
|
||||
|
||||
79
packages/ui/primitives/duration-selector.tsx
Normal file
79
packages/ui/primitives/duration-selector.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
132
packages/ui/primitives/expiry-settings-picker.tsx
Normal file
132
packages/ui/primitives/expiry-settings-picker.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user