feat: expiry links

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

View File

@ -198,6 +198,8 @@ export const DocumentEditForm = ({
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
expiryAmount: data.meta.expiryAmount,
expiryUnit: data.meta.expiryUnit,
},
});

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@ import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
import { CheckCircle, Clock, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
@ -36,6 +36,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
const isPending = row.status === DocumentStatus.PENDING;
const isComplete = isDocumentCompleted(row.status);
const isSigned = recipient?.signingStatus === SigningStatus.SIGNED;
const isExpired = recipient?.signingStatus === SigningStatus.EXPIRED;
const role = recipient?.role;
const isCurrentTeamDocument = team && row.team?.url === team.url;
@ -87,8 +88,15 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
isPending,
isComplete,
isSigned,
isExpired,
isCurrentTeamDocument,
})
.with({ isRecipient: true, isExpired: true }, () => (
<Button className="w-32 bg-orange-100 text-orange-600 hover:bg-orange-200" disabled={true}>
<Clock className="-ml-1 mr-2 h-4 w-4" />
<Trans>Expired</Trans>
</Button>
))
.with(
isOwner ? { isDraft: true, isOwner: true } : { isDraft: true, isCurrentTeamDocument: true },
() => (

View File

@ -5,7 +5,7 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus as DocumentStatusEnum } from '@prisma/client';
import { RecipientRole, SigningStatus } from '@prisma/client';
import { CheckCircleIcon, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
import { CheckCircleIcon, Clock, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { Link, useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
@ -194,6 +194,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) {
@ -231,7 +232,14 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
isPending,
isComplete,
isSigned,
isExpired,
})
.with({ isExpired: true }, () => (
<Button className="w-32 bg-orange-100 text-orange-600 hover:bg-orange-200" disabled={true}>
<Clock className="-ml-1 mr-2 h-4 w-4" />
<Trans>Expired</Trans>
</Button>
))
.with({ isPending: true, isSigned: false }, () => (
<Button className="w-32" asChild>
<Link to={`/sign/${recipient?.token}`}>

View File

@ -12,6 +12,7 @@ import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-re
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { 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';
@ -20,6 +21,7 @@ import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { isRecipientExpired } from '@documenso/lib/utils/expiry';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
@ -127,6 +129,11 @@ export async function loader({ params, request }: Route.LoaderArgs) {
const { documentMeta } = document;
if (isRecipientExpired(recipient)) {
await expireRecipient({ recipientId: recipient.id });
throw redirect(`/sign/${token}/expired`);
}
if (recipient.signingStatus === SigningStatus.REJECTED) {
throw redirect(`/sign/${token}/rejected`);
}

View File

@ -0,0 +1,141 @@
import { Trans } from '@lingui/react/macro';
import { FieldType } from '@prisma/client';
import { Clock8 } from 'lucide-react';
import { Link } from 'react-router';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { isRecipientExpired } from '@documenso/lib/utils/expiry';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
import { truncateTitle } from '~/utils/truncate-title';
import type { Route } from './+types/expired';
export async function loader({ params, request }: Route.LoaderArgs) {
const { user } = await getOptionalSession(request);
const { token } = params;
if (!token) {
throw new Response('Not Found', { status: 404 });
}
const document = await getDocumentAndSenderByToken({
token,
requireAccessAuth: false,
}).catch(() => null);
if (!document) {
throw new Response('Not Found', { status: 404 });
}
const truncatedTitle = truncateTitle(document.title);
const [fields, recipient] = await Promise.all([
getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null),
]);
if (!recipient) {
throw new Response('Not Found', { status: 404 });
}
if (!isRecipientExpired(recipient)) {
throw new Response('Not Found', { status: 404 });
}
const isDocumentAccessValid = await isRecipientAuthorized({
type: 'ACCESS',
documentAuthOptions: document.authOptions,
recipient,
userId: user?.id,
});
const recipientReference =
recipient.name ||
fields.find((field) => field.type === FieldType.NAME)?.customText ||
recipient.email;
if (isDocumentAccessValid) {
return {
isDocumentAccessValid: true,
recipientReference,
truncatedTitle,
recipient,
};
}
// Don't leak data if access is denied.
return {
isDocumentAccessValid: false,
recipientReference,
};
}
export default function SigningExpiredPage({ loaderData }: Route.ComponentProps) {
const { sessionData } = useOptionalSession();
const user = sessionData?.user;
const { isDocumentAccessValid, recipientReference, truncatedTitle, recipient } = loaderData;
if (!isDocumentAccessValid) {
return <DocumentSigningAuthPageView email={recipientReference} />;
}
return (
<div className="flex flex-col items-center pt-24 lg:pt-36 xl:pt-44">
<Badge variant="neutral" size="default" className="mb-6 rounded-xl border bg-transparent">
{truncatedTitle}
</Badge>
<div className="flex flex-col items-center">
<div className="flex items-center gap-x-4">
<Clock8 className="h-10 w-10 text-orange-500" />
<h2 className="max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
<Trans>Signing Link Expired</Trans>
</h2>
</div>
<div className="mt-4 flex items-center text-center text-sm text-orange-600">
<Trans>This signing link is no longer valid</Trans>
</div>
<p className="text-muted-foreground mt-6 max-w-[60ch] text-center text-sm">
<Trans>
The signing link has expired and can no longer be used to sign the document. Please
contact the document sender if you need a new signing link.
</Trans>
</p>
{recipient?.expired && (
<p className="text-muted-foreground mt-2 max-w-[60ch] text-center text-sm">
<Trans>
Expired on:{' '}
{new Date(recipient.expired).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</Trans>
</p>
)}
{user && (
<Button className="mt-6" asChild>
<Link to={`/`}>Return Home</Link>
</Button>
)}
</div>
</div>
);
}