Compare commits

..
109 changed files with 119 additions and 1467 deletions
@@ -11,14 +11,9 @@ Documenso enforces rate limits on all API endpoints to ensure service stability.
## HTTP Rate Limits
**Limit:** 1000 requests per minute per IP address
**Limit:** 100 requests per minute per IP address
**Response:** 429 Too Many Requests
<Callout type="info">
This is the global per-IP ceiling. Your organisation may have its own rate limits configured below
this value, in which case you can be rate-limited before reaching the global limit.
</Callout>
### Rate Limit Response
```json
@@ -463,7 +463,6 @@ const response = await fetch(`${BASE_URL}/template/use`, {
typedSignatureEnabled: true,
uploadSignatureEnabled: false,
drawSignatureEnabled: true,
qrSignatureEnabled: true,
},
distributeDocument: true,
}),
@@ -484,7 +483,6 @@ const response = await fetch(`${BASE_URL}/template/use`, {
| `typedSignatureEnabled` | boolean | Allow typed signatures |
| `uploadSignatureEnabled` | boolean | Allow uploaded signature images |
| `drawSignatureEnabled` | boolean | Allow drawn signatures |
| `qrSignatureEnabled` | boolean | Allow QR code handoff to a mobile device |
---
@@ -390,7 +390,6 @@ const response = await fetch(`${BASE_URL}/template/update`, {
typedSignatureEnabled: true, // Allow typed signatures
drawSignatureEnabled: true, // Allow drawn signatures
uploadSignatureEnabled: false, // Disable uploaded signatures
qrSignatureEnabled: true, // Allow QR code handoff to a mobile device
},
}),
});
@@ -472,7 +472,7 @@ Send the same document to multiple recipients in parallel. Useful for policy ack
<code>distributeDocument: true</code>
</Step>
<Step>
Process in batches with a short delay to respect rate limits (e.g. 1000 requests/minute)
Process in batches with a short delay to respect rate limits (e.g. 100 requests/minute)
</Step>
</Steps>
@@ -638,8 +638,8 @@ done
</Tabs>
<Callout type="info">
The API allows 1000 requests per minute (your organisation may have its own lower limit). For large
batches, implement rate limiting with delays between requests to avoid hitting limits.
The API allows 100 requests per minute. For large batches, implement rate limiting with delays
between requests to avoid hitting limits.
</Callout>
---
@@ -483,7 +483,7 @@ The API returns standard HTTP status codes and JSON error responses:
### Handling Rate Limits
The API allows 1000 requests per minute per IP address. Your organisation may have its own lower rate limits. When rate limited, wait at least 60 seconds before retrying:
The API allows 100 requests per minute per IP address. When rate limited, wait at least 60 seconds before retrying:
```javascript
async function fetchWithRetry(url, options, maxRetries = 3) {
@@ -68,7 +68,6 @@ All webhook events share a common structure:
| `typedSignatureEnabled` | boolean | Whether typed signatures are allowed |
| `uploadSignatureEnabled` | boolean | Whether uploaded signatures are allowed |
| `drawSignatureEnabled` | boolean | Whether drawn signatures are allowed |
| `qrSignatureEnabled` | boolean | Whether QR code handoff to a mobile device is allowed |
| `language` | string | Document language code |
| `distributionMethod` | string | How document is distributed |
| `emailSettings` | object? | Custom email settings for this document |
@@ -139,7 +138,6 @@ Triggered when a new document is created.
"typedSignatureEnabled": true,
"uploadSignatureEnabled": true,
"drawSignatureEnabled": true,
"qrSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
@@ -232,7 +230,6 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
"typedSignatureEnabled": true,
"uploadSignatureEnabled": true,
"drawSignatureEnabled": true,
"qrSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
@@ -425,7 +422,6 @@ The document status changes to `COMPLETED` and `completedAt` is set.
"typedSignatureEnabled": true,
"uploadSignatureEnabled": true,
"drawSignatureEnabled": true,
"qrSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
@@ -603,7 +599,6 @@ This event is **not** triggered when a recipient hides a document from their inb
"typedSignatureEnabled": true,
"uploadSignatureEnabled": true,
"drawSignatureEnabled": true,
"qrSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
+1 -6
View File
@@ -41,17 +41,12 @@ When a limit is reached, requests return a `429 Too Many Requests` response with
| Action | Limit | Window |
| --- | --- | --- |
| API requests (v1 and v2) | 1000 requests | 1 minute |
| API requests (v1 and v2) | 100 requests | 1 minute |
| File uploads | 20 requests | 1 minute |
| AI features | 3 requests | 1 minute |
Authentication endpoints (login, signup, password reset, etc.) are also rate-limited to protect against abuse.
<Callout type="info">
The API request limit above is the global per-IP ceiling. Individual organisations also have their
own rate limits, which may be configured below this value.
</Callout>
<Callout type="info">
Rate limits may vary by plan. Enterprise plans can include higher or custom limits. Contact
[sales](https://documen.so/sales) for details.
@@ -13,7 +13,7 @@ There are three distinct kinds of limit:
| ---------------------- | ------------------------------------------------- | ----------------------- |
| Resource quota | Documents, emails, and API requests **per month** | Yes — per claim and org |
| Resource rate limit | The same resources over a short window (e.g. `1h`) | Yes — per claim and org |
| Global HTTP rate limit | API requests per IP (1000/min, hardcoded) | No — see [Limitations](#limitations) |
| Global HTTP rate limit | API requests per IP (100/min, hardcoded) | No — see [Limitations](#limitations) |
## Prerequisites
@@ -91,7 +91,7 @@ Monthly quota usage is keyed to the **UTC calendar month**. There is no schedule
## Limitations
The **global HTTP rate limit is not configurable.** Documenso enforces a hardcoded **1000 requests per minute per IP address** on its API endpoint groups (`/api/v1`, `/api/v2`, and the tRPC API are limited separately), returning `429 Too Many Requests`. It is a per-IP safeguard applied at the HTTP layer — not per-organisation, not stored on any claim, and not adjustable from the admin panel. See [Rate Limits](/docs/developers/api/rate-limits).
The **global HTTP rate limit is not configurable.** Documenso enforces a hardcoded **100 requests per minute per IP address** on its API endpoint groups (`/api/v1`, `/api/v2`, and the tRPC API are limited separately), returning `429 Too Many Requests`. It is a per-IP safeguard applied at the HTTP layer — not per-organisation, not stored on any claim, and not adjustable from the admin panel. See [Rate Limits](/docs/developers/api/rate-limits).
## Troubleshooting
@@ -1,119 +0,0 @@
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
export type BrandingPreferencesResetDialogProps = {
hasAdvancedBranding: boolean;
isSubmitting: boolean;
onReset: () => Promise<void>;
trigger?: React.ReactNode;
};
export const BrandingPreferencesResetDialog = ({
hasAdvancedBranding,
isSubmitting,
onReset,
trigger,
}: BrandingPreferencesResetDialogProps) => {
const [open, setOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const isLoading = isSubmitting || isResetting;
const handleResetToDefaults = async () => {
setIsResetting(true);
try {
await onReset();
setOpen(false);
} catch {
// The submit handler surfaces its own error toast. Keep the dialog open
// so the user can retry.
} finally {
setIsResetting(false);
}
};
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && setOpen(value)}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="destructive" type="button" size="sm" disabled={isLoading}>
<Trans>Reset to defaults</Trans>
</Button>
)}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Reset branding preferences</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
This will reset all branding preferences to their default values and save the changes immediately.
</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="warning">
<AlertDescription>
<p>
<Trans>Once confirmed, the following will be reset:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
<li>
<Trans>Custom branding enabled setting</Trans>
</li>
<li>
<Trans>Branding logo</Trans>
</li>
<li>
<Trans>Brand website and brand details</Trans>
</li>
<li>
<Trans>Brand colours, including background, foreground, primary, and border colours</Trans>
</li>
{hasAdvancedBranding && (
<>
<li>
<Trans>Border radius</Trans>
</li>
<li>
<Trans>Custom CSS</Trans>
</li>
</>
)}
</ul>
</AlertDescription>
</Alert>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isLoading}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button type="button" variant="destructive" loading={isLoading} onClick={() => void handleResetToDefaults()}>
<Trans>Reset to defaults</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -1,141 +0,0 @@
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
export type DocumentPreferencesResetDialogProps = {
isSubmitting: boolean;
onReset: () => Promise<void>;
showAiFeatures?: boolean;
showDocumentVisibility?: boolean;
showIncludeSenderDetails?: boolean;
};
export const DocumentPreferencesResetDialog = ({
isSubmitting,
onReset,
showAiFeatures = false,
showDocumentVisibility = false,
showIncludeSenderDetails = false,
}: DocumentPreferencesResetDialogProps) => {
const [open, setOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const isLoading = isSubmitting || isResetting;
const handleResetToDefaults = async () => {
setIsResetting(true);
try {
await onReset();
setOpen(false);
} catch {
// The submit handler surfaces its own error toast. Keep the dialog open
// so the user can retry.
} finally {
setIsResetting(false);
}
};
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && setOpen(value)}>
<DialogTrigger asChild>
<Button variant="destructive" type="button" size="sm" disabled={isLoading}>
<Trans>Reset to defaults</Trans>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Reset document preferences</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
This will reset all document preferences to their default values and save the changes immediately.
</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="warning">
<AlertDescription>
<p>
<Trans>Once confirmed, the following will be reset:</Trans>
</p>
<ul className="mt-0.5 list-inside list-disc">
{showDocumentVisibility && (
<li>
<Trans>Default document visibility</Trans>
</li>
)}
<li>
<Trans>Default document language</Trans>
</li>
<li>
<Trans>Default date format</Trans>
</li>
<li>
<Trans>Default time zone</Trans>
</li>
<li>
<Trans>Default signature settings</Trans>
</li>
{showIncludeSenderDetails && (
<li>
<Trans>Send on behalf of team</Trans>
</li>
)}
<li>
<Trans>Include the signing certificate in the document</Trans>
</li>
<li>
<Trans>Include the audit logs in the document</Trans>
</li>
<li>
<Trans>Default recipients</Trans>
</li>
<li>
<Trans>Delegate document ownership</Trans>
</li>
<li>
<Trans>Default envelope expiration</Trans>
</li>
<li>
<Trans>Default signing reminders</Trans>
</li>
{showAiFeatures && (
<li>
<Trans>AI features</Trans>
</li>
)}
</ul>
</AlertDescription>
</Alert>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isLoading}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button type="button" variant="destructive" loading={isLoading} onClick={() => void handleResetToDefaults()}>
<Trans>Reset to defaults</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -13,19 +13,10 @@ export type SignFieldSignatureDialogProps = {
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
qrSignatureEnabled?: boolean;
};
export const SignFieldSignatureDialog = createCallable<SignFieldSignatureDialogProps, string | null>(
({
call,
fullName,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
qrSignatureEnabled,
initialSignature,
}) => {
({ call, fullName, typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled, initialSignature }) => {
const [localSignature, setLocalSignature] = useState(initialSignature);
return (
@@ -45,7 +36,6 @@ export const SignFieldSignatureDialog = createCallable<SignFieldSignatureDialogP
typedSignatureEnabled={typedSignatureEnabled}
uploadSignatureEnabled={uploadSignatureEnabled}
drawSignatureEnabled={drawSignatureEnabled}
qrSignatureEnabled={qrSignatureEnabled}
/>
</div>
@@ -470,7 +470,6 @@ export const EmbedDirectTemplateClientPage = ({
typedSignatureEnabled={metadata?.typedSignatureEnabled}
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
drawSignatureEnabled={metadata?.drawSignatureEnabled}
qrSignatureEnabled={metadata?.qrSignatureEnabled}
/>
</div>
)}
@@ -33,12 +33,7 @@ export type EmbedDocumentFieldsProps = {
fields: Field[];
metadata?: Pick<
DocumentMeta,
| 'timezone'
| 'dateFormat'
| 'typedSignatureEnabled'
| 'uploadSignatureEnabled'
| 'drawSignatureEnabled'
| 'qrSignatureEnabled'
'timezone' | 'dateFormat' | 'typedSignatureEnabled' | 'uploadSignatureEnabled' | 'drawSignatureEnabled'
> | null;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
@@ -58,7 +53,6 @@ export const EmbedDocumentFields = ({ fields, metadata, onSignField, onUnsignFie
typedSignatureEnabled={metadata?.typedSignatureEnabled}
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
drawSignatureEnabled={metadata?.drawSignatureEnabled}
qrSignatureEnabled={metadata?.qrSignatureEnabled}
/>
))
.with(FieldType.INITIALS, () => (
@@ -461,7 +461,6 @@ export const EmbedSignDocumentV1ClientPage = ({
typedSignatureEnabled={metadata?.typedSignatureEnabled}
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
drawSignatureEnabled={metadata?.drawSignatureEnabled}
qrSignatureEnabled={metadata?.qrSignatureEnabled}
/>
</div>
)}
@@ -313,7 +313,6 @@ export const MultiSignDocumentSigningView = ({
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
qrSignatureEnabled={document.documentMeta?.qrSignatureEnabled}
/>
</div>
)}
@@ -7,7 +7,6 @@ import {
} from '@documenso/lib/constants/branding';
import { DEFAULT_BRAND_COLORS, DEFAULT_BRAND_RADIUS } from '@documenso/lib/constants/theme';
import { ZCssVarsSchema } from '@documenso/lib/types/css-vars';
import { normalizeBrandingColors } from '@documenso/lib/utils/normalize-branding-colors';
import { cn } from '@documenso/ui/lib/utils';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@documenso/ui/primitives/accordion';
import { Button } from '@documenso/ui/primitives/button';
@@ -24,7 +23,6 @@ import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { BrandingPreferencesResetDialog } from '~/components/dialogs/branding-preferences-reset-dialog';
import { useOptionalCurrentTeam } from '~/providers/team';
import { useCspNonce } from '~/utils/nonce';
@@ -76,7 +74,6 @@ export function BrandingPreferencesForm({
const [previewUrl, setPreviewUrl] = useState<string>('');
const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
const [colorPickerKey, setColorPickerKey] = useState(0);
const parsedColors = ZCssVarsSchema.safeParse(settings.brandingColors);
const initialColors = parsedColors.success ? parsedColors.data : {};
@@ -99,42 +96,6 @@ export function BrandingPreferencesForm({
const isBrandingEnabled = form.watch('brandingEnabled');
const hasResetBrandingColors =
settings.brandingColors === null ||
settings.brandingColors === undefined ||
(parsedColors.success && normalizeBrandingColors(parsedColors.data) === null);
// Only show the reset action when the saved settings actually differ from the
// defaults, so it never renders as a pointless disabled button.
const isResetToDefaultsVisible =
settings.brandingEnabled !== (canInherit ? null : false) ||
!!settings.brandingLogo ||
!!settings.brandingUrl ||
!!settings.brandingCompanyDetails ||
!!settings.brandingCss ||
!hasResetBrandingColors;
const handleResetToDefaults = async () => {
const data: TBrandingPreferencesFormSchema = {
brandingEnabled: canInherit ? null : false,
brandingLogo: null,
brandingUrl: '',
brandingCompanyDetails: '',
brandingColors: {},
brandingCss: '',
};
await onFormSubmit(data);
if (previewUrl.startsWith('blob:')) {
URL.revokeObjectURL(previewUrl);
}
setPreviewUrl('');
setColorPickerKey((key) => key + 1);
form.reset(data);
};
const getSavedLogoPreviewUrl = () => {
if (!settings.brandingLogo) {
return '';
@@ -436,7 +397,6 @@ export function BrandingPreferencesForm({
</FormDescription>
<FormControl>
<ColorPicker
key={`background-${colorPickerKey}`}
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.background}
@@ -460,7 +420,6 @@ export function BrandingPreferencesForm({
</FormDescription>
<FormControl>
<ColorPicker
key={`foreground-${colorPickerKey}`}
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.foreground}
@@ -484,7 +443,6 @@ export function BrandingPreferencesForm({
</FormDescription>
<FormControl>
<ColorPicker
key={`primary-${colorPickerKey}`}
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.primary}
@@ -508,7 +466,6 @@ export function BrandingPreferencesForm({
</FormDescription>
<FormControl>
<ColorPicker
key={`primary-foreground-${colorPickerKey}`}
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.primaryForeground}
@@ -532,7 +489,6 @@ export function BrandingPreferencesForm({
</FormDescription>
<FormControl>
<ColorPicker
key={`border-${colorPickerKey}`}
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.border}
@@ -556,7 +512,6 @@ export function BrandingPreferencesForm({
</FormDescription>
<FormControl>
<ColorPicker
key={`ring-${colorPickerKey}`}
nonce={nonce}
value={field.value ?? ''}
defaultValue={DEFAULT_BRAND_COLORS.ring}
@@ -638,15 +593,6 @@ export function BrandingPreferencesForm({
isDirty={hasUnsavedChanges}
isSubmitting={form.formState.isSubmitting}
onReset={handleReset}
resetToDefaults={
isResetToDefaultsVisible ? (
<BrandingPreferencesResetDialog
hasAdvancedBranding={hasAdvancedBranding}
isSubmitting={form.formState.isSubmitting}
onReset={handleResetToDefaults}
/>
) : undefined
}
/>
</fieldset>
</form>
@@ -11,10 +11,10 @@ import { isValidLanguageCode, SUPPORTED_LANGUAGE_CODES, SUPPORTED_LANGUAGES } fr
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
import type { TDefaultRecipients } from '@documenso/lib/types/default-recipients';
import { ZDefaultRecipientsSchema } from '@documenso/lib/types/default-recipients';
import { type TDocumentMetaDateFormat, ZDocumentMetaDateFormatSchema } from '@documenso/lib/types/document-meta';
import { generateDefaultOrganisationSettings, isPersonalLayout } from '@documenso/lib/utils/organisations';
import { type TDocumentMetaDateFormat, ZDocumentMetaTimezoneSchema } from '@documenso/lib/types/document-meta';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { extractTeamSignatureSettings, generateDefaultTeamSettings } from '@documenso/lib/utils/teams';
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
import { ExpirationPeriodPicker } from '@documenso/ui/components/document/expiration-period-picker';
import { ReminderSettingsPicker } from '@documenso/ui/components/document/reminder-settings-picker';
@@ -37,11 +37,11 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { msg, t } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentVisibility, OrganisationType, type RecipientRole, type TeamGlobalSettings } from '@prisma/client';
import type { TeamGlobalSettings } from '@prisma/client';
import { DocumentVisibility, OrganisationType, type RecipientRole } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { DocumentPreferencesResetDialog } from '~/components/dialogs/document-preferences-reset-dialog';
import { useOptionalCurrentTeam } from '~/providers/team';
import { DefaultRecipientsMultiSelectCombobox } from '../general/default-recipients-multiselect-combobox';
@@ -79,7 +79,6 @@ type SettingsSubset = Pick<
| 'typedSignatureEnabled'
| 'uploadSignatureEnabled'
| 'drawSignatureEnabled'
| 'qrSignatureEnabled'
| 'defaultRecipients'
| 'delegateDocumentOwnership'
| 'aiFeaturesEnabled'
@@ -94,26 +93,6 @@ export type DocumentPreferencesFormProps = {
onFormSubmit: (data: TDocumentPreferencesFormSchema) => Promise<void>;
};
const getDocumentPreferencesFormValues = (settings: SettingsSubset): TDocumentPreferencesFormSchema => {
const parsedDocumentDateFormat = ZDocumentMetaDateFormatSchema.safeParse(settings.documentDateFormat);
return {
documentVisibility: settings.documentVisibility,
documentLanguage: isValidLanguageCode(settings.documentLanguage) ? settings.documentLanguage : null,
documentTimezone: settings.documentTimezone,
documentDateFormat: parsedDocumentDateFormat.success ? parsedDocumentDateFormat.data : null,
includeSenderDetails: settings.includeSenderDetails,
includeSigningCertificate: settings.includeSigningCertificate,
includeAuditLog: settings.includeAuditLog,
signatureTypes: extractTeamSignatureSettings({ ...settings }),
defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null,
delegateDocumentOwnership: settings.delegateDocumentOwnership,
aiFeaturesEnabled: settings.aiFeaturesEnabled,
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
reminderSettings: settings.reminderSettings ?? null,
};
};
export const DocumentPreferencesForm = ({
settings,
onFormSubmit,
@@ -134,7 +113,7 @@ export const DocumentPreferencesForm = ({
documentVisibility: z.nativeEnum(DocumentVisibility).nullable(),
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullable(),
documentTimezone: z.string().nullable(),
documentDateFormat: ZDocumentMetaDateFormatSchema.nullable(),
documentDateFormat: ZDocumentMetaTimezoneSchema.nullable(),
includeSenderDetails: z.boolean().nullable(),
includeSigningCertificate: z.boolean().nullable(),
includeAuditLog: z.boolean().nullable(),
@@ -148,33 +127,26 @@ export const DocumentPreferencesForm = ({
reminderSettings: ZEnvelopeReminderSettings.nullable(),
});
const defaultValues = getDocumentPreferencesFormValues(settings);
const defaultSettings = canInherit ? generateDefaultTeamSettings() : generateDefaultOrganisationSettings();
const baseResetValues = getDocumentPreferencesFormValues(defaultSettings);
const resetValues = {
...baseResetValues,
aiFeaturesEnabled: isAiFeaturesConfigured ? baseResetValues.aiFeaturesEnabled : defaultValues.aiFeaturesEnabled,
};
const form = useForm<TDocumentPreferencesFormSchema>({
defaultValues,
defaultValues: {
documentVisibility: settings.documentVisibility,
documentLanguage: isValidLanguageCode(settings.documentLanguage) ? settings.documentLanguage : null,
documentTimezone: settings.documentTimezone,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
documentDateFormat: settings.documentDateFormat as TDocumentMetaDateFormat | null,
includeSenderDetails: settings.includeSenderDetails,
includeSigningCertificate: settings.includeSigningCertificate,
includeAuditLog: settings.includeAuditLog,
signatureTypes: extractTeamSignatureSettings({ ...settings }),
defaultRecipients: settings.defaultRecipients ? ZDefaultRecipientsSchema.parse(settings.defaultRecipients) : null,
delegateDocumentOwnership: settings.delegateDocumentOwnership,
aiFeaturesEnabled: settings.aiFeaturesEnabled,
envelopeExpirationPeriod: settings.envelopeExpirationPeriod ?? null,
reminderSettings: settings.reminderSettings ?? null,
},
resolver: zodResolver(ZDocumentPreferencesFormSchema),
});
// Parse both sides through the schema so we compare canonical representations
const parsedCurrentValues = ZDocumentPreferencesFormSchema.safeParse(defaultValues);
const parsedResetValues = ZDocumentPreferencesFormSchema.safeParse(resetValues);
const isResetToDefaultsVisible =
!parsedCurrentValues.success ||
!parsedResetValues.success ||
JSON.stringify(parsedCurrentValues.data) !== JSON.stringify(parsedResetValues.data);
const handleResetToDefaults = async () => {
await onFormSubmit(resetValues);
form.reset(resetValues);
};
const handleFormSubmit = form.handleSubmit(async (data) => {
try {
await onFormSubmit(data);
@@ -800,17 +772,6 @@ export const DocumentPreferencesForm = ({
isDirty={form.formState.isDirty}
isSubmitting={form.formState.isSubmitting}
onReset={() => form.reset()}
resetToDefaults={
isResetToDefaultsVisible ? (
<DocumentPreferencesResetDialog
isSubmitting={form.formState.isSubmitting}
onReset={handleResetToDefaults}
showAiFeatures={isAiFeaturesConfigured}
showDocumentVisibility={!isPersonalLayoutMode}
showIncludeSenderDetails={!isPersonalLayoutMode && !isPersonalOrganisation}
/>
) : undefined
}
/>
</fieldset>
</form>
@@ -3,17 +3,12 @@ import { Button } from '@documenso/ui/primitives/button';
import { Trans, useLingui } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion';
import { AlertTriangleIcon } from 'lucide-react';
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
export type FormStickySaveBarProps = {
isDirty: boolean;
isSubmitting: boolean;
onReset: () => void;
/**
* Slot for a "reset to defaults" action, rendered before the Undo button. Hidden while
* the bar is floating so it never appears in the unsaved-changes island.
*/
resetToDefaults?: ReactNode;
};
/**
@@ -29,7 +24,7 @@ export type FormStickySaveBarProps = {
* shared-layout morph). A 1px sentinel below it detects the stuck state so we can toggle
* the pill chrome.
*/
export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset, resetToDefaults }: FormStickySaveBarProps) => {
export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset }: FormStickySaveBarProps) => {
const { t } = useLingui();
const sentinelRef = useRef<HTMLDivElement>(null);
@@ -105,8 +100,6 @@ export const FormStickySaveBar = ({ isDirty, isSubmitting, onReset, resetToDefau
</AnimatePresence>
<div className="ml-auto flex flex-shrink-0 items-center gap-x-2">
{!isFloating && resetToDefaults}
{isDirty && (
<Button type="button" variant="secondary" size="sm" onClick={onReset} disabled={isSubmitting}>
<Trans>Undo</Trans>
@@ -156,10 +156,6 @@ export const AdminGlobalSettingsSection = ({
</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>QR signature</Trans>}>
<DetailsValue>{booleanValue(settings.qrSignatureEnabled, inheritedSettings?.qrSignatureEnabled)}</DetailsValue>
</DetailsCard>
<DetailsCard label={<Trans>Branding</Trans>}>
<DetailsValue>{booleanValue(settings.brandingEnabled, inheritedSettings?.brandingEnabled)}</DetailsValue>
</DetailsCard>
@@ -1,23 +0,0 @@
import { Trans } from '@lingui/react/macro';
import { AlertTriangleIcon } from 'lucide-react';
export const DirectTemplateInvalidPageView = () => {
return (
<div className="mx-auto flex h-[70vh] w-full max-w-md flex-col items-center justify-center">
<div>
<AlertTriangleIcon className="h-10 w-10 text-destructive" />
<h1 className="mt-4 font-semibold text-3xl">
<Trans>Invalid direct link template</Trans>
</h1>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>
This direct link template cannot be used because one or more signers do not have a signature field assigned.
Please contact the sender to update the template.
</Trans>
</p>
</div>
</div>
);
};
@@ -269,7 +269,6 @@ export const DirectTemplateSigningForm = ({
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
qrSignatureEnabled={template.templateMeta?.qrSignatureEnabled}
/>
))
.with(FieldType.INITIALS, () => (
@@ -409,7 +408,6 @@ export const DirectTemplateSigningForm = ({
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
qrSignatureEnabled={template.templateMeta?.qrSignatureEnabled}
/>
</div>
</div>
@@ -254,7 +254,6 @@ export const DocumentSigningForm = ({
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
qrSignatureEnabled={document.documentMeta?.qrSignatureEnabled}
/>
</div>
)}
@@ -408,7 +408,6 @@ export const DocumentSigningPageViewV1 = ({
typedSignatureEnabled={documentMeta?.typedSignatureEnabled}
uploadSignatureEnabled={documentMeta?.uploadSignatureEnabled}
drawSignatureEnabled={documentMeta?.drawSignatureEnabled}
qrSignatureEnabled={documentMeta?.qrSignatureEnabled}
/>
))
.with(FieldType.INITIALS, () => <DocumentSigningInitialsField key={field.id} field={field} />)
@@ -33,7 +33,6 @@ export interface DocumentSigningProviderProps {
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
qrSignatureEnabled?: boolean;
children: React.ReactNode;
}
@@ -44,7 +43,6 @@ export const DocumentSigningProvider = ({
typedSignatureEnabled = true,
uploadSignatureEnabled = true,
drawSignatureEnabled = true,
qrSignatureEnabled = true,
children,
}: DocumentSigningProviderProps) => {
const [fullName, setFullName] = useState(initialFullName || '');
@@ -56,7 +54,7 @@ export const DocumentSigningProvider = ({
const sig = initialSignature || '';
const isBase64 = isBase64Image(sig);
if (isBase64 && (uploadSignatureEnabled || drawSignatureEnabled || qrSignatureEnabled)) {
if (isBase64 && (uploadSignatureEnabled || drawSignatureEnabled)) {
return sig;
}
@@ -34,7 +34,6 @@ export type DocumentSigningSignatureFieldProps = {
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
qrSignatureEnabled?: boolean;
};
export const DocumentSigningSignatureField = ({
@@ -44,7 +43,6 @@ export const DocumentSigningSignatureField = ({
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
qrSignatureEnabled,
}: DocumentSigningSignatureFieldProps) => {
const { _ } = useLingui();
const { toast } = useToast();
@@ -281,7 +279,6 @@ export const DocumentSigningSignatureField = ({
typedSignatureEnabled={typedSignatureEnabled}
uploadSignatureEnabled={uploadSignatureEnabled}
drawSignatureEnabled={drawSignatureEnabled}
qrSignatureEnabled={qrSignatureEnabled}
/>
<DocumentSigningDisclosure />
@@ -172,9 +172,7 @@ export const EnvelopeSigningProvider = ({
if (
!sig &&
(envelope.documentMeta.uploadSignatureEnabled ||
envelope.documentMeta.drawSignatureEnabled ||
envelope.documentMeta.qrSignatureEnabled) &&
(envelope.documentMeta.uploadSignatureEnabled || envelope.documentMeta.drawSignatureEnabled) &&
envelopeData.recipientSignature?.signatureImageAsBase64
) {
return envelopeData.recipientSignature.signatureImageAsBase64;
@@ -184,12 +182,7 @@ export const EnvelopeSigningProvider = ({
return envelopeData.recipientSignature.typedSignature;
}
if (
isBase64 &&
(envelope.documentMeta.uploadSignatureEnabled ||
envelope.documentMeta.drawSignatureEnabled ||
envelope.documentMeta.qrSignatureEnabled)
) {
if (isBase64 && (envelope.documentMeta.uploadSignatureEnabled || envelope.documentMeta.drawSignatureEnabled)) {
return sig;
}
@@ -174,7 +174,6 @@ export const DocumentEditForm = ({ className, initialDocument, documentRootPath
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
qrSignatureEnabled: signatureTypes.includes(DocumentSignatureType.QR),
},
});
};
@@ -27,7 +27,7 @@ import {
Share,
Trash2,
} from 'lucide-react';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router';
import { EnvelopeDeleteDialog } from '~/components/dialogs/envelope-delete-dialog';
@@ -53,6 +53,21 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
const [isRenameDialogOpen, setRenameDialogOpen] = useState(false);
const [isSaveAsTemplateDialogOpen, setSaveAsTemplateDialogOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkIfMobile = () => {
setIsMobile(window.innerWidth < 768);
};
checkIfMobile();
window.addEventListener('resize', checkIfMobile);
return () => {
window.removeEventListener('resize', checkIfMobile);
};
}, []);
const recipient = envelope.recipients.find((recipient) => recipient.email === user.email);
@@ -74,7 +89,7 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
<MoreHorizontal className="h-5 w-5 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" align="end" forceMount>
<DropdownMenuContent className="w-52" align={isMobile ? 'end' : 'start'} forceMount>
<DropdownMenuLabel>
<Trans>Action</Trans>
</DropdownMenuLabel>
@@ -54,7 +54,6 @@ import { useCurrentTeam } from '~/providers/team';
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
import { EnvelopeEditorFieldsPageRenderer } from './envelope-editor-fields-page-renderer';
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
import { EnvelopeRecipientSelector } from './envelope-recipient-selector';
@@ -239,8 +238,6 @@ export const EnvelopeEditorFieldsPage = () => {
}
/>
<EnvelopeEditorInvalidDirectTemplateAlert />
{/* Document View */}
<div className="mt-4 flex h-full flex-col items-center justify-center">
{envelope.recipients.length === 0 && (
@@ -1,55 +0,0 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Trans } from '@lingui/react/macro';
import { useMemo } from 'react';
export type EnvelopeEditorInvalidDirectTemplateAlertProps = {
className?: string;
};
/**
* Warns that a direct link template cannot be used because one or more signers
* are missing a signature field.
*/
export const EnvelopeEditorInvalidDirectTemplateAlert = ({
className,
}: EnvelopeEditorInvalidDirectTemplateAlertProps) => {
const { envelope, isTemplate } = useCurrentEnvelopeEditor();
const signersMissingSignatureFields = useMemo(() => {
if (!isTemplate || !envelope.directLink?.enabled) {
return [];
}
return getRecipientsWithMissingFields(envelope.recipients, envelope.fields);
}, [isTemplate, envelope.directLink, envelope.recipients, envelope.fields]);
if (signersMissingSignatureFields.length === 0) {
return null;
}
return (
<Alert
variant="destructive"
className={cn('mx-auto w-full max-w-[800px] flex-row items-start gap-3 rounded-sm', className)}
>
<AlertTitle>
<Trans>Invalid direct link template</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Recipients cannot use this direct link template because the following signers are missing a signature field
</Trans>
<ul className="list-disc pl-5">
{signersMissingSignatureFields.map((recipient, i) => (
<li key={recipient.id}>{recipient.email || recipient.name || `Recipient ${i + 1}`}</li>
))}
</ul>
</AlertDescription>
</Alert>
);
};
@@ -22,7 +22,6 @@ import { match } from 'ts-pattern';
import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer';
import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer';
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
export const EnvelopeEditorPreviewPage = () => {
@@ -229,8 +228,6 @@ export const EnvelopeEditorPreviewPage = () => {
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector className="px-0" fields={editorFields.localFields} />
<EnvelopeEditorInvalidDirectTemplateAlert className="mb-4" />
<Alert variant="warning" className="mx-auto max-w-[800px]">
<AlertTitle>
<Trans>Preview Mode</Trans>
@@ -278,7 +278,6 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
qrSignatureEnabled: signatureTypes.includes(DocumentSignatureType.QR),
envelopeExpirationPeriod,
reminderSettings,
},
@@ -26,7 +26,6 @@ import { ErrorCode as DropzoneErrorCode, type FileRejection, useDropzone } from
import { EnvelopeItemDeleteDialog } from '~/components/dialogs/envelope-item-delete-dialog';
import { EnvelopeEditorInvalidDirectTemplateAlert } from './envelope-editor-invalid-direct-template-alert';
import { EnvelopeEditorRecipientForm } from './envelope-editor-recipient-form';
import { EnvelopeItemTitleInput } from './envelope-editor-title-input';
@@ -450,9 +449,6 @@ export const EnvelopeEditorUploadPage = () => {
return (
<div className="mx-auto max-w-4xl space-y-6 p-8">
<input {...getReplaceInputProps()} />
<EnvelopeEditorInvalidDirectTemplateAlert className="max-w-none" />
<Card backdropBlur={false} className="border">
<CardHeader className="pb-3">
<CardTitle>
@@ -121,7 +121,6 @@ export default function EnvelopeSignerForm() {
typedSignatureEnabled={envelope.documentMeta.typedSignatureEnabled}
uploadSignatureEnabled={envelope.documentMeta.uploadSignatureEnabled}
drawSignatureEnabled={envelope.documentMeta.drawSignatureEnabled}
qrSignatureEnabled={envelope.documentMeta.qrSignatureEnabled}
/>
</div>
)}
@@ -384,7 +384,6 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled,
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled,
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled,
qrSignatureEnabled: envelope.documentMeta.qrSignatureEnabled,
})
.then(async (payload) => {
if (!payload) {
@@ -137,7 +137,6 @@ export const TemplateEditForm = ({ initialTemplate, className, templateRootPath
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
qrSignatureEnabled: signatureTypes.includes(DocumentSignatureType.QR),
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
},
});
@@ -88,8 +88,7 @@ export default function OrganisationSettingsDocumentPage() {
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
qrSignatureEnabled: signatureTypes.includes(DocumentSignatureType.QR),
delegateDocumentOwnership,
delegateDocumentOwnership: delegateDocumentOwnership,
aiFeaturesEnabled,
envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined,
reminderSettings: reminderSettings ?? undefined,
@@ -76,15 +76,13 @@ export default function TeamsSettingsPage() {
typedSignatureEnabled: null,
uploadSignatureEnabled: null,
drawSignatureEnabled: null,
qrSignatureEnabled: null,
}
: {
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
qrSignatureEnabled: signatureTypes.includes(DocumentSignatureType.QR),
}),
delegateDocumentOwnership,
delegateDocumentOwnership: delegateDocumentOwnership,
},
});
@@ -7,7 +7,6 @@ import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { Plural } from '@lingui/react/macro';
import { UsersIcon } from 'lucide-react';
@@ -15,7 +14,6 @@ import { redirect } from 'react-router';
import { match } from 'ts-pattern';
import { Header as AuthenticatedHeader } from '~/components/general/app-header';
import { DirectTemplateInvalidPageView } from '~/components/general/direct-template/direct-template-invalid-page';
import { DirectTemplatePageView } from '~/components/general/direct-template/direct-template-page';
import { DirectTemplateAuthPageView } from '~/components/general/direct-template/direct-template-signing-auth-page';
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
@@ -72,18 +70,8 @@ const handleV1Loader = async ({ params, request }: Route.LoaderArgs) => {
};
}
const recipientsWithMissingFields = getRecipientsWithMissingFields(template.recipients, template.fields);
if (recipientsWithMissingFields.length > 0) {
return {
isAccessAuthValid: true,
isTemplateMissingSignatures: true,
} as const;
}
return {
isAccessAuthValid: true,
isTemplateMissingSignatures: false,
template: {
...template,
folder: null,
@@ -108,7 +96,6 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
.then((envelopeForSigning) => {
return {
isDocumentAccessValid: true,
isTemplateMissingSignatures: false,
envelopeForSigning,
} as const;
})
@@ -121,13 +108,6 @@ const handleV2Loader = async ({ params, request }: Route.LoaderArgs) => {
} as const;
}
if (error.code === AppErrorCode.MISSING_SIGNATURE_FIELD) {
return {
isDocumentAccessValid: true,
isTemplateMissingSignatures: true,
} as const;
}
throw new Response('Not Found', { status: 404 });
});
};
@@ -201,10 +181,6 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV
return <DirectTemplateAuthPageView />;
}
if (data.isTemplateMissingSignatures) {
return <DirectTemplateInvalidPageView />;
}
const { template, directTemplateRecipient } = data;
return (
@@ -215,7 +191,6 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
qrSignatureEnabled={template.templateMeta?.qrSignatureEnabled}
>
<DocumentSigningAuthProvider
documentAuthOptions={template.authOptions}
@@ -260,10 +235,6 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
return <DocumentSigningAuthPageView email={''} emailHasAccount={true} />;
}
if (data.isTemplateMissingSignatures) {
return <DirectTemplateInvalidPageView />;
}
const { envelope, recipient } = data.envelopeForSigning;
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
@@ -474,7 +474,6 @@ const SigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loade
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
qrSignatureEnabled={document.documentMeta?.qrSignatureEnabled}
>
<DocumentSigningAuthProvider documentAuthOptions={document.authOptions} recipient={recipient} user={user}>
{sessionData?.user && <AuthenticatedHeader />}
@@ -266,7 +266,6 @@ const EmbedDirectTemplatePageV1 = ({ data }: { data: Awaited<ReturnType<typeof h
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
qrSignatureEnabled={template.templateMeta?.qrSignatureEnabled}
>
<DocumentSigningAuthProvider documentAuthOptions={template.authOptions} recipient={recipient} user={user}>
<DocumentSigningRecipientProvider recipient={recipient}>
@@ -354,7 +354,6 @@ const EmbedSignDocumentPageV1 = ({ data }: { data: Awaited<ReturnType<typeof han
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
qrSignatureEnabled={document.documentMeta?.qrSignatureEnabled}
>
<DocumentSigningAuthProvider documentAuthOptions={document.authOptions} recipient={recipient} user={user}>
<EmbedSignDocumentV1ClientPage
@@ -83,7 +83,6 @@ export default function EmbeddingAuthoringDocumentCreatePage() {
drawSignatureEnabled: signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.DRAW),
typedSignatureEnabled: signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.UPLOAD),
qrSignatureEnabled: signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.QR),
},
recipients: configuration.signers.map((signer) => ({
name: signer.name,
@@ -101,10 +101,6 @@ export default function EmbeddingAuthoringDocumentEditPage() {
types.push(DocumentSignatureType.UPLOAD);
}
if (document.documentMeta?.qrSignatureEnabled) {
types.push(DocumentSignatureType.QR);
}
return types;
}, [document.documentMeta]);
@@ -220,10 +216,6 @@ export default function EmbeddingAuthoringDocumentEditPage() {
? configuration.meta.signatureTypes.length === 0 ||
configuration.meta.signatureTypes.includes(DocumentSignatureType.UPLOAD)
: undefined,
qrSignatureEnabled: configuration.meta.signatureTypes
? configuration.meta.signatureTypes.length === 0 ||
configuration.meta.signatureTypes.includes(DocumentSignatureType.QR)
: undefined,
},
recipients: configuration.signers.map((signer) => ({
id: signer.nativeId,
@@ -101,10 +101,6 @@ export default function EmbeddingAuthoringTemplateEditPage() {
types.push(DocumentSignatureType.UPLOAD);
}
if (template.templateMeta?.qrSignatureEnabled) {
types.push(DocumentSignatureType.QR);
}
return types;
}, [template.templateMeta]);
@@ -219,10 +215,6 @@ export default function EmbeddingAuthoringTemplateEditPage() {
? configuration.meta.signatureTypes.length === 0 ||
configuration.meta.signatureTypes.includes(DocumentSignatureType.UPLOAD)
: undefined,
qrSignatureEnabled: configuration.meta.signatureTypes
? configuration.meta.signatureTypes.length === 0 ||
configuration.meta.signatureTypes.includes(DocumentSignatureType.QR)
: undefined,
},
recipients: configuration.signers.map((signer) => ({
id: signer.nativeId,
@@ -238,7 +238,6 @@ export default function MultisignPage() {
typedSignatureEnabled={selectedDocument.documentMeta?.typedSignatureEnabled}
uploadSignatureEnabled={selectedDocument.documentMeta?.uploadSignatureEnabled}
drawSignatureEnabled={selectedDocument.documentMeta?.drawSignatureEnabled}
qrSignatureEnabled={selectedDocument.documentMeta?.qrSignatureEnabled}
>
<DocumentSigningAuthProvider
documentAuthOptions={selectedDocument.authOptions}
@@ -224,7 +224,6 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled ?? undefined,
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled ?? undefined,
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled ?? undefined,
qrSignatureEnabled: envelope.documentMeta.qrSignatureEnabled ?? undefined,
dateFormat: (envelope.documentMeta.dateFormat as TDocumentMetaDateFormat) ?? undefined,
language: envelope.documentMeta.language as SupportedLanguageCodes,
},
@@ -239,7 +239,6 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled, //
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled, //
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled, //
qrSignatureEnabled: envelope.documentMeta.qrSignatureEnabled, //
dateFormat: (envelope.documentMeta.dateFormat as TDocumentMetaDateFormat) ?? undefined,
language: envelope.documentMeta.language as SupportedLanguageCodes,
},
@@ -12,21 +12,12 @@ type HandleSignatureFieldClickOptions = {
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
qrSignatureEnabled?: boolean;
};
export const handleSignatureFieldClick = async (
options: HandleSignatureFieldClickOptions,
): Promise<Extract<TSignEnvelopeFieldValue, { type: typeof FieldType.SIGNATURE }> | null> => {
const {
field,
fullName,
signature,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
qrSignatureEnabled,
} = options;
const { field, fullName, signature, typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled } = options;
if (field.type !== FieldType.SIGNATURE) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
@@ -49,7 +40,6 @@ export const handleSignatureFieldClick = async (
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
qrSignatureEnabled,
});
}
@@ -32,10 +32,6 @@ export const getDirectTemplateErrorMessage = (code: string): ToastMessageDescrip
return match(code)
.with('RECIPIENT_LIMIT_EXCEEDED', () => RECIPIENT_LIMIT_EXCEEDED_ERROR_MESSAGE)
.with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE)
.with(AppErrorCode.MISSING_SIGNATURE_FIELD, () => ({
title: msg`Missing signature fields`,
description: msg`This direct link template cannot be used because one or more signers do not have a signature field assigned.`,
}))
.otherwise(() => ({
title: msg`Something went wrong`,
description: msg`We were unable to submit this document at this time. Please try again later.`,
@@ -81,10 +77,6 @@ export const getTemplateUseErrorMessage = (code: string): ToastMessageDescriptor
title: msg`Error`,
description: msg`The document was created but could not be sent to recipients.`,
}))
.with(AppErrorCode.MISSING_SIGNATURE_FIELD, () => ({
title: msg`Missing signature fields`,
description: msg`The document could not be sent because some signers do not have a signature field. Please edit the template and add a signature field for each signer.`,
}))
.with(AppErrorCode.INVALID_BODY, AppErrorCode.INVALID_REQUEST, () => ({
title: msg`Error`,
description: msg`The document could not be created because of missing or invalid information. Please review the template's recipients and fields.`,
+1 -1
View File
@@ -106,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.16.0"
"version": "2.15.0"
}
+3 -4
View File
@@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "2.16.0",
"version": "2.15.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "2.16.0",
"version": "2.15.0",
"hasInstallScript": true,
"workspaces": [
"apps/*",
@@ -366,7 +366,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "2.16.0",
"version": "2.15.0",
"dependencies": {
"@cantoo/pdf-lib": "^2.5.3",
"@documenso/api": "*",
@@ -33965,7 +33965,6 @@
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
"ts-pattern": "^5.9.0",
"uqr": "^0.1.2",
"zod": "^3.25.76"
},
"devDependencies": {
+1 -1
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.16.0",
"version": "2.15.0",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
-1
View File
@@ -438,7 +438,6 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
typedSignatureEnabled: body.meta.typedSignatureEnabled,
uploadSignatureEnabled: body.meta.uploadSignatureEnabled,
drawSignatureEnabled: body.meta.drawSignatureEnabled,
qrSignatureEnabled: body.meta.qrSignatureEnabled,
distributionMethod: body.meta.distributionMethod,
emailSettings: body.meta.emailSettings,
},
@@ -50,7 +50,7 @@ import type { Organisation, Team, User } from '@prisma/client';
*
* --- GLOBAL LIMIT AWARENESS ---
* apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v1/*:
* apiV1RateLimit = 1000 requests / 1 minute (action `api.v1`, see rate-limits.ts).
* apiV1RateLimit = 100 requests / 1 minute (action `api.v1`, see rate-limits.ts).
* Every per-org limit/quota configured here is kept FAR below that ceiling (single
* digits) and the suite runs serially so the shared-IP global bucket is never the
* thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit
@@ -62,7 +62,7 @@ const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v1`;
// Run serially: all workers share one IP, and the global /api/v1 limiter is
// per-IP. Serial execution keeps the shared global bucket well under 1000/min.
// per-IP. Serial execution keeps the shared global bucket well under 100/min.
test.describe.configure({ mode: 'serial' });
// This suite is only meaningful with real rate limiting enabled. CI sets the
@@ -125,7 +125,7 @@ const setClaimLimits = async (team: Team, limits: ClaimLimits) => {
* GLOBAL /api/v1 IP bucket so a fresh scenario starts from zero.
*
* - The org windowed limiter keys its rows `ip:org:<id>`.
* - The GLOBAL limiter (apps/remix/server/router.ts -> apiV1RateLimit, 1000/min
* - The GLOBAL limiter (apps/remix/server/router.ts -> apiV1RateLimit, 100/min
* per IP, action `api.v1`) is shared by EVERY v1 request from this test client.
* Across the suite (and especially across repeated local runs within the same
* minute) that shared bucket would otherwise fill up and trip BEFORE the org
@@ -196,7 +196,6 @@ test.describe('API V2 Envelopes', () => {
typedSignatureEnabled: true,
uploadSignatureEnabled: false,
drawSignatureEnabled: false,
qrSignatureEnabled: false,
emailReplyTo: userA.email,
emailSettings: {
recipientSigningRequest: false,
@@ -296,7 +295,6 @@ test.describe('API V2 Envelopes', () => {
expect(envelope.documentMeta.typedSignatureEnabled).toBe(payload.meta.typedSignatureEnabled);
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(payload.meta.uploadSignatureEnabled);
expect(envelope.documentMeta.drawSignatureEnabled).toBe(payload.meta.drawSignatureEnabled);
expect(envelope.documentMeta.qrSignatureEnabled).toBe(payload.meta.qrSignatureEnabled);
expect(envelope.documentMeta.emailReplyTo).toBe(payload.meta.emailReplyTo);
expect(envelope.documentMeta.emailSettings).toEqual(payload.meta.emailSettings);
@@ -37,7 +37,7 @@ import type { Organisation, Team, User } from '@prisma/client';
*
* --- GLOBAL LIMIT AWARENESS ---
* apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v2/*:
* apiV2RateLimit = 1000 requests / 1 minute (see rate-limits.ts).
* apiV2RateLimit = 100 requests / 1 minute (see rate-limits.ts).
* Every per-org limit/quota configured here is kept FAR below that ceiling (single
* digits) and the suite runs serially so the shared-IP global bucket is never the
* thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit
@@ -49,7 +49,7 @@ const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
// Run serially: all workers share one IP, and the global /api/v2 limiter is
// per-IP. Serial execution keeps the shared global bucket well under 1000/min.
// per-IP. Serial execution keeps the shared global bucket well under 100/min.
test.describe.configure({ mode: 'serial' });
// This suite is only meaningful with real rate limiting enabled. CI sets the
@@ -158,7 +158,6 @@ test.describe('AutoSave Settings Step', () => {
expect(retrieved.documentMeta?.drawSignatureEnabled).toBe(false);
expect(retrieved.documentMeta?.typedSignatureEnabled).toBe(false);
expect(retrieved.documentMeta?.uploadSignatureEnabled).toBe(true);
expect(retrieved.documentMeta?.qrSignatureEnabled).toBe(true);
}).toPass();
});
@@ -1,76 +0,0 @@
import { seedDirectTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, type Page, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
import { clickEnvelopeEditorStep } from '../fixtures/envelope-editor';
const INVALID_DIRECT_TEMPLATE_ALERT_TITLE = 'Invalid direct link template';
/**
* Place a field on the PDF canvas in the envelope editor.
*/
const placeFieldOnPdf = async (root: Page, fieldName: 'Signature' | 'Text', position: { x: number; y: number }) => {
await root.getByRole('button', { name: fieldName, exact: true }).click();
const canvas = root.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible();
await canvas.click({ position });
};
/**
* Seed a V2 direct template and open it in the native template editor.
*
* Only the native template editor is covered here: direct links only exist
* for templates and are not part of the embedded editor surfaces.
*/
const openDirectTemplateEditor = async (page: Page, options: { createDirectRecipientSignatureField: boolean }) => {
const { user, team } = await seedUser();
const template = await seedDirectTemplate({
title: `E2E Direct Template Validation ${Date.now()}`,
userId: user.id,
teamId: team.id,
internalVersion: 2,
createDirectRecipientSignatureField: options.createDirectRecipientSignatureField,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/templates/${template.id}/edit`,
});
return { user, team, template };
};
test.describe('template editor', () => {
test('shows invalid direct template warning when a signer has no signature field', async ({ page }) => {
await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: false });
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).toBeVisible();
await expect(page.getByText('are missing a signature field')).toBeVisible();
});
test('does not show the warning when all signers have signature fields', async ({ page }) => {
await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: true });
// Wait for the editor to render before asserting the banner is absent.
await expect(page.getByTestId('envelope-editor-step-upload')).toBeVisible();
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).not.toBeVisible();
});
test('warning disappears after placing a signature field', async ({ page }) => {
await openDirectTemplateEditor(page, { createDirectRecipientSignatureField: false });
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).toBeVisible();
// Place a signature field for the direct recipient (auto-selected single recipient).
await clickEnvelopeEditorStep(page, 'addFields');
await expect(page.locator('.konva-container canvas').first()).toBeVisible();
await placeFieldOnPdf(page, 'Signature', { x: 120, y: 140 });
// The banner clears once the field is autosaved and the envelope state updates.
await expect(page.getByText(INVALID_DIRECT_TEMPLATE_ALERT_TITLE)).not.toBeVisible({ timeout: 15_000 });
});
});
@@ -384,7 +384,6 @@ const assertEnvelopeSettingsPersistedInDatabase = async ({
expect(envelope.documentMeta.drawSignatureEnabled).toBe(true);
expect(envelope.documentMeta.typedSignatureEnabled).toBe(true);
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(false);
expect(envelope.documentMeta.qrSignatureEnabled).toBe(true);
expect(envelope.documentMeta.emailSettings).toMatchObject(DB_EXPECTED_VALUES.emailSettings);
const authOptions = parseAuthOptions(envelope.authOptions);
@@ -56,7 +56,6 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
expect(teamSettings.typedSignatureEnabled).toEqual(true);
expect(teamSettings.uploadSignatureEnabled).toEqual(false);
expect(teamSettings.drawSignatureEnabled).toEqual(false);
expect(teamSettings.qrSignatureEnabled).toEqual(true);
// Edit the team settings
await page.goto(`/t/${team.url}/settings/document`);
@@ -91,7 +90,6 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
expect(updatedTeamSettings.typedSignatureEnabled).toEqual(true);
expect(updatedTeamSettings.uploadSignatureEnabled).toEqual(false);
expect(updatedTeamSettings.drawSignatureEnabled).toEqual(false);
expect(updatedTeamSettings.qrSignatureEnabled).toEqual(true);
const document = await seedTeamDocumentWithMeta(team);
@@ -107,7 +105,6 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
expect(documentMeta.typedSignatureEnabled).toEqual(true);
expect(documentMeta.uploadSignatureEnabled).toEqual(false);
expect(documentMeta.drawSignatureEnabled).toEqual(false);
expect(documentMeta.qrSignatureEnabled).toEqual(true);
expect(documentMeta.language).toEqual('pl');
expect(documentMeta.timezone).toEqual('Europe/London');
expect(documentMeta.dateFormat).toEqual('MM/dd/yyyy');
@@ -6,7 +6,6 @@ import { expect, test } from '@playwright/test';
import { apiSignin } from '../fixtures/authentication';
import { expectToastTextToBeVisible } from '../fixtures/generic';
import { signSignaturePad } from '../fixtures/signature';
test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
const { user, team } = await seedUser();
@@ -74,19 +73,8 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
await expect(page.locator('body')).toContainText('public-direct-template-title');
await expect(page.locator('body')).toContainText('public-direct-template-description');
const directSignatureField = directTemplate.fields[0];
if (!directSignatureField) {
throw new Error('Expected seeded direct template signature field to exist');
}
await page.getByRole('link', { name: 'Sign' }).click();
await page.getByRole('button', { name: 'Continue' }).click();
await signSignaturePad(page);
await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click();
await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true');
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
@@ -1,213 +0,0 @@
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { prisma } from '@documenso/prisma';
import { AnonymousVerificationTokenType, FieldType } from '@documenso/prisma/client';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
test.describe.configure({ mode: 'parallel' });
/**
* Draw a zig-zag onto the drawing canvas so that it passes the minimum
* signature coverage threshold.
*/
const drawOnSignaturePad = async (page: Page) => {
const canvas = page.getByTestId('signature-pad-draw');
await canvas.waitFor({ state: 'visible' });
let capturedBox: { x: number; y: number; width: number; height: number } | null = null;
// `boundingBox()` can return null if the canvas is replaced mid-hydration,
// so poll until a measurable element is attached, capturing the box inside
// the retry closure so it is never re-fetched (and re-raced) afterwards.
await expect(async () => {
capturedBox = await canvas.boundingBox();
expect(capturedBox).not.toBeNull();
expect(capturedBox?.width ?? 0).toBeGreaterThan(0);
}).toPass({ timeout: 5_000 });
// TS cannot see the closure assignment above, so widen the type back out.
const box = capturedBox as { x: number; y: number; width: number; height: number } | null;
if (!box) {
throw new Error('Signature pad canvas not found');
}
await page.mouse.move(box.x + box.width * 0.15, box.y + box.height * 0.5);
await page.mouse.down();
for (let i = 0; i < 8; i++) {
await page.mouse.move(box.x + box.width * (0.15 + i * 0.09), box.y + box.height * (i % 2 === 0 ? 0.25 : 0.75), {
steps: 10,
});
}
await page.mouse.up();
};
test('[QR_SIGNATURE]: complete signing via mobile qr handoff', async ({ page, browser }) => {
const { user, team } = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: ['qr-signer@test.documenso.com'],
teamId: team.id,
fields: [FieldType.SIGNATURE],
});
const recipient = recipients[0];
await page.goto(`/sign/${recipient.token}`);
// Wait for the client-side PDF render so we know the page has hydrated
// before interacting with the signature pad.
await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR);
// Open the signature dialog and switch to the Mobile tab.
await page.getByTestId('signature-pad-dialog-button').click();
await page.getByRole('tab', { name: 'Mobile' }).click();
// Read the handoff URL rendered beneath the QR code.
await expect(page.getByTestId('signature-pad-qr-url')).toBeVisible();
const handoffUrl = await page.getByTestId('signature-pad-qr-url').textContent();
expect(handoffUrl).toContain('/mobile-signature/');
// Open the mobile page in a fully isolated browser context (no shared
// cookies or session) to prove the handoff requires no authentication.
const mobileContext = await browser.newContext();
const mobilePage = await mobileContext.newPage();
await mobilePage.goto(handoffUrl ?? '');
await expect(mobilePage.getByRole('heading', { name: 'Draw your signature' })).toBeVisible();
await drawOnSignaturePad(mobilePage);
await mobilePage.getByRole('button', { name: 'Submit' }).click();
await expect(mobilePage.getByText('Signature sent')).toBeVisible();
await mobileContext.close();
// The desktop pad should receive the signature within a poll interval.
await expect(page.getByTestId('signature-pad-qr-preview')).toBeVisible({ timeout: 10_000 });
// The session is single-use: the desktop pickup deletes the row on read, and
// a missing row is indistinguishable from an expired one by design. So a
// revisit must show the expired page (not "Signature already sent"), which
// proves the deletion happened.
const revisitContext = await browser.newContext();
const revisitPage = await revisitContext.newPage();
await revisitPage.goto(handoffUrl ?? '');
await expect(revisitPage.getByRole('heading', { name: 'This link has expired' })).toBeVisible();
await revisitContext.close();
// Direct proof of consumption: the token row must be gone from the database.
const consumedToken = (handoffUrl ?? '').split('/mobile-signature/')[1];
const consumedRow = await prisma.anonymousVerificationToken.findFirst({
where: { token: consumedToken },
});
expect(consumedRow).toBeNull();
// Confirm and finish signing the document.
await page.getByRole('button', { name: 'Next' }).click();
await page.locator('[data-field-type="SIGNATURE"]:not([data-readonly="true"])').first().click();
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`/sign/${recipient.token}/complete`);
await expect(page.getByText('Document Signed')).toBeVisible();
});
test('[QR_SIGNATURE]: mobile tab hidden when qr disabled', async ({ page }) => {
const { user, team } = await seedUser();
const { document, recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: ['qr-disabled-signer@test.documenso.com'],
teamId: team.id,
fields: [FieldType.SIGNATURE],
});
// Seeded documents create their meta row with bare column defaults, which
// leave qrSignatureEnabled true, so disable it directly on the meta row.
await prisma.documentMeta.update({
where: { id: document.documentMetaId },
data: { qrSignatureEnabled: false },
});
const recipient = recipients[0];
await page.goto(`/sign/${recipient.token}`);
await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR);
await page.getByTestId('signature-pad-dialog-button').click();
// Waiting on the Draw tab first guarantees the tab list has rendered before
// asserting the Mobile tab is absent.
await expect(page.getByRole('tab', { name: 'Draw' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Mobile' })).not.toBeVisible();
});
test('[QR_SIGNATURE]: mobile tab shown when draw disabled but qr enabled', async ({ page }) => {
const { user, team } = await seedUser();
const { document, recipients } = await seedPendingDocumentWithFullFields({
owner: user,
recipients: ['qr-only-signer@test.documenso.com'],
teamId: team.id,
fields: [FieldType.SIGNATURE],
});
// qrSignatureEnabled already defaults to true on seeded metas, but set it
// explicitly so the test still documents the required state if defaults change.
await prisma.documentMeta.update({
where: { id: document.documentMetaId },
data: { drawSignatureEnabled: false, qrSignatureEnabled: true },
});
const recipient = recipients[0];
await page.goto(`/sign/${recipient.token}`);
await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR);
await page.getByTestId('signature-pad-dialog-button').click();
await expect(page.getByRole('tab', { name: 'Mobile' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Draw' })).not.toBeVisible();
});
test('[QR_SIGNATURE]: unknown token shows expired page', async ({ page }) => {
await page.goto('/mobile-signature/this-token-does-not-exist');
await expect(page.getByRole('heading', { name: 'This link has expired' })).toBeVisible();
});
test('[QR_SIGNATURE]: expired token shows expired page', async ({ page }) => {
const expiredToken = `qr-e2e-expired-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
await prisma.anonymousVerificationToken.create({
data: {
type: AnonymousVerificationTokenType.QR_SIGNATURE,
token: expiredToken,
expiresAt: new Date(Date.now() - 60_000),
},
});
await page.goto(`/mobile-signature/${expiredToken}`);
await expect(page.getByRole('heading', { name: 'This link has expired' })).toBeVisible();
});
@@ -25,7 +25,6 @@ test('[TEAMS]: check that default team signature settings are all enabled', asyn
await expect(page.getByRole('combobox').filter({ hasText: 'Type' })).toBeVisible();
await expect(page.getByRole('combobox').filter({ hasText: 'Upload' })).toBeVisible();
await expect(page.getByRole('combobox').filter({ hasText: 'Draw' })).toBeVisible();
await expect(page.getByRole('combobox').filter({ hasText: 'QR code' })).toBeVisible();
// Go to document and check that the signatured tabs are correct.
await page.goto(`/sign/${document.recipients[0].token}`);
@@ -35,7 +34,6 @@ test('[TEAMS]: check that default team signature settings are all enabled', asyn
await expect(page.getByRole('tab', { name: 'Type' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Upload' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Draw' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Mobile' })).toBeVisible();
});
test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
@@ -47,11 +45,8 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
redirectPath: `/t/${team.url}/settings/document`,
});
// The 'QR code' signature type is surfaced as the 'Mobile' tab on the signing dialog.
const allSignatureOptions = ['Type', 'Upload', 'Draw', 'QR code'];
const tabNameForOption = (option: string) => (option === 'QR code' ? 'Mobile' : option);
const tabTest = [['Type', 'Upload', 'Draw', 'QR code'], ['Type', 'Upload'], ['Type']];
const allTabs = ['Type', 'Upload', 'Draw'];
const tabTest = [['Type', 'Upload', 'Draw'], ['Type', 'Upload'], ['Type']];
for (const tabs of tabTest) {
await page.goto(`/t/${team.url}/settings/document`);
@@ -62,10 +57,9 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
await expect(page.getByRole('option', { name: 'Type' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Draw' })).toBeVisible();
await expect(page.getByRole('option', { name: 'QR code' })).toBeVisible();
// Clear all selected items.
for (const tab of allSignatureOptions) {
for (const tab of allTabs) {
const item = page.getByRole('option', { name: tab });
const isSelected = (await item.innerHTML()).includes('opacity-100');
@@ -96,13 +90,12 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
await page.waitForSelector('[role="dialog"]');
// Check the tab values
for (const option of allSignatureOptions) {
const tabName = tabNameForOption(option);
if (tabs.includes(option)) {
await expect(page.getByRole('tab', { name: tabName })).toBeVisible();
for (const tab of allTabs) {
if (tabs.includes(tab)) {
await expect(page.getByRole('tab', { name: tab })).toBeVisible();
} else {
await expect(page.getByRole('tab', { name: tabName })).toHaveCount(0);
// await expect(page.getByRole('tab', { name: tab })).not.toBeVisible();
await expect(page.getByRole('tab', { name: tab })).toHaveCount(0);
}
}
}
@@ -117,8 +110,8 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
redirectPath: `/t/${team.url}/settings/document`,
});
const allSignatureOptions = ['Type', 'Upload', 'Draw', 'QR code'];
const tabTest = [['Type', 'Upload', 'Draw', 'QR code'], ['Type', 'Upload'], ['Type']];
const allTabs = ['Type', 'Upload', 'Draw'];
const tabTest = [['Type', 'Upload', 'Draw'], ['Type', 'Upload'], ['Type']];
for (const tabs of tabTest) {
await page.goto(`/t/${team.url}/settings/document`);
@@ -129,10 +122,9 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
await expect(page.getByRole('option', { name: 'Type' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Draw' })).toBeVisible();
await expect(page.getByRole('option', { name: 'QR code' })).toBeVisible();
// Clear all selected items.
for (const tab of allSignatureOptions) {
for (const tab of allTabs) {
const item = page.getByRole('option', { name: tab });
const isSelected = (await item.innerHTML()).includes('opacity-100');
@@ -184,6 +176,5 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
expect(document?.documentMeta?.typedSignatureEnabled).toEqual(tabs.includes('Type'));
expect(document?.documentMeta?.uploadSignatureEnabled).toEqual(tabs.includes('Upload'));
expect(document?.documentMeta?.drawSignatureEnabled).toEqual(tabs.includes('Draw'));
expect(document?.documentMeta?.qrSignatureEnabled).toEqual(tabs.includes('QR code'));
}
});
@@ -152,7 +152,6 @@ test.describe('AutoSave Settings Step - Templates', () => {
expect(retrievedTemplate.templateMeta?.drawSignatureEnabled).toBe(false);
expect(retrievedTemplate.templateMeta?.typedSignatureEnabled).toBe(false);
expect(retrievedTemplate.templateMeta?.uploadSignatureEnabled).toBe(true);
expect(retrievedTemplate.templateMeta?.qrSignatureEnabled).toBe(true);
}).toPass();
});
@@ -197,18 +197,7 @@ test('[DIRECT_TEMPLATES]: V1 direct template link auth access', async ({ page })
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByLabel('Email')).toBeDisabled();
const directSignatureField = directTemplateWithAuth.fields[0];
if (!directSignatureField) {
throw new Error('Expected seeded direct template signature field to exist');
}
await page.getByRole('button', { name: 'Continue' }).click();
await signSignaturePad(page);
await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click();
await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true');
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
@@ -246,37 +235,6 @@ test('[DIRECT_TEMPLATES]: V2 direct template link auth access', async ({ page })
await page.goto(directTemplatePath);
await expect(page.getByRole('heading', { name: 'Personal direct template link' })).toBeVisible();
const directSignatureField = directTemplateWithAuth.fields[0];
if (!directSignatureField) {
throw new Error('Expected seeded direct template signature field to exist');
}
// Wait for the PDF and the Konva canvas overlay to be ready.
await expect(page.locator('img[data-page-number]').first()).toBeVisible({ timeout: 30_000 });
const canvas = page.locator('.konva-container canvas').first();
await expect(canvas).toBeVisible({ timeout: 30_000 });
// Sign the direct template recipient's signature field via the canvas-based V2 UI.
await signSignaturePad(page);
const canvasBox = await canvas.boundingBox();
if (!canvasBox) {
throw new Error('Canvas bounding box not found');
}
const x =
(Number(directSignatureField.positionX) / 100) * canvasBox.width +
((Number(directSignatureField.width) / 100) * canvasBox.width) / 2;
const y =
(Number(directSignatureField.positionY) / 100) * canvasBox.height +
((Number(directSignatureField.height) / 100) * canvasBox.height) / 2;
await canvas.click({ position: { x, y } });
await expect(page.getByText('0 Fields Remaining').first()).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByLabel('Your Email')).not.toBeVisible();
@@ -308,16 +266,6 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p
await expect(page.getByText('Next Recipient Name')).not.toBeVisible();
const directSignatureField = template.fields[0];
if (!directSignatureField) {
throw new Error('Expected seeded direct template signature field to exist');
}
await signSignaturePad(page);
await page.locator(`#field-${directSignatureField.id}`).getByRole('button').click();
await expect(page.locator(`#field-${directSignatureField.id}`)).toHaveAttribute('data-inserted', 'true');
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
@@ -351,13 +299,19 @@ test('[DIRECT_TEMPLATES]: V1 use direct template link with 2 recipients with nex
},
});
// The seeded direct template already includes a signature field for the direct recipient.
const directSignatureField = template.fields[0];
const directTemplateRecipient = template.recipients[0];
if (!directSignatureField) {
throw new Error('Expected seeded direct template signature field to exist');
if (!directTemplateRecipient) {
throw new Error('Expected direct template recipient to exist');
}
// All SIGNER recipients need a signature field for sendDocument to dispatch emails.
const directSignatureField = await seedSignatureFieldForRecipient({
envelopeId: template.id,
recipientId: directTemplateRecipient.id,
positionY: 10,
});
const originalName = 'Signer 2';
const originalSecondSignerEmail = seedTestEmail();
@@ -459,13 +413,19 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex
},
});
// The seeded direct template already includes a signature field for the direct recipient.
const directSignatureField = template.fields[0];
const directTemplateRecipient = template.recipients[0];
if (!directSignatureField) {
throw new Error('Expected seeded direct template signature field to exist');
if (!directTemplateRecipient) {
throw new Error('Expected direct template recipient to exist');
}
// All SIGNER recipients need a signature field for sendDocument to dispatch emails.
const directSignatureField = await seedSignatureFieldForRecipient({
envelopeId: template.id,
recipientId: directTemplateRecipient.id,
positionY: 10,
});
const originalName = 'Signer 2';
const originalSecondSignerEmail = seedTestEmail();
@@ -561,48 +521,3 @@ test('[DIRECT_TEMPLATES]: V2 use direct template link with 2 recipients with nex
expect(updatedSecondRecipient.email).toBe(newSecondSignerEmail);
await expectSigningRequestJobForRecipient(updatedSecondRecipient.id);
});
test('[DIRECT_TEMPLATES]: V1 direct template without signature fields shows invalid template page', async ({
page,
}) => {
const { user, team } = await seedUser();
const template = await seedDirectTemplate({
title: 'V1 invalid direct template',
userId: user.id,
teamId: team.id,
createDirectRecipientSignatureField: false,
});
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'Invalid direct link template' })).toBeVisible();
await expect(page.getByText('This direct link template cannot be used because one or more signers')).toBeVisible();
// The signing flow must not render.
await expect(page.getByRole('heading', { name: 'General' })).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Continue' })).not.toBeVisible();
});
test('[DIRECT_TEMPLATES]: V2 direct template without signature fields shows invalid template page', async ({
page,
}) => {
const { user, team } = await seedUser();
const template = await seedDirectTemplate({
title: 'V2 invalid direct template',
userId: user.id,
teamId: team.id,
internalVersion: 2,
createDirectRecipientSignatureField: false,
});
await page.goto(formatDirectTemplatePath(template.directLink?.token || ''));
await expect(page.getByRole('heading', { name: 'Invalid direct link template' })).toBeVisible();
await expect(page.getByText('This direct link template cannot be used because one or more signers')).toBeVisible();
// The signing flow (PDF canvas) must not render.
await expect(page.locator('.konva-container canvas')).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Complete' })).not.toBeVisible();
});
@@ -1,101 +0,0 @@
import { FIELD_SIGNATURE_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
import { prisma } from '@documenso/prisma';
import { seedTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { expect, test } from '@playwright/test';
import { DocumentStatus, FieldType } from '@prisma/client';
import { apiSignin } from '../fixtures/authentication';
import { expectToastTextToBeVisible } from '../fixtures/generic';
const seedSignatureFieldForRecipient = async (options: { envelopeId: string; recipientId: number }) => {
const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({
where: { envelopeId: options.envelopeId },
});
return await prisma.field.create({
data: {
envelopeId: options.envelopeId,
envelopeItemId: envelopeItem.id,
recipientId: options.recipientId,
type: FieldType.SIGNATURE,
page: 1,
positionX: 5,
positionY: 10,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: FIELD_SIGNATURE_META_DEFAULT_VALUES,
},
});
};
test('[TEMPLATE_USE]: shows missing signature fields error when sending a template without signature fields', async ({
page,
}) => {
const { user, team } = await seedUser();
// seedTemplate creates one SIGNER recipient and no fields.
await seedTemplate({
title: 'Template missing signature fields',
userId: user.id,
teamId: team.id,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/templates`,
});
await page.getByRole('button', { name: 'Use Template' }).click();
await expect(page.getByRole('heading', { name: 'Create document from template' })).toBeVisible();
// Enable distribution so the document is sent on creation.
await page.locator('#distributeDocument').click();
await page.getByRole('button', { name: 'Create and send' }).click();
await expectToastTextToBeVisible(page, 'Missing signature fields');
await expectToastTextToBeVisible(
page,
'The document could not be sent because some signers do not have a signature field',
);
});
test('[TEMPLATE_USE]: creates and sends a document when signers have signature fields', async ({ page }) => {
const { user, team } = await seedUser();
const template = await seedTemplate({
title: 'Template with signature fields',
userId: user.id,
teamId: team.id,
});
await seedSignatureFieldForRecipient({
envelopeId: template.id,
recipientId: template.recipients[0].id,
});
await apiSignin({
page,
email: user.email,
redirectPath: `/t/${team.url}/templates`,
});
await page.getByRole('button', { name: 'Use Template' }).click();
await expect(page.getByRole('heading', { name: 'Create document from template' })).toBeVisible();
await page.locator('#distributeDocument').click();
await page.getByRole('button', { name: 'Create and send' }).click();
await page.waitForURL(new RegExp(`/t/${team.url}/documents/envelope_.*`));
const envelopeId = page.url().split('/').pop()?.split('?')[0];
const envelope = await prisma.envelope.findFirstOrThrow({
where: { id: envelopeId },
});
expect(envelope.status).toBe(DocumentStatus.PENDING);
});
-7
View File
@@ -77,11 +77,4 @@ export const DOCUMENT_SIGNATURE_TYPES = {
}),
value: DocumentSignatureType.UPLOAD,
},
[DocumentSignatureType.QR]: {
label: msg({
message: `QR code`,
context: `Sign using a mobile phone via QR code`,
}),
value: DocumentSignatureType.QR,
},
} satisfies Record<DocumentSignatureType, DocumentSignatureTypeData>;
-2
View File
@@ -2,5 +2,3 @@ export const SIGNATURE_CANVAS_DPI = 2;
export const SIGNATURE_MIN_COVERAGE_THRESHOLD = 0.01;
export const isBase64Image = (value: string) => value.startsWith('data:image/png;base64,');
export const QR_SIGNATURE_TOKEN_EXPIRY_MINUTES = 10;
-9
View File
@@ -36,13 +36,6 @@ export enum AppErrorCode {
*/
ENVELOPE_TSP_LOCKED = 'ENVELOPE_TSP_LOCKED',
/**
* A signer recipient does not have a signature field assigned. Thrown when
* distributing an envelope or using a direct template where at least one
* signer has no signature field.
*/
MISSING_SIGNATURE_FIELD = 'MISSING_SIGNATURE_FIELD',
/**
* CSC (Cloud Signature Consortium) error codes. See the CSC QES V1 spec
* for the recovery taxonomy.
@@ -91,7 +84,6 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
[AppErrorCode.ENVELOPE_CANCELLED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_TSP_LOCKED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.MISSING_SIGNATURE_FIELD]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_INSTANCE_MODE_MISMATCH]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.CSC_UNLICENSED]: { code: 'FORBIDDEN', status: 403 },
[AppErrorCode.CSC_PROVIDER_INFO_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
@@ -299,7 +291,6 @@ export class AppError extends Error {
AppErrorCode.ENVELOPE_CANCELLED,
AppErrorCode.ENVELOPE_LEGACY,
AppErrorCode.ENVELOPE_TSP_LOCKED,
AppErrorCode.MISSING_SIGNATURE_FIELD,
AppErrorCode.CSC_INSTANCE_MODE_MISMATCH,
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
AppErrorCode.CSC_CERT_INVALID,
-2
View File
@@ -20,7 +20,6 @@ import { ADMIN_DELETE_ORGANISATION_JOB_DEFINITION } from './definitions/internal
import { BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION } from './definitions/internal/backport-subscription-claims';
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
import { CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION } from './definitions/internal/cancel-organisation-subscription';
import { CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION } from './definitions/internal/cleanup-anonymous-tokens';
import { CLEANUP_RATE_LIMITS_JOB_DEFINITION } from './definitions/internal/cleanup-rate-limits';
import { EXECUTE_WEBHOOK_JOB_DEFINITION } from './definitions/internal/execute-webhook';
import { EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION } from './definitions/internal/expire-recipients-sweep';
@@ -63,7 +62,6 @@ export const jobsClient = new JobClient([
SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION,
PROCESS_SIGNING_REMINDER_JOB_DEFINITION,
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION,
SYNC_EMAIL_DOMAINS_JOB_DEFINITION,
ADMIN_DELETE_ORGANISATION_JOB_DEFINITION,
CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION,
@@ -1,5 +1,4 @@
import { prisma } from '@documenso/prisma';
import { AnonymousVerificationTokenType } from '@prisma/client';
import { generateAuthenticationOptions } from '@simplewebauthn/server';
import { DateTime } from 'luxon';
@@ -25,14 +24,12 @@ export const createPasskeySigninOptions = async ({ sessionId }: CreatePasskeySig
id: sessionId,
},
update: {
type: AnonymousVerificationTokenType.PASSKEY,
token: challenge,
expiresAt: DateTime.now().plus({ minutes: 2 }).toJSDate(),
createdAt: new Date(),
},
create: {
id: sessionId,
type: AnonymousVerificationTokenType.PASSKEY,
token: challenge,
expiresAt: DateTime.now().plus({ minutes: 2 }).toJSDate(),
createdAt: new Date(),
@@ -31,7 +31,6 @@ export type CreateDocumentMetaOptions = {
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
qrSignatureEnabled?: boolean;
language?: SupportedLanguageCodes;
requestMetadata: ApiRequestMetadata;
};
@@ -54,7 +53,6 @@ export const updateDocumentMeta = async ({
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
qrSignatureEnabled,
language,
requestMetadata,
}: CreateDocumentMetaOptions) => {
@@ -134,7 +132,6 @@ export const updateDocumentMeta = async ({
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
qrSignatureEnabled,
language,
},
});
@@ -194,7 +194,7 @@ export const sendDocument = async ({ id, userId, teamId, sendEmail, requestMetad
.map((r) => (r.name ? `${r.name} (${r.email}, id: ${r.id})` : `${r.email} (id: ${r.id})`))
.join(', ');
throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: `The following recipients are missing required fields: ${missingRecipientDescriptions}. Signers must have at least one signature field.`,
});
}
@@ -5,7 +5,6 @@ import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DocumentAccessAuth, type TDocumentAuthMethods } from '../../types/document-auth';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { getRecipientsWithMissingFields } from '../../utils/recipients';
import { extractFieldAutoInsertValues } from '../document/send-document';
import { getTeamSettings } from '../team/get-team-settings';
import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
@@ -126,17 +125,6 @@ export const getEnvelopeForDirectTemplateSigning = async ({
});
}
const recipientsWithMissingFields = getRecipientsWithMissingFields(
envelope.recipients,
envelope.recipients.flatMap((envelopeRecipient) => envelopeRecipient.fields),
);
if (recipientsWithMissingFields.length > 0) {
throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, {
message: 'One or more signers on this direct template are missing a signature field',
});
}
const settings = await getTeamSettings({ teamId: envelope.teamId });
const sender = settings.includeSenderDetails
@@ -47,7 +47,6 @@ export const ZEnvelopeForSigningResponse = z.object({
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
qrSignatureEnabled: true,
allowDictateNextSigner: true,
language: true,
}),
@@ -84,13 +84,13 @@ export const syncSubscriptionRateLimit = createRateLimit({
export const apiV1RateLimit = createRateLimit({
action: 'api.v1',
max: 1000,
max: 100,
window: '1m',
});
export const apiV2RateLimit = createRateLimit({
action: 'api.v2',
max: 1000,
max: 100,
window: '1m',
});
@@ -40,7 +40,6 @@ import {
extractDocumentAuthMethods,
} from '../../utils/document-auth';
import { mapSecondaryIdToTemplateId } from '../../utils/envelope';
import { getRecipientsWithMissingFields } from '../../utils/recipients';
import { sendDocument } from '../document/send-document';
import { validateFieldAuth } from '../document/validate-field-auth';
import { incrementDocumentId } from '../envelope/increment-id';
@@ -173,17 +172,6 @@ export const createDocumentFromDirectTemplate = async ({
});
}
const recipientsWithMissingFields = getRecipientsWithMissingFields(
recipients,
recipients.flatMap((recipient) => recipient.fields),
);
if (recipientsWithMissingFields.length > 0) {
throw new AppError(AppErrorCode.MISSING_SIGNATURE_FIELD, {
message: 'One or more signers on this direct template are missing a signature field',
});
}
if (directTemplateEnvelope.updatedAt.getTime() !== templateUpdatedAt.getTime()) {
throw new AppError(AppErrorCode.INVALID_REQUEST, { message: 'Template no longer matches' });
}
@@ -112,7 +112,6 @@ export type CreateDocumentFromTemplateOptions = {
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
qrSignatureEnabled?: boolean;
envelopeExpirationPeriod?: TEnvelopeExpirationPeriod | null;
};
@@ -541,7 +540,6 @@ export const createDocumentFromTemplate = async ({
typedSignatureEnabled: override?.typedSignatureEnabled ?? template.documentMeta?.typedSignatureEnabled,
uploadSignatureEnabled: override?.uploadSignatureEnabled ?? template.documentMeta?.uploadSignatureEnabled,
drawSignatureEnabled: override?.drawSignatureEnabled ?? template.documentMeta?.drawSignatureEnabled,
qrSignatureEnabled: override?.qrSignatureEnabled ?? template.documentMeta?.qrSignatureEnabled,
allowDictateNextSigner: override?.allowDictateNextSigner ?? template.documentMeta?.allowDictateNextSigner,
envelopeExpirationPeriod: override?.envelopeExpirationPeriod ?? template.documentMeta?.envelopeExpirationPeriod,
},
@@ -46,7 +46,6 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
qrSignatureEnabled: true,
language: 'en',
distributionMethod: DocumentDistributionMethod.EMAIL,
emailSettings: null,
-6
View File
@@ -28,7 +28,6 @@ export const ZDocumentMetaSchema = DocumentMetaSchema.pick({
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
qrSignatureEnabled: true,
language: true,
emailSettings: true,
});
@@ -106,10 +105,6 @@ export const ZDocumentMetaUploadSignatureEnabledSchema = z
.boolean()
.describe('Whether to allow recipients to sign using an uploaded signature.');
export const ZDocumentMetaQrSignatureEnabledSchema = z
.boolean()
.describe('Whether to allow recipients to sign using a QR code handoff to a mobile device.');
/**
* 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,7 +123,6 @@ export const ZDocumentMetaCreateSchema = z.object({
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
emailId: z.string().nullish(),
emailReplyTo: zEmail().nullish(),
emailSettings: ZDocumentEmailSettingsSchema.nullish(),
-1
View File
@@ -62,7 +62,6 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
qrSignatureEnabled: true,
allowDictateNextSigner: true,
language: true,
emailSettings: true,
-1
View File
@@ -279,7 +279,6 @@ export const ZEditorEnvelopeSchema = EnvelopeSchema.pick({
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
qrSignatureEnabled: true,
allowDictateNextSigner: true,
language: true,
emailSettings: true,
-1
View File
@@ -49,7 +49,6 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
qrSignatureEnabled: true,
allowDictateNextSigner: true,
language: true,
emailSettings: true,
-1
View File
@@ -54,7 +54,6 @@ export const ZTemplateSchema = TemplateSchema.pick({
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
qrSignatureEnabled: true,
allowDictateNextSigner: true,
distributionMethod: true,
redirectUrl: true,
-1
View File
@@ -55,7 +55,6 @@ export const ZWebhookDocumentMetaSchema = z.object({
typedSignatureEnabled: z.boolean(),
uploadSignatureEnabled: z.boolean(),
drawSignatureEnabled: z.boolean(),
qrSignatureEnabled: z.boolean(),
language: z.string(),
distributionMethod: z.nativeEnum(DocumentDistributionMethod),
emailSettings: z.any().nullable(),
-1
View File
@@ -59,7 +59,6 @@ export const extractDerivedDocumentMeta = (
typedSignatureEnabled: meta.typedSignatureEnabled ?? settings.typedSignatureEnabled,
uploadSignatureEnabled: meta.uploadSignatureEnabled ?? settings.uploadSignatureEnabled,
drawSignatureEnabled: meta.drawSignatureEnabled ?? settings.drawSignatureEnabled,
qrSignatureEnabled: meta.qrSignatureEnabled ?? settings.qrSignatureEnabled,
// Email settings.
emailId: meta.emailId ?? settings.emailId,
-1
View File
@@ -119,7 +119,6 @@ export const generateDefaultOrganisationSettings = (): Omit<OrganisationGlobalSe
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
qrSignatureEnabled: true,
brandingEnabled: false,
brandingLogo: '',
+1 -13
View File
@@ -17,7 +17,6 @@ export enum DocumentSignatureType {
DRAW = 'draw',
TYPE = 'type',
UPLOAD = 'upload',
QR = 'qr',
}
export const formatTeamUrl = (teamUrl: string, baseUrl?: string) => {
@@ -94,16 +93,10 @@ export const extractTeamSignatureSettings = (
typedSignatureEnabled: boolean | null;
drawSignatureEnabled: boolean | null;
uploadSignatureEnabled: boolean | null;
qrSignatureEnabled: boolean | null;
} | null,
) => {
if (!settings) {
return [
DocumentSignatureType.TYPE,
DocumentSignatureType.UPLOAD,
DocumentSignatureType.DRAW,
DocumentSignatureType.QR,
];
return [DocumentSignatureType.TYPE, DocumentSignatureType.UPLOAD, DocumentSignatureType.DRAW];
}
const signatureTypes: DocumentSignatureType[] = [];
@@ -120,10 +113,6 @@ export const extractTeamSignatureSettings = (
signatureTypes.push(DocumentSignatureType.UPLOAD);
}
if (settings.qrSignatureEnabled) {
signatureTypes.push(DocumentSignatureType.QR);
}
return signatureTypes;
};
@@ -197,7 +186,6 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
typedSignatureEnabled: null,
uploadSignatureEnabled: null,
drawSignatureEnabled: null,
qrSignatureEnabled: null,
brandingEnabled: null,
brandingLogo: null,
+2 -14
View File
@@ -144,18 +144,9 @@ model Passkey {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
enum AnonymousVerificationTokenType {
PASSKEY
QR_SIGNATURE
}
model AnonymousVerificationToken {
id String @id @unique @default(cuid())
type AnonymousVerificationTokenType
token String @unique
value String?
metadata Json?
id String @id @unique @default(cuid())
token String @unique
expiresAt DateTime
createdAt DateTime @default(now())
}
@@ -579,7 +570,6 @@ model DocumentMeta {
typedSignatureEnabled Boolean @default(true)
uploadSignatureEnabled Boolean @default(true)
drawSignatureEnabled Boolean @default(true)
qrSignatureEnabled Boolean @default(true)
language String @default("en")
distributionMethod DocumentDistributionMethod @default(EMAIL)
@@ -979,7 +969,6 @@ model OrganisationGlobalSettings {
typedSignatureEnabled Boolean @default(true)
uploadSignatureEnabled Boolean @default(true)
drawSignatureEnabled Boolean @default(true)
qrSignatureEnabled Boolean @default(true)
defaultRecipients Json? /// [DefaultRecipient[]] @zod.custom.use(ZDefaultRecipientsSchema)
@@ -1023,7 +1012,6 @@ model TeamGlobalSettings {
typedSignatureEnabled Boolean?
uploadSignatureEnabled Boolean?
drawSignatureEnabled Boolean?
qrSignatureEnabled Boolean?
defaultRecipients Json? /// [DefaultRecipient[]] @zod.custom.use(ZDefaultRecipientsSchema)
+3 -3
View File
@@ -361,9 +361,9 @@ export const seedAlignmentTestDocument = async ({
const { id, recipients, envelopeItems } = createdEnvelope;
if (isDirectTemplate) {
const directTemplateRecipient = recipients.find((recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL);
const directTemplateRecpient = recipients.find((recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL);
if (!directTemplateRecipient) {
if (!directTemplateRecpient) {
throw new Error('Need to create a direct template recipient');
}
@@ -372,7 +372,7 @@ export const seedAlignmentTestDocument = async ({
envelopeId: id,
enabled: true,
token: directTemplateToken ?? Math.random().toString(),
directTemplateRecipientId: directTemplateRecipient.id,
directTemplateRecipientId: directTemplateRecpient.id,
},
});
}
+3 -35
View File
@@ -6,7 +6,6 @@ import {
DIRECT_TEMPLATE_RECIPIENT_NAME,
} from '@documenso/lib/constants/direct-templates';
import { incrementTemplateId } from '@documenso/lib/server-only/envelope/increment-id';
import { FIELD_SIGNATURE_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
import { SignatureLevel } from '@documenso/lib/types/signature-level';
import { prefixedId } from '@documenso/lib/universal/id';
@@ -16,7 +15,6 @@ import {
DocumentDataType,
DocumentSource,
EnvelopeType,
FieldType,
ReadStatus,
RecipientRole,
SendStatus,
@@ -31,11 +29,6 @@ type SeedTemplateOptions = {
teamId: number;
internalVersion?: 1 | 2;
createTemplateOptions?: Partial<Prisma.EnvelopeUncheckedCreateInput>;
/**
* Only used by seedDirectTemplate. Creates a signature field for the direct
* recipient so the seeded direct template is valid. Defaults to true.
*/
createDirectRecipientSignatureField?: boolean;
};
type CreateTemplateOptions = {
@@ -205,11 +198,11 @@ export const seedDirectTemplate = async (options: SeedTemplateOptions) => {
},
});
const directTemplateRecipient = template.recipients.find(
const directTemplateRecpient = template.recipients.find(
(recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL,
);
if (!directTemplateRecipient) {
if (!directTemplateRecpient) {
throw new Error('Need to create a direct template recipient');
}
@@ -218,35 +211,10 @@ export const seedDirectTemplate = async (options: SeedTemplateOptions) => {
envelopeId: template.id,
enabled: true,
token: Math.random().toString(),
directTemplateRecipientId: directTemplateRecipient.id,
directTemplateRecipientId: directTemplateRecpient.id,
},
});
const { createDirectRecipientSignatureField = true } = options;
if (createDirectRecipientSignatureField) {
const envelopeItem = await prisma.envelopeItem.findFirstOrThrow({
where: { envelopeId: template.id },
});
await prisma.field.create({
data: {
envelopeId: template.id,
envelopeItemId: envelopeItem.id,
recipientId: directTemplateRecipient.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 5,
positionY: 10,
width: 20,
height: 5,
customText: '',
inserted: false,
fieldMeta: FIELD_SIGNATURE_META_DEFAULT_VALUES,
},
});
}
return await prisma.envelope.findFirstOrThrow({
where: {
id: template.id,
@@ -5,7 +5,6 @@ import {
ZDocumentMetaDrawSignatureEnabledSchema,
ZDocumentMetaLanguageSchema,
ZDocumentMetaMessageSchema,
ZDocumentMetaQrSignatureEnabledSchema,
ZDocumentMetaRedirectUrlSchema,
ZDocumentMetaSubjectSchema,
ZDocumentMetaTimezoneSchema,
@@ -66,7 +65,6 @@ export const ZCreateEmbeddingDocumentRequestSchema = z.object({
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
})
.optional(),
@@ -5,7 +5,6 @@ import {
ZDocumentMetaDrawSignatureEnabledSchema,
ZDocumentMetaLanguageSchema,
ZDocumentMetaMessageSchema,
ZDocumentMetaQrSignatureEnabledSchema,
ZDocumentMetaRedirectUrlSchema,
ZDocumentMetaSubjectSchema,
ZDocumentMetaTimezoneSchema,
@@ -63,7 +62,6 @@ export const ZCreateEmbeddingTemplateRequestSchema = z.object({
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
})
.optional(),
@@ -30,7 +30,6 @@ export const ZGetMultiSignDocumentResponseSchema = ZDocumentLiteSchema.extend({
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
qrSignatureEnabled: true,
allowDictateNextSigner: true,
language: true,
emailSettings: true,
@@ -5,7 +5,6 @@ import {
ZDocumentMetaDrawSignatureEnabledSchema,
ZDocumentMetaLanguageSchema,
ZDocumentMetaMessageSchema,
ZDocumentMetaQrSignatureEnabledSchema,
ZDocumentMetaRedirectUrlSchema,
ZDocumentMetaSubjectSchema,
ZDocumentMetaTimezoneSchema,
@@ -67,7 +66,6 @@ export const ZUpdateEmbeddingDocumentRequestSchema = z.object({
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
})
.optional(),
@@ -5,7 +5,6 @@ import {
ZDocumentMetaDrawSignatureEnabledSchema,
ZDocumentMetaLanguageSchema,
ZDocumentMetaMessageSchema,
ZDocumentMetaQrSignatureEnabledSchema,
ZDocumentMetaRedirectUrlSchema,
ZDocumentMetaSubjectSchema,
ZDocumentMetaTimezoneSchema,
@@ -67,7 +66,6 @@ export const ZUpdateEmbeddingTemplateRequestSchema = z.object({
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
})
.optional(),
@@ -7,7 +7,6 @@ import {
ZDocumentMetaDrawSignatureEnabledSchema,
ZDocumentMetaLanguageSchema,
ZDocumentMetaMessageSchema,
ZDocumentMetaQrSignatureEnabledSchema,
ZDocumentMetaRedirectUrlSchema,
ZDocumentMetaSubjectSchema,
ZDocumentMetaTimezoneSchema,
@@ -95,7 +94,6 @@ export const ZUseEnvelopePayloadSchema = z.object({
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
allowDictateNextSigner: z.boolean().optional(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
})
@@ -37,7 +37,6 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
qrSignatureEnabled,
defaultRecipients,
delegateDocumentOwnership,
envelopeExpirationPeriod,
@@ -105,7 +104,6 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
uploadSignatureEnabled ?? organisation.organisationGlobalSettings.uploadSignatureEnabled;
const derivedDrawSignatureEnabled =
drawSignatureEnabled ?? organisation.organisationGlobalSettings.drawSignatureEnabled;
const derivedQrSignatureEnabled = qrSignatureEnabled ?? organisation.organisationGlobalSettings.qrSignatureEnabled;
const derivedDelegateDocumentOwnership =
delegateDocumentOwnership ?? organisation.organisationGlobalSettings.delegateDocumentOwnership;
@@ -113,8 +111,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
if (
derivedTypedSignatureEnabled === false &&
derivedUploadSignatureEnabled === false &&
derivedDrawSignatureEnabled === false &&
derivedQrSignatureEnabled === false
derivedDrawSignatureEnabled === false
) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'At least one signature type must be enabled',
@@ -169,7 +166,6 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
qrSignatureEnabled,
defaultRecipients: defaultRecipients === null ? Prisma.DbNull : defaultRecipients,
delegateDocumentOwnership: derivedDelegateDocumentOwnership,
envelopeExpirationPeriod: envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod,
@@ -25,7 +25,6 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
typedSignatureEnabled: z.boolean().optional(),
uploadSignatureEnabled: z.boolean().optional(),
drawSignatureEnabled: z.boolean().optional(),
qrSignatureEnabled: z.boolean().optional(),
defaultRecipients: ZDefaultRecipientsSchema.nullish(),
delegateDocumentOwnership: z.boolean().nullish(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.optional(),

Some files were not shown because too many files have changed in this diff Show More