feat: per-recipient envelope expiration (#2519)

This commit is contained in:
Lucas Smith
2026-02-20 11:36:20 +11:00
committed by GitHub
parent f3ec8ddc57
commit 006b1d0a57
70 changed files with 2705 additions and 93 deletions
@@ -34,7 +34,7 @@ export const DocumentEmailCheckboxes = ({
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
htmlFor={DocumentEmailEvents.RecipientSigned}
>
<Trans>Email the owner when a recipient signs</Trans>
@@ -44,7 +44,7 @@ export const DocumentEmailCheckboxes = ({
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Recipient signed email</Trans>
@@ -72,7 +72,7 @@ export const DocumentEmailCheckboxes = ({
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
htmlFor={DocumentEmailEvents.RecipientSigningRequest}
>
<Trans>Email recipients with a signing request</Trans>
@@ -82,7 +82,7 @@ export const DocumentEmailCheckboxes = ({
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Recipient signing request email</Trans>
@@ -110,7 +110,7 @@ export const DocumentEmailCheckboxes = ({
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
htmlFor={DocumentEmailEvents.RecipientRemoved}
>
<Trans>Email recipients when they're removed from a pending document</Trans>
@@ -120,7 +120,7 @@ export const DocumentEmailCheckboxes = ({
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Recipient removed email</Trans>
@@ -148,7 +148,7 @@ export const DocumentEmailCheckboxes = ({
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
htmlFor={DocumentEmailEvents.DocumentPending}
>
<Trans>Email the signer if the document is still pending</Trans>
@@ -158,7 +158,7 @@ export const DocumentEmailCheckboxes = ({
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Document pending email</Trans>
@@ -187,7 +187,7 @@ export const DocumentEmailCheckboxes = ({
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
htmlFor={DocumentEmailEvents.DocumentCompleted}
>
<Trans>Email recipients when the document is completed</Trans>
@@ -197,7 +197,7 @@ export const DocumentEmailCheckboxes = ({
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Document completed email</Trans>
@@ -225,7 +225,7 @@ export const DocumentEmailCheckboxes = ({
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
htmlFor={DocumentEmailEvents.DocumentDeleted}
>
<Trans>Email recipients when a pending document is deleted</Trans>
@@ -235,7 +235,7 @@ export const DocumentEmailCheckboxes = ({
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Document deleted email</Trans>
@@ -263,7 +263,7 @@ export const DocumentEmailCheckboxes = ({
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
htmlFor={DocumentEmailEvents.OwnerDocumentCompleted}
>
<Trans>Email the owner when the document is completed</Trans>
@@ -273,7 +273,7 @@ export const DocumentEmailCheckboxes = ({
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 p-4">
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Document completed email</Trans>
@@ -290,6 +290,45 @@ export const DocumentEmailCheckboxes = ({
</Tooltip>
</label>
</div>
<div className="flex flex-row items-center">
<Checkbox
id={DocumentEmailEvents.OwnerRecipientExpired}
className="h-5 w-5"
checked={value.ownerRecipientExpired}
onCheckedChange={(checked) =>
onChange({ ...value, [DocumentEmailEvents.OwnerRecipientExpired]: Boolean(checked) })
}
/>
<label
className="ml-2 flex flex-row items-center text-sm text-muted-foreground"
htmlFor={DocumentEmailEvents.OwnerRecipientExpired}
>
<Trans>Send recipient expired email to the owner</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="max-w-md space-y-2 p-4 text-foreground">
<h2>
<strong>
<Trans>Recipient expired email</Trans>
</strong>
</h2>
<p>
<Trans>
This will be sent to the document owner when a recipient's signing window has
expired.
</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
</div>
</div>
);
};
@@ -0,0 +1,153 @@
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type {
TEnvelopeExpirationDurationPeriod,
TEnvelopeExpirationPeriod,
} from '@documenso/lib/constants/envelope-expiration';
import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
const EXPIRATION_UNITS: Array<{
value: TEnvelopeExpirationDurationPeriod['unit'];
label: MessageDescriptor;
}> = [
{ value: 'day', label: msg`Days` },
{ value: 'week', label: msg`Weeks` },
{ value: 'month', label: msg`Months` },
{ value: 'year', label: msg`Years` },
];
type ExpirationMode = 'duration' | 'disabled' | 'inherit';
const getMode = (value: TEnvelopeExpirationPeriod | null | undefined): ExpirationMode => {
if (!value) {
return 'inherit';
}
if ('disabled' in value) {
return 'disabled';
}
return 'duration';
};
const getAmount = (value: TEnvelopeExpirationPeriod | null | undefined): number => {
if (value && 'amount' in value) {
return value.amount;
}
return 1;
};
const getUnit = (
value: TEnvelopeExpirationPeriod | null | undefined,
): TEnvelopeExpirationDurationPeriod['unit'] => {
if (value && 'unit' in value) {
return value.unit;
}
return 'month';
};
export type ExpirationPeriodPickerProps = {
value: TEnvelopeExpirationPeriod | null | undefined;
onChange: (value: TEnvelopeExpirationPeriod | null) => void;
disabled?: boolean;
inheritLabel?: string;
};
export const ExpirationPeriodPicker = ({
value,
onChange,
disabled = false,
inheritLabel,
}: ExpirationPeriodPickerProps) => {
const { _ } = useLingui();
const mode = getMode(value);
const amount = getAmount(value);
const unit = getUnit(value);
const onModeChange = (newMode: string) => {
if (newMode === 'inherit') {
onChange(null);
return;
}
if (newMode === 'disabled') {
onChange({ disabled: true });
return;
}
onChange({ unit, amount });
};
const onAmountChange = (newAmount: number) => {
const clamped = Math.max(1, Math.floor(newAmount));
onChange({ unit, amount: clamped });
};
const onUnitChange = (newUnit: string) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
onChange({ unit: newUnit as TEnvelopeExpirationDurationPeriod['unit'], amount });
};
return (
<div className="flex flex-col gap-2">
<Select value={mode} onValueChange={onModeChange} disabled={disabled}>
<SelectTrigger className="bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="duration">
<Trans>Custom duration</Trans>
</SelectItem>
<SelectItem value="disabled">
<Trans>Never expires</Trans>
</SelectItem>
{inheritLabel !== undefined && <SelectItem value="inherit">{inheritLabel}</SelectItem>}
</SelectContent>
</Select>
{mode === 'duration' && (
<div className="flex flex-row gap-2">
<Input
type="number"
min={1}
className="w-20 bg-background"
value={amount}
onChange={(e) => onAmountChange(Number(e.target.value))}
disabled={disabled}
/>
<Select value={unit} onValueChange={onUnitChange} disabled={disabled}>
<SelectTrigger className="flex-1 bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
{EXPIRATION_UNITS.map((u) => (
<SelectItem key={u.value} value={u.value}>
{_(u.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
);
};