chore: merge main, resolve biome formatting conflicts

Merge origin/main into feat/external-2fa-codes. Resolve formatting
conflicts caused by biome rollout; preserve both feature streams:
PR's external 2FA token + signing-session 2FA proof additions plus
main's RateLimit/RecipientExpired/signingReminders/date-auto-insert.

In complete-document-with-token.ts, drop the duplicate early
field-fetching block introduced when main moved that logic later
with date auto-insert support; keep the EXTERNAL_TWO_FACTOR_AUTH
check using derivedRecipientActionAuth.
This commit is contained in:
ephraimduncan
2026-05-12 11:46:11 +00:00
parent 9194884fbe
commit 138d663c25
1959 changed files with 93488 additions and 47038 deletions
@@ -1,9 +1,8 @@
import { Trans } from '@lingui/react/macro';
import { InfoIcon } from 'lucide-react';
import type { TDocumentEmailSettings } from '@documenso/lib/types/document-email';
import { DocumentEmailEvents } from '@documenso/lib/types/document-email';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { Trans } from '@lingui/react/macro';
import { InfoIcon } from 'lucide-react';
import { cn } from '../../lib/utils';
import { Checkbox } from '../../primitives/checkbox';
@@ -16,11 +15,7 @@ type DocumentEmailCheckboxesProps = {
className?: string;
};
export const DocumentEmailCheckboxes = ({
value,
onChange,
className,
}: DocumentEmailCheckboxesProps) => {
export const DocumentEmailCheckboxes = ({ value, onChange, className }: DocumentEmailCheckboxesProps) => {
return (
<div className={cn('space-y-3', className)}>
<div className="flex flex-row items-center">
@@ -28,23 +23,21 @@ export const DocumentEmailCheckboxes = ({
id={DocumentEmailEvents.RecipientSigned}
className="h-5 w-5"
checked={value.recipientSigned}
onCheckedChange={(checked) =>
onChange({ ...value, [DocumentEmailEvents.RecipientSigned]: Boolean(checked) })
}
onCheckedChange={(checked) => onChange({ ...value, [DocumentEmailEvents.RecipientSigned]: Boolean(checked) })}
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
className="ml-2 flex flex-row items-center text-muted-foreground text-sm"
htmlFor={DocumentEmailEvents.RecipientSigned}
>
<Trans>Send recipient signed email</Trans>
<Trans>Email the owner when a recipient signs</Trans>
<Tooltip>
<TooltipTrigger>
<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>
@@ -52,9 +45,7 @@ export const DocumentEmailCheckboxes = ({
</h2>
<p>
<Trans>
This email is sent to the document owner when a recipient has signed the document.
</Trans>
<Trans>This email is sent to the document owner when a recipient has signed the document.</Trans>
</p>
</TooltipContent>
</Tooltip>
@@ -72,17 +63,17 @@ 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-muted-foreground text-sm"
htmlFor={DocumentEmailEvents.RecipientSigningRequest}
>
<Trans>Send recipient signing request email</Trans>
<Trans>Email recipients with a signing request</Trans>
<Tooltip>
<TooltipTrigger>
<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>
@@ -90,9 +81,7 @@ export const DocumentEmailCheckboxes = ({
</h2>
<p>
<Trans>
This email is sent to the recipient requesting them to sign the document.
</Trans>
<Trans>This email is sent to the recipient requesting them to sign the document.</Trans>
</p>
</TooltipContent>
</Tooltip>
@@ -110,17 +99,17 @@ 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-muted-foreground text-sm"
htmlFor={DocumentEmailEvents.RecipientRemoved}
>
<Trans>Send recipient removed email</Trans>
<Trans>Email recipients when they're removed from a pending document</Trans>
<Tooltip>
<TooltipTrigger>
<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>
@@ -128,9 +117,7 @@ export const DocumentEmailCheckboxes = ({
</h2>
<p>
<Trans>
This email is sent to the recipient if they are removed from a pending document.
</Trans>
<Trans>This email is sent to the recipient if they are removed from a pending document.</Trans>
</p>
</TooltipContent>
</Tooltip>
@@ -142,23 +129,21 @@ export const DocumentEmailCheckboxes = ({
id={DocumentEmailEvents.DocumentPending}
className="h-5 w-5"
checked={value.documentPending}
onCheckedChange={(checked) =>
onChange({ ...value, [DocumentEmailEvents.DocumentPending]: Boolean(checked) })
}
onCheckedChange={(checked) => onChange({ ...value, [DocumentEmailEvents.DocumentPending]: Boolean(checked) })}
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
className="ml-2 flex flex-row items-center text-muted-foreground text-sm"
htmlFor={DocumentEmailEvents.DocumentPending}
>
<Trans>Send document pending email</Trans>
<Trans>Email the signer if the document is still pending</Trans>
<Tooltip>
<TooltipTrigger>
<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>
@@ -167,8 +152,8 @@ export const DocumentEmailCheckboxes = ({
<p>
<Trans>
This email will be sent to the recipient who has just signed the document, if
there are still other recipients who have not signed yet.
This email will be sent to the recipient who has just signed the document, if there are still other
recipients who have not signed yet.
</Trans>
</p>
</TooltipContent>
@@ -187,17 +172,17 @@ 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-muted-foreground text-sm"
htmlFor={DocumentEmailEvents.DocumentCompleted}
>
<Trans>Send document completed email</Trans>
<Trans>Email recipients when the document is completed</Trans>
<Tooltip>
<TooltipTrigger>
<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>
@@ -205,9 +190,7 @@ export const DocumentEmailCheckboxes = ({
</h2>
<p>
<Trans>
This will be sent to all recipients once the document has been fully completed.
</Trans>
<Trans>This will be sent to all recipients once the document has been fully completed.</Trans>
</p>
</TooltipContent>
</Tooltip>
@@ -219,23 +202,21 @@ export const DocumentEmailCheckboxes = ({
id={DocumentEmailEvents.DocumentDeleted}
className="h-5 w-5"
checked={value.documentDeleted}
onCheckedChange={(checked) =>
onChange({ ...value, [DocumentEmailEvents.DocumentDeleted]: Boolean(checked) })
}
onCheckedChange={(checked) => onChange({ ...value, [DocumentEmailEvents.DocumentDeleted]: Boolean(checked) })}
/>
<label
className="text-muted-foreground ml-2 flex flex-row items-center text-sm"
className="ml-2 flex flex-row items-center text-muted-foreground text-sm"
htmlFor={DocumentEmailEvents.DocumentDeleted}
>
<Trans>Send document deleted email</Trans>
<Trans>Email recipients when a pending document is deleted</Trans>
<Tooltip>
<TooltipTrigger>
<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>
@@ -243,9 +224,7 @@ export const DocumentEmailCheckboxes = ({
</h2>
<p>
<Trans>
This will be sent to all recipients if a pending document has been deleted.
</Trans>
<Trans>This will be sent to all recipients if a pending document has been deleted.</Trans>
</p>
</TooltipContent>
</Tooltip>
@@ -263,33 +242,105 @@ 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-muted-foreground text-sm"
htmlFor={DocumentEmailEvents.OwnerDocumentCompleted}
>
<Trans>Send document completed email to the owner</Trans>
<Trans>Email the owner when the document is completed</Trans>
<Tooltip>
<TooltipTrigger>
<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>
</strong>
</h2>
<p>
<Trans>This will be sent to the document owner once the document has been fully completed.</Trans>
</p>
</TooltipContent>
</Tooltip>
</label>
</div>
<div className="flex flex-row items-center">
<Checkbox
id={DocumentEmailEvents.OwnerDocumentCreated}
className="h-5 w-5"
checked={value.ownerDocumentCreated}
onCheckedChange={(checked) =>
onChange({ ...value, [DocumentEmailEvents.OwnerDocumentCreated]: Boolean(checked) })
}
/>
<label
className="ml-2 flex flex-row items-center text-muted-foreground text-sm"
htmlFor={DocumentEmailEvents.OwnerDocumentCreated}
>
<Trans>Email the owner when a document is created from a direct template</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>Document created from direct template email</Trans>
</strong>
</h2>
<p>
<Trans>
This will be sent to the document owner once the document has been fully
completed.
This email is sent to the document owner when a recipient creates a document via a direct template
link.
</Trans>
</p>
</TooltipContent>
</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-muted-foreground text-sm"
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>
);
};
@@ -1,14 +1,11 @@
import React from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { InfoIcon } from 'lucide-react';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { InfoIcon } from 'lucide-react';
export interface DocumentGlobalAuthAccessSelectProps {
value?: string[];
@@ -41,15 +38,11 @@ export const DocumentGlobalAuthAccessSelect = ({
// Convert string array to Option array for MultiSelect
const selectedOptions =
(value
?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
(value?.map((val) => authOptions.find((option) => option.value === val)).filter(Boolean) as Option[]) || [];
// Convert default value to Option array
const defaultOptions =
(defaultValue
?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
(defaultValue?.map((val) => authOptions.find((option) => option.value === val)).filter(Boolean) as Option[]) || [];
const handleChange = (options: Option[]) => {
const values = options.map((option) => option.value);
@@ -79,7 +72,7 @@ export const DocumentGlobalAuthAccessTooltip = () => (
<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 access</Trans>
@@ -102,8 +95,7 @@ export const DocumentGlobalAuthAccessTooltip = () => (
</li>
<li>
<Trans>
<strong>No restrictions</strong> - The document can be accessed directly by the URL sent
to the recipient
<strong>No restrictions</strong> - The document can be accessed directly by the URL sent to the recipient
</Trans>
</li>
</ul>
@@ -1,12 +1,11 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { InfoIcon } from 'lucide-react';
import { DOCUMENT_AUTH_TYPES } from '@documenso/lib/constants/document-auth';
import { DocumentActionAuth, DocumentAuth } from '@documenso/lib/types/document-auth';
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { InfoIcon } from 'lucide-react';
export interface DocumentGlobalAuthActionSelectProps {
value?: string[];
@@ -41,15 +40,11 @@ export const DocumentGlobalAuthActionSelect = ({
// Convert string array to Option array for MultiSelect
const selectedOptions =
(value
?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
(value?.map((val) => authOptions.find((option) => option.value === val)).filter(Boolean) as Option[]) || [];
// Convert default value to Option array
const defaultOptions =
(defaultValue
?.map((val) => authOptions.find((option) => option.value === val))
.filter(Boolean) as Option[]) || [];
(defaultValue?.map((val) => authOptions.find((option) => option.value === val)).filter(Boolean) as Option[]) || [];
const handleChange = (options: Option[]) => {
const values = options.map((option) => option.value);
@@ -79,42 +74,39 @@ export const DocumentGlobalAuthActionTooltip = () => (
<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>
<Trans>Global recipient action authentication</Trans>
</h2>
<p>
<Trans>
The authentication methods required for recipients to sign the signature field.
</Trans>
<Trans>The authentication methods required for recipients to sign the signature field.</Trans>
</p>
<p>
<Trans>
These can be overriden by setting the authentication requirements directly on each
recipient in the next step. Multiple methods can be selected.
These can be overriden by setting the authentication requirements directly on each recipient in the next step.
Multiple methods can be selected.
</Trans>
</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
<li>
<Trans>
<strong>Require passkey</strong> - The recipient must have an account and passkey
configured via their settings
<strong>Require passkey</strong> - The recipient must have an account and passkey configured via their
settings
</Trans>
</li>
<li>
<Trans>
<strong>Require 2FA</strong> - The recipient must have an account and 2FA enabled via
their settings
<strong>Require 2FA</strong> - The recipient must have an account and 2FA enabled via their settings
</Trans>
</li>
<li>
<Trans>
<strong>Require password</strong> - The recipient must have an account and password
configured via their settings, the password will be verified during signing
<strong>Require password</strong> - The recipient must have an account and password configured via their
settings, the password will be verified during signing
</Trans>
</li>
@@ -1,13 +1,6 @@
import { useState } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, Field, Recipient } from '@prisma/client';
import { SigningStatus } from '@prisma/client';
import { Clock, EyeOffIcon } from 'lucide-react';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { FieldRootContainer } from '@documenso/ui/components/field/field';
@@ -17,6 +10,12 @@ import { Badge } from '@documenso/ui/primitives/badge';
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { PopoverHover } from '@documenso/ui/primitives/popover';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, Field } from '@prisma/client';
import { SigningStatus } from '@prisma/client';
import { Clock, EyeOffIcon } from 'lucide-react';
import { useState } from 'react';
import { getRecipientColorStyles } from '../../lib/recipient-colors';
import { FieldContent } from '../../primitives/document-flow/field-content';
@@ -34,7 +33,7 @@ const getRecipientDisplayText = (recipient: { name: string; email: string }) =>
};
export type DocumentField = Field & {
recipient: Pick<Recipient, 'name' | 'email' | 'signingStatus'>;
recipient: Pick<TRecipientLite, 'name' | 'email' | 'signingStatus'>;
};
export type DocumentReadOnlyFieldsProps = {
@@ -66,10 +65,7 @@ export type DocumentReadOnlyFieldsProps = {
showRecipientColors?: boolean;
};
export const mapFieldsWithRecipients = (
fields: Field[],
recipients: Recipient[],
): DocumentField[] => {
export const mapFieldsWithRecipients = (fields: Field[], recipients: TRecipientLite[]): DocumentField[] => {
return fields.map((field) => {
const recipient = recipients.find((recipient) => recipient.id === field.recipientId) || {
id: field.recipientId,
@@ -98,10 +94,8 @@ export const DocumentReadOnlyFields = ({
setHiddenFieldIds((prev) => ({ ...prev, [fieldId]: true }));
};
const highestPageNumber = Math.max(...fields.map((field) => field.page));
return (
<ElementVisible target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map(
(field) =>
!hiddenFieldIds[field.secondaryId] && (
@@ -111,21 +105,16 @@ export const DocumentReadOnlyFields = ({
readonly={true}
color={
showRecipientColors
? getRecipientColorStyles(
Math.max(
recipientIds.findIndex((id) => id === field.recipientId),
0,
),
)
? getRecipientColorStyles(recipientIds.findIndex((id) => id === field.recipientId))
: undefined
}
>
{showRecipientTooltip && (
<div className="absolute -right-3 -top-3">
<div className="absolute -top-3 -right-3">
<PopoverHover
trigger={
<Avatar className="h-6 w-6 border-2 border-solid border-gray-200/50 transition-colors hover:border-gray-200">
<AvatarFallback className="bg-neutral-50 text-xs text-gray-400">
<Avatar className="h-6 w-6 border-2 border-gray-200/50 border-solid transition-colors hover:border-gray-200">
<AvatarFallback className="bg-neutral-50 text-gray-400 text-xs">
{extractInitials(field.recipient.name || field.recipient.email)}
</AvatarFallback>
</Avatar>
@@ -137,11 +126,7 @@ export const DocumentReadOnlyFields = ({
{showFieldStatus && (
<Badge
className="mx-auto mb-1 py-0.5"
variant={
field.recipient.signingStatus === SigningStatus.SIGNED
? 'default'
: 'secondary'
}
variant={field.recipient.signingStatus === SigningStatus.SIGNED ? 'default' : 'secondary'}
>
{field.recipient.signingStatus === SigningStatus.SIGNED ? (
<>
@@ -158,17 +143,15 @@ export const DocumentReadOnlyFields = ({
)}
<p className="text-center font-semibold">
<span>
{parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[field.type])} field
</span>
<span>{parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[field.type])} field</span>
</p>
<p className="text-muted-foreground mt-1 text-center text-xs">
<p className="mt-1 text-center text-muted-foreground text-xs">
{getRecipientDisplayText(field.recipient)}
</p>
<button
className="absolute right-0 top-0 my-1 p-2 focus:outline-none focus-visible:ring-0"
className="absolute top-0 right-0 my-1 p-2 focus:outline-none focus-visible:ring-0"
onClick={() => handleHideField(field.secondaryId)}
title="Hide field"
>
@@ -1,5 +1,3 @@
import React from 'react';
import { Trans } from '@lingui/react/macro';
export const DocumentSendEmailMessageHelper = () => {
@@ -11,21 +9,15 @@ export const DocumentSendEmailMessageHelper = () => {
<ul className="mt-2 flex list-inside list-disc flex-col gap-y-2 text-sm">
<li className="text-muted-foreground">
<code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm">
{'{signer.name}'}
</code>{' '}
- <Trans>The signer's name</Trans>
<code className="rounded bg-muted-foreground/20 p-1 text-muted-foreground text-sm">{'{signer.name}'}</code> -{' '}
<Trans>The signer's name</Trans>
</li>
<li className="text-muted-foreground">
<code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm">
{'{signer.email}'}
</code>{' '}
- <Trans>The signer's email</Trans>
<code className="rounded bg-muted-foreground/20 p-1 text-muted-foreground text-sm">{'{signer.email}'}</code> -{' '}
<Trans>The signer's email</Trans>
</li>
<li className="text-muted-foreground">
<code className="text-muted-foreground bg-muted-foreground/20 rounded p-1 text-sm">
{'{document.name}'}
</code>{' '}
<code className="rounded bg-muted-foreground/20 p-1 text-muted-foreground text-sm">{'{document.name}'}</code>{' '}
- <Trans>The document's name</Trans>
</li>
</ul>
@@ -1,17 +1,17 @@
import type { HTMLAttributes } from 'react';
import React, { useState } from 'react';
import { useCopyShareLink } from '@documenso/lib/client-only/hooks/use-copy-share-link';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { generateTwitterIntent } from '@documenso/lib/universal/generate-twitter-intent';
import { trpc } from '@documenso/trpc/react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Copy, Sparkles } from 'lucide-react';
import type React from 'react';
import type { HTMLAttributes } from 'react';
import { useState } from 'react';
import { FaXTwitter } from 'react-icons/fa6';
import { useCopyShareLink } from '@documenso/lib/client-only/hooks/use-copy-share-link';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { generateTwitterIntent } from '@documenso/lib/universal/generate-twitter-intent';
import { trpc } from '@documenso/trpc/react';
import { cn } from '../../lib/utils';
import { Button } from '../../primitives/button';
import {
@@ -30,12 +30,7 @@ export type DocumentShareButtonProps = HTMLAttributes<HTMLButtonElement> & {
trigger?: (_props: { loading: boolean; disabled: boolean }) => React.ReactNode;
};
export const DocumentShareButton = ({
token,
documentId,
className,
trigger,
}: DocumentShareButtonProps) => {
export const DocumentShareButton = ({ token, documentId, className, trigger }: DocumentShareButtonProps) => {
const { _ } = useLingui();
const { toast } = useToast();
@@ -60,7 +55,9 @@ export const DocumentShareButton = ({
mutateAsync: createOrGetShareLink,
data: shareLink,
isPending: isCreatingOrGettingShareLink,
} = trpc.document.share.useMutation();
} = trpc.document.share.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
});
const isLoading = isCreatingOrGettingShareLink || isCopyingShareLink;
@@ -138,15 +135,14 @@ export const DocumentShareButton = ({
<DialogContent position="end">
<DialogHeader>
<DialogTitle>
<DialogTitle className="w-full max-w-full whitespace-pre-line break-words">
<Trans>Share your signing experience!</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
Rest assured, your document is strictly confidential and will never be shared. Only
your signing experience will be highlighted. Share your personalized signing card to
showcase your signature!
Rest assured, your document is strictly confidential and will never be shared. Only your signing
experience will be highlighted. Share your personalized signing card to showcase your signature!
</Trans>
</DialogDescription>
</DialogHeader>
@@ -154,8 +150,7 @@ export const DocumentShareButton = ({
<div className="flex w-full flex-col">
<div className="rounded-md border p-4">
I just {token ? 'signed' : 'sent'} a document in style with{' '}
<span className="font-medium text-blue-400">@documenso</span>
. Check it out!
<span className="font-medium text-blue-400">@documenso</span>. Check it out!
<span className="mt-2 block" />
<span
className={cn('break-all font-medium text-blue-400', {
@@ -165,12 +160,9 @@ export const DocumentShareButton = ({
{NEXT_PUBLIC_WEBAPP_URL()}/share/{shareLink?.slug || '...'}
</span>
<div
className={cn(
'bg-muted/40 mt-4 aspect-[1200/630] overflow-hidden rounded-lg border',
{
'animate-pulse': !shareLink?.slug,
},
)}
className={cn('mt-4 aspect-[1200/630] overflow-hidden rounded-lg border bg-muted/40', {
'animate-pulse': !shareLink?.slug,
})}
>
{shareLink?.slug && (
<img
@@ -1,8 +1,7 @@
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { Trans } from '@lingui/react/macro';
import { InfoIcon } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
export const DocumentSignatureSettingsTooltip = () => {
return (
<Tooltip>
@@ -10,7 +9,7 @@ export const DocumentSignatureSettingsTooltip = () => {
<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>Signature types</Trans>
@@ -18,9 +17,7 @@ export const DocumentSignatureSettingsTooltip = () => {
</h2>
<p>
<Trans>
The types of signatures that recipients are allowed to use when signing the document.
</Trans>
<Trans>The types of signatures that recipients are allowed to use when signing the document.</Trans>
</p>
<ul className="ml-3.5 list-outside list-disc space-y-0.5 py-2">
@@ -1,22 +1,14 @@
import React, { forwardRef } from 'react';
import { DOCUMENT_VISIBILITY } from '@documenso/lib/constants/document-visibility';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { t } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import type { SelectProps } from '@radix-ui/react-select';
import { InfoIcon } from 'lucide-react';
import { DOCUMENT_VISIBILITY } from '@documenso/lib/constants/document-visibility';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { forwardRef } from 'react';
export type DocumentVisibilitySelectType = SelectProps & {
currentTeamMemberRole?: string;
@@ -26,10 +18,7 @@ export type DocumentVisibilitySelectType = SelectProps & {
};
export const DocumentVisibilitySelect = forwardRef<HTMLButtonElement, DocumentVisibilitySelectType>(
(
{ currentTeamMemberRole, isTeamSettings = false, disabled, canUpdateVisibility, ...props },
ref,
) => {
({ currentTeamMemberRole, isTeamSettings = false, disabled, canUpdateVisibility, ...props }, ref) => {
const { _ } = useLingui();
const isAdmin = currentTeamMemberRole === TeamMemberRole.ADMIN;
@@ -43,13 +32,8 @@ export const DocumentVisibilitySelect = forwardRef<HTMLButtonElement, DocumentVi
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value={DocumentVisibility.EVERYONE}>
{_(DOCUMENT_VISIBILITY.EVERYONE.value)}
</SelectItem>
<SelectItem
value={DocumentVisibility.MANAGER_AND_ABOVE}
disabled={!isAdmin && !isManager}
>
<SelectItem value={DocumentVisibility.EVERYONE}>{_(DOCUMENT_VISIBILITY.EVERYONE.value)}</SelectItem>
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE} disabled={!isAdmin && !isManager}>
{_(DOCUMENT_VISIBILITY.MANAGER_AND_ABOVE.value)}
</SelectItem>
<SelectItem value={DocumentVisibility.ADMIN} disabled={!isAdmin}>
@@ -70,7 +54,7 @@ export const DocumentVisibilityTooltip = () => {
<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 visibility</Trans>
@@ -89,8 +73,7 @@ export const DocumentVisibilityTooltip = () => {
</li>
<li>
<Trans>
<strong>Managers and above</strong> - Only managers and above can access and view the
document
<strong>Managers and above</strong> - Only managers and above can access and view the document
</Trans>
</li>
<li>
@@ -1,12 +1,10 @@
import { useCallback, useEffect, useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { SigningStatus } from '@prisma/client';
import type { Field, Recipient } from '@prisma/client';
import { ClockIcon, EyeOffIcon, LockIcon } from 'lucide-react';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { Trans, useLingui } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client';
import { SigningStatus } from '@prisma/client';
import { ClockIcon, EyeOffIcon, LockIcon } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { isTemplateRecipientEmailPlaceholder } from '../../../lib/constants/template';
import { extractInitials } from '../../../lib/utils/recipient-formatter';
@@ -20,15 +18,7 @@ import { PopoverHover } from '../../primitives/popover';
interface EnvelopeRecipientFieldTooltipProps {
field: Pick<
Field,
| 'id'
| 'inserted'
| 'positionX'
| 'positionY'
| 'width'
| 'height'
| 'page'
| 'type'
| 'fieldMeta'
'id' | 'inserted' | 'positionX' | 'positionY' | 'width' | 'height' | 'page' | 'type' | 'fieldMeta'
> & {
recipient: Pick<Recipient, 'name' | 'email' | 'signingStatus'>;
};
@@ -70,9 +60,7 @@ export function EnvelopeRecipientFieldTooltip({
});
const calculateCoords = useCallback(() => {
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
);
const $page = document.querySelector<HTMLElement>(`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`);
if (!$page) {
return;
@@ -111,9 +99,7 @@ export function EnvelopeRecipientFieldTooltip({
}, [calculateCoords]);
useEffect(() => {
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
);
const $page = document.querySelector<HTMLElement>(`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`);
if (!$page) {
return;
@@ -145,8 +131,8 @@ export function EnvelopeRecipientFieldTooltip({
>
<PopoverHover
trigger={
<Avatar className="absolute -left-3 -top-3 z-50 h-6 w-6 border-2 border-solid border-gray-200/50 transition-colors hover:border-gray-200">
<AvatarFallback className="bg-neutral-50 text-xs text-gray-400">
<Avatar className="absolute -top-3 -left-3 z-50 h-6 w-6 border-2 border-gray-200/50 border-solid transition-colors hover:border-gray-200">
<AvatarFallback className="bg-neutral-50 text-gray-400 text-xs">
{extractInitials(field.recipient.name || field.recipient.email)}
</AvatarFallback>
</Avatar>
@@ -186,15 +172,15 @@ export function EnvelopeRecipientFieldTooltip({
)}
<p className="text-center font-semibold">
<span>{t(FRIENDLY_FIELD_TYPE[field.type])} field</span>
<span>
<Trans>{t(FRIENDLY_FIELD_TYPE[field.type])} field</Trans>
</span>
</p>
<p className="text-muted-foreground mt-1 text-center text-xs">
{getRecipientDisplayText(field.recipient)}
</p>
<p className="mt-1 text-center text-muted-foreground text-xs">{getRecipientDisplayText(field.recipient)}</p>
<button
className="absolute right-0 top-0 my-1 p-2 focus:outline-none focus-visible:ring-0"
className="absolute top-0 right-0 my-1 p-2 focus:outline-none focus-visible:ring-0"
onClick={() => setHideField(true)}
title="Hide field"
>
@@ -0,0 +1,137 @@
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';
import { Plural, Trans } from '@lingui/react/macro';
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 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" data-testid="envelope-expiration-mode">
<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"
data-testid="envelope-expiration-amount"
value={amount}
onChange={(e) => onAmountChange(Number(e.target.value))}
disabled={disabled}
/>
<Select value={unit} onValueChange={onUnitChange} disabled={disabled}>
<SelectTrigger className="flex-1 bg-background" data-testid="envelope-expiration-unit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="day">
<Plural value={amount} one="Day" other="Days" />
</SelectItem>
<SelectItem value="week">
<Plural value={amount} one="Week" other="Weeks" />
</SelectItem>
<SelectItem value="month">
<Plural value={amount} one="Month" other="Months" />
</SelectItem>
<SelectItem value="year">
<Plural value={amount} one="Year" other="Years" />
</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
);
};
@@ -0,0 +1,253 @@
import type {
TEnvelopeReminderDurationPeriod,
TEnvelopeReminderPeriod,
TEnvelopeReminderSettings,
} from '@documenso/lib/constants/envelope-reminder';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Plural, Trans } from '@lingui/react/macro';
type ReminderMode = 'enabled' | 'disabled' | 'inherit';
const getMode = (value: TEnvelopeReminderSettings | null | undefined): ReminderMode => {
if (value === null || value === undefined) {
return 'inherit';
}
if ('disabled' in value.sendAfter) {
return 'disabled';
}
return 'enabled';
};
const getPeriodAmount = (period: TEnvelopeReminderPeriod | undefined): number => {
if (period && 'amount' in period) {
return period.amount;
}
return 1;
};
const getPeriodUnit = (period: TEnvelopeReminderPeriod | undefined): TEnvelopeReminderDurationPeriod['unit'] => {
if (period && 'unit' in period) {
return period.unit;
}
return 'day';
};
export type ReminderSettingsPickerProps = {
value: TEnvelopeReminderSettings | null | undefined;
onChange: (value: TEnvelopeReminderSettings | null) => void;
disabled?: boolean;
inheritLabel?: string;
};
export const ReminderSettingsPicker = ({
value,
onChange,
disabled = false,
inheritLabel,
}: ReminderSettingsPickerProps) => {
const mode = getMode(value);
const sendAfterAmount = getPeriodAmount(value?.sendAfter);
const sendAfterUnit = getPeriodUnit(value?.sendAfter);
const repeatEveryAmount = getPeriodAmount(value?.repeatEvery);
const repeatEveryUnit = getPeriodUnit(value?.repeatEvery);
const onModeChange = (newMode: string) => {
if (newMode === 'inherit') {
onChange(null);
return;
}
if (newMode === 'disabled') {
onChange({
sendAfter: { disabled: true },
repeatEvery: { disabled: true },
});
return;
}
onChange({
sendAfter: { unit: sendAfterUnit, amount: sendAfterAmount },
repeatEvery: { unit: repeatEveryUnit, amount: repeatEveryAmount },
});
};
const updateSendAfter = (updates: Partial<{ amount: number; unit: TEnvelopeReminderDurationPeriod['unit'] }>) => {
const newAmount = Math.max(1, Math.floor(updates.amount ?? sendAfterAmount));
const newUnit = updates.unit ?? sendAfterUnit;
onChange({
sendAfter: { unit: newUnit, amount: newAmount },
repeatEvery: value?.repeatEvery ?? { unit: repeatEveryUnit, amount: repeatEveryAmount },
});
};
const updateRepeatEvery = (updates: Partial<{ amount: number; unit: TEnvelopeReminderDurationPeriod['unit'] }>) => {
const newAmount = Math.max(1, Math.floor(updates.amount ?? repeatEveryAmount));
const newUnit = updates.unit ?? repeatEveryUnit;
onChange({
sendAfter: value?.sendAfter ?? { unit: sendAfterUnit, amount: sendAfterAmount },
repeatEvery: { unit: newUnit, amount: newAmount },
});
};
const onRepeatModeChange = (newMode: string) => {
if (newMode === 'disabled') {
onChange({
sendAfter: value?.sendAfter ?? { unit: sendAfterUnit, amount: sendAfterAmount },
repeatEvery: { disabled: true },
});
return;
}
onChange({
sendAfter: value?.sendAfter ?? { unit: sendAfterUnit, amount: sendAfterAmount },
repeatEvery: { unit: repeatEveryUnit, amount: repeatEveryAmount },
});
};
const repeatMode = value?.repeatEvery && 'disabled' in value.repeatEvery ? 'disabled' : 'enabled';
return (
<div className="flex flex-col gap-4" data-testid="reminder-settings-picker">
<Select value={mode} onValueChange={onModeChange} disabled={disabled}>
<SelectTrigger className="bg-background" data-testid="reminder-mode-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="enabled">
<Trans>Enabled</Trans>
</SelectItem>
<SelectItem value="disabled">
<Trans>No reminders</Trans>
</SelectItem>
{inheritLabel !== undefined && <SelectItem value="inherit">{inheritLabel}</SelectItem>}
</SelectContent>
</Select>
{mode === 'enabled' && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label className="text-muted-foreground text-sm">
<Trans>Send first reminder after</Trans>
</Label>
<div className="flex flex-row gap-2">
<Input
type="number"
min={1}
className="w-20 bg-background"
value={sendAfterAmount}
onChange={(e) => updateSendAfter({ amount: Number(e.target.value) })}
disabled={disabled}
data-testid="reminder-send-after-amount"
/>
<UnitSelect
value={sendAfterUnit}
amount={sendAfterAmount}
onChange={(unit) =>
updateSendAfter({
unit: unit as TEnvelopeReminderDurationPeriod['unit'],
})
}
disabled={disabled}
testId="reminder-send-after-unit"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label className="text-muted-foreground text-sm">
<Trans>Then repeat every</Trans>
</Label>
<Select value={repeatMode} onValueChange={onRepeatModeChange} disabled={disabled}>
<SelectTrigger className="bg-background" data-testid="reminder-repeat-mode-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="enabled">
<Trans>Custom interval</Trans>
</SelectItem>
<SelectItem value="disabled">
<Trans>Don't repeat</Trans>
</SelectItem>
</SelectContent>
</Select>
{repeatMode === 'enabled' && (
<div className="flex flex-row gap-2">
<Input
type="number"
min={1}
className="w-20 bg-background"
value={repeatEveryAmount}
onChange={(e) => updateRepeatEvery({ amount: Number(e.target.value) })}
disabled={disabled}
data-testid="reminder-repeat-amount"
/>
<UnitSelect
value={repeatEveryUnit}
amount={repeatEveryAmount}
onChange={(unit) =>
updateRepeatEvery({
unit: unit as TEnvelopeReminderDurationPeriod['unit'],
})
}
disabled={disabled}
testId="reminder-repeat-unit"
/>
</div>
)}
</div>
</div>
)}
</div>
);
};
const UnitSelect = ({
value,
amount,
onChange,
disabled,
testId,
}: {
value: string;
amount: number;
onChange: (value: string) => void;
disabled: boolean;
testId: string;
}) => (
<Select value={value} onValueChange={onChange} disabled={disabled}>
<SelectTrigger className="flex-1 bg-background" data-testid={testId}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="day">
<Plural value={amount} one="Day" other="Days" />
</SelectItem>
<SelectItem value="week">
<Plural value={amount} one="Week" other="Weeks" />
</SelectItem>
<SelectItem value="month">
<Plural value={amount} one="Month" other="Months" />
</SelectItem>
</SelectContent>
</Select>
);