Compare commits

..
Author SHA1 Message Date
Catalin Pit 0557ce3dce Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-08-17 10:51:48 +03:00
Catalin Pit 6b8eb9fc6a Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-07-16 08:43:44 +03:00
Catalin Pit 12d44e1c59 Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-07-08 10:34:25 +03:00
Catalin Pit 9dc66afc7b Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-07-06 09:44:38 +03:00
Catalin Pit 7a13be6bf2 Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-06-29 10:10:49 +03:00
Catalin Pit 1032028395 Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-06-25 09:02:29 +03:00
Catalin Pit 2396d0d5d0 refactor: improve logger for wrong pdf placeholders 2026-06-19 13:26:54 +03:00
Catalin Pit dac262edc9 chore: add comment 2026-06-19 10:36:48 +03:00
Catalin Pit 25eb4ffedf refactor: simplify placeholder key-value parsing logic in PDF helpers 2026-06-19 08:43:22 +03:00
Catalin Pit ee0ea82635 chore: add tests 2026-06-18 15:06:15 +03:00
Catalin Pit 5f7f6698fd refactor: improve error handling and validation for PDF field metadata parsing 2026-06-18 14:03:21 +03:00
Catalin Pit 402809c809 refactor: add detailed docs for placeholder string parsing functions 2026-06-18 11:52:27 +03:00
Catalin Pit 733e273c05 Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-06-18 09:32:18 +03:00
Catalin Pit 7c7933c2d4 Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-06-17 11:50:50 +03:00
Catalin Pit bb120d65dd Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-05-27 12:50:34 +03:00
Catalin Pit 9c14aa6297 Merge branch 'main' into feature/pdf-placeholder-selection-fields 2026-05-26 08:54:20 +03:00
Catalin Pit 6387e809a8 chore: merged main 2026-05-26 08:51:26 +03:00
Catalin Pit 5b2b1591a4 feat: enhance placeholder parsing and validation for selection fields in PDF helpers 2026-05-26 08:45:57 +03:00
Catalin Pit e5cb4c6bfd chore: refine dropdown placeholder support in PDF fields documentation and implementation 2026-05-22 11:27:18 +03:00
Catalin Pit 8185f916d3 chore: update plan and documentation 2026-05-22 09:00:46 +03:00
Catalin Pit c308dbf257 feat: support selection field options in PDF placeholders 2026-05-08 12:11:06 +03:00
107 changed files with 925 additions and 1704 deletions
@@ -0,0 +1,222 @@
---
date: 2026-05-07
title: Pdf Placeholder Selection Fields
---
## Summary
Extend PDF placeholders so radio and dropdown fields can be configured from the existing Documenso placeholder syntax:
```text
{{FIELD_TYPE, RECIPIENT, key=value, key=value}}
```
Do not introduce a new delimiter style. Existing applications may already generate placeholders in this format, so the new selection-field behavior should fit into it.
## Goals
- Keep the current placeholder grammar unchanged.
- Support checkbox placeholders with option lists, checked values, validation, direction, required, read-only, and font size.
- Support radio placeholders with option lists, default/preselected values, direction, required, read-only, and font size.
- Support dropdown placeholders with option lists, default value, required, read-only, and font size.
- Use `options` as the only public list key in PDF placeholders.
- Convert `options` into internal `fieldMeta.values` during parsing.
- Make generated fields usable immediately in the editor, signing UI, preview renderer, and final PDF export.
## Non-Goals
- No semicolon placeholder syntax.
- No `values` alias in PDF placeholder syntax.
- No database migration.
- No behavior change for existing placeholders such as `{{text, r1, required=true}}`.
## Placeholder Syntax
Use the existing comma-separated placeholder format:
```text
{{checkbox, r1, options=Email|SMS|Phone, checked=Email|Phone, validationRule=atLeast, validationLength=1}}
{{radio, r1, options=Card|Bank transfer|Check, defaultValue=Check}}
{{radio, r1, options=Basic|Pro|Enterprise, selected=Pro, direction=horizontal}}
{{dropdown, r1, options=United States|Canada|United Kingdom}}
{{dropdown, r2, options=Sales|Legal|Finance, defaultValue=Legal}}
```
Use `|` inside `options` because `,` is already the top-level placeholder delimiter.
Parsing rules:
- Split top-level placeholder tokens on unescaped commas.
- Split metadata tokens on the first unescaped equals sign.
- Split `options` on unescaped pipes.
- Trim option values and drop empty values.
- Preserve option order.
- Support escaped delimiters: `\,`, `\=`, and `\|`.
- Treat field type values case-insensitively.
## Field Type Mapping
- `checkbox` maps to `FieldType.CHECKBOX`.
- `radio` maps to `FieldType.RADIO`.
- `dropdown` maps to `FieldType.DROPDOWN`.
## Metadata Mapping
### Checkbox
Example:
```text
{{checkbox, r1, options=Email|SMS|Phone, checked=Email|Phone, validationRule=atLeast, validationLength=1}}
```
Normalize to:
```ts
{
type: FieldType.CHECKBOX,
fieldMeta: {
type: 'checkbox',
validationRule: 'Select at least',
validationLength: 1,
values: [
{ id: 1, value: 'Email', checked: true },
{ id: 2, value: 'SMS', checked: false },
{ id: 3, value: 'Phone', checked: true },
],
},
}
```
Accepted keys:
- `options`
- `checked`
- `direction=vertical|horizontal`
- `validationRule=atLeast|exactly|atMost`
- `validationLength=1`
- `required=true|false`
- `readOnly=true|false`
- `fontSize=12`
Map checkbox validation aliases internally: `atLeast` -> `Select at least`, `exactly` -> `Select exactly`, `atMost` -> `Select at most`.
Checkbox placeholders do not support `label` or `placeholder` metadata.
### Radio
Example:
```text
{{radio, r1, options=Card|Bank transfer|Check, selected=Bank transfer}}
```
Normalize to:
```ts
{
type: FieldType.RADIO,
fieldMeta: {
type: 'radio',
values: [
{ id: 1, value: 'Card', checked: false },
{ id: 2, value: 'Bank transfer', checked: true },
{ id: 3, value: 'Check', checked: false },
],
},
}
```
Accepted keys:
- `options`
- `selected`, `default`, or `defaultValue`
- `direction=vertical|horizontal`
- `required=true|false`
- `readOnly=true|false`
- `fontSize=12`
Radio placeholders do not support `label` or `placeholder` metadata.
### Dropdown
Example:
```text
{{dropdown, r1, options=Sales|Legal|Finance, defaultValue=Legal}}
```
Normalize to:
```ts
{
type: FieldType.DROPDOWN,
fieldMeta: {
type: 'dropdown',
values: [{ value: 'Sales' }, { value: 'Legal' }, { value: 'Finance' }],
defaultValue: 'Legal',
},
}
```
Accepted keys:
- `options`
- `selected`, `default`, or `defaultValue`
- `required=true|false`
- `readOnly=true|false`
- `fontSize=12`
`defaultValue` should only be set if it matches one parsed option.
Dropdown placeholders do not support `label` or `placeholder` metadata.
## Code Touchpoints
- `packages/lib/server-only/pdf/helpers.ts`
- Extend `parseFieldMetaFromPlaceholder` so `options` normalizes into checkbox/radio/dropdown `fieldMeta.values`.
- Add delimiter-aware helpers for commas, equals signs, and pipes.
- `packages/lib/server-only/pdf/auto-place-fields.ts`
- Replace plain comma splitting with delimiter-aware splitting.
- Preserve the existing positional structure: field type, recipient, metadata.
- `packages/lib/types/field-meta.ts`
- Keep current internal schemas: checkbox/radio/dropdown still store options as `fieldMeta.values`.
- `packages/ui/primitives/document-flow/field-content.tsx`
- Display a radio fallback when a placeholder-created radio has no options.
- Docs:
- `apps/docs/content/docs/users/documents/advanced/pdf-placeholders.mdx`
- `apps/docs/content/docs/developers/api/fields.mdx`
## Test Plan
Unit tests:
- `options=Yes|No|Maybe` becomes stable radio values.
- `selected=No` marks only the matching radio option checked.
- Checkbox `options`, `checked`, `validationRule`, and `validationLength` normalize correctly.
- Dropdown `options` and `defaultValue` normalize correctly.
- Escaped delimiters parse correctly, for example `options=Sales\|Ops|Legal\, Compliance|A\=B`.
E2E/API tests:
- Add a PDF fixture with checkbox, radio, and dropdown placeholders using the current syntax.
- Verify created fields have schema-compatible metadata and expected options/defaults.
Suggested verification:
```bash
npm run test -w @documenso/lib -- server-only/pdf/helpers.test.ts
npm run test:dev -w @documenso/app-tests -- e2e/auto-placing-fields/auto-place-fields-document.spec.ts
npm run test:dev -w @documenso/app-tests -- e2e/envelope-editor-v2/envelope-fields.spec.ts
npx tsc --noEmit -p packages/lib/tsconfig.json
npx tsc --noEmit -p apps/remix/tsconfig.json
```
Do not use `npm run build` for routine verification unless explicitly requested.
## Decisions
- Keep the existing placeholder format.
- Use only `options` publicly.
- Keep `values` as an internal metadata field only.
- Use `|` as the option delimiter inside `options`.
@@ -465,7 +465,6 @@ const response = await fetch(`${BASE_URL}/template/use`, {
typedSignatureEnabled: true,
uploadSignatureEnabled: false,
drawSignatureEnabled: true,
qrSignatureEnabled: true,
},
distributeDocument: true,
}),
@@ -486,7 +485,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
},
}),
});
@@ -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 |
@@ -142,7 +141,6 @@ Triggered when a new document is created.
"typedSignatureEnabled": true,
"uploadSignatureEnabled": true,
"drawSignatureEnabled": true,
"qrSignatureEnabled": true,
"language": "en",
"distributionMethod": "EMAIL",
"emailSettings": null
@@ -237,7 +235,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
@@ -438,7 +435,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
@@ -622,7 +618,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
@@ -109,6 +109,37 @@ You can customize fields by adding options after the recipient identifier:
| `maxValue` | Number | Maximum allowed value |
| `numberFormat` | Format string | Number display format |
### Selection Field Options
Checkbox, radio, and dropdown placeholders can define their selectable choices via the `options` property.
Separate choices with pipe (`|`) characters.
Checkbox, radio, and dropdown placeholders do not support `label` or `placeholder` metadata.
| Option | Applies To | Values | Description |
| ------------------ | ------------------------- | ------------------------ | ---------------------------------------- |
| `options` | Checkbox, Radio, Dropdown | `Option 1|Option 2` | Selectable choices |
| `checked` | Checkbox | `Option 1|Option 2` | Pre-checked choices |
| `selected` | Radio, Dropdown | One option value | Pre-selected/default choice |
| `default` | Radio, Dropdown | One option value | Alias for `selected` |
| `defaultValue` | Radio, Dropdown | One option value | Alias for `selected` |
| `direction` | Checkbox, Radio | `vertical`, `horizontal` | Option layout |
| `validationRule` | Checkbox | `atLeast`, `exactly`, `atMost` | Checkbox selection validation rule |
| `validationLength` | Checkbox | Number (e.g., `1`) | Checkbox validation option count |
| `required` | Checkbox, Radio, Dropdown | `true`, `false` | Whether the field must be completed |
| `readOnly` | Checkbox, Radio, Dropdown | `true`, `false` | Whether the pre-selected value is locked |
| `fontSize` | Checkbox, Radio, Dropdown | Number (e.g., `12`) | Field text size |
For checkbox validation, `validationLength` defines the option count:
- `atLeast` means at least that many options must be selected
- `exactly` means exactly that many options must be selected
- `atMost` means at most that many options must be selected
If an option needs a literal delimiter, escape it with a backslash:
```
{{dropdown, r1, options=Sales\|Ops|Legal\, Compliance|A\=B}}
```
### Examples with Options
```
@@ -116,6 +147,10 @@ You can customize fields by adding options after the recipient identifier:
{{number, r1, minValue=0, maxValue=100, value=50}}
{{name, r1, fontSize=14}}
{{text, r2, readOnly=true, text=Contract #12345}}
{{checkbox, r1, options=Email|SMS|Phone, checked=Email|Phone, validationRule=atLeast, validationLength=1}}
{{radio, r1, options=Card|Bank transfer|Check, selected=Check}}
{{dropdown, r1, options=United States|Canada|United Kingdom}}
{{dropdown, r2, options=Sales|Legal|Finance, defaultValue=Legal}}
```
<Callout type="info">
@@ -1,4 +1,3 @@
import type { TQrSignatureContext } from '@documenso/lib/types/qr-signature';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
@@ -14,21 +13,10 @@ export type SignFieldSignatureDialogProps = {
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
qrSignatureEnabled?: boolean;
qrSignatureContext?: TQrSignatureContext;
};
export const SignFieldSignatureDialog = createCallable<SignFieldSignatureDialogProps, string | null>(
({
call,
fullName,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
qrSignatureEnabled,
qrSignatureContext,
initialSignature,
}) => {
({ call, fullName, typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled, initialSignature }) => {
const [localSignature, setLocalSignature] = useState(initialSignature);
return (
@@ -48,8 +36,6 @@ export const SignFieldSignatureDialog = createCallable<SignFieldSignatureDialogP
typedSignatureEnabled={typedSignatureEnabled}
uploadSignatureEnabled={uploadSignatureEnabled}
drawSignatureEnabled={drawSignatureEnabled}
qrSignatureEnabled={qrSignatureEnabled}
qrSignatureContext={qrSignatureContext}
/>
</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,8 +461,6 @@ export const EmbedSignDocumentV1ClientPage = ({
typedSignatureEnabled={metadata?.typedSignatureEnabled}
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
drawSignatureEnabled={metadata?.drawSignatureEnabled}
qrSignatureEnabled={metadata?.qrSignatureEnabled}
qrSignatureContext={{ type: 'DOCUMENT_SIGNATURE', recipientToken: recipient.token }}
/>
</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>
)}
@@ -55,7 +55,6 @@ type SettingsSubset = Pick<
| 'typedSignatureEnabled'
| 'uploadSignatureEnabled'
| 'drawSignatureEnabled'
| 'qrSignatureEnabled'
| 'defaultRecipients'
| 'delegateDocumentOwnership'
| 'aiFeaturesEnabled'
@@ -111,7 +111,6 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
<FormControl>
<SignaturePadDialog
disabled={isSubmitting}
qrSignatureContext={{ type: 'PROFILE_SIGNATURE' }}
fullName={user.name ?? ''}
value={value}
onChange={(v) => onChange(v ?? '')}
@@ -314,7 +314,6 @@ export const SignUpForm = ({
<FormControl>
<SignaturePadDialog
disabled={isSubmitting}
qrSignatureContext={{ type: 'PROFILE_SIGNATURE' }}
value={value}
onChange={(v) => onChange(v ?? '')}
/>
@@ -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>
@@ -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,8 +254,6 @@ export const DocumentSigningForm = ({
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
qrSignatureEnabled={document.documentMeta?.qrSignatureEnabled}
qrSignatureContext={{ type: 'DOCUMENT_SIGNATURE', recipientToken: recipient.token }}
/>
</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,8 +279,6 @@ export const DocumentSigningSignatureField = ({
typedSignatureEnabled={typedSignatureEnabled}
uploadSignatureEnabled={uploadSignatureEnabled}
drawSignatureEnabled={drawSignatureEnabled}
qrSignatureEnabled={qrSignatureEnabled}
qrSignatureContext={{ type: 'DOCUMENT_SIGNATURE', recipientToken: recipient.token }}
/>
<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),
},
});
};
@@ -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,
},
@@ -121,8 +121,6 @@ export default function EnvelopeSignerForm() {
typedSignatureEnabled={envelope.documentMeta.typedSignatureEnabled}
uploadSignatureEnabled={envelope.documentMeta.uploadSignatureEnabled}
drawSignatureEnabled={envelope.documentMeta.drawSignatureEnabled}
qrSignatureEnabled={envelope.documentMeta.qrSignatureEnabled}
qrSignatureContext={{ type: 'DOCUMENT_SIGNATURE', recipientToken: recipient.token }}
/>
</div>
)}
@@ -384,8 +384,6 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled,
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled,
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled,
qrSignatureEnabled: envelope.documentMeta.qrSignatureEnabled,
recipientToken: envelopeData.recipient.token,
})
.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,
},
});
@@ -62,7 +62,6 @@ 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,
aiFeaturesEnabled,
},
@@ -50,13 +50,11 @@ 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,
},
@@ -215,7 +215,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}
@@ -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 />}
@@ -1,28 +1,21 @@
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
import { Outlet, useLocation } from 'react-router';
import { Outlet } from 'react-router';
export default function Layout() {
const { pathname } = useLocation();
// Todo: Use the layout params to hide instead of hardcoding the pathname.
const hideBackground = pathname.includes('mobile-signature');
return (
<main className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
<div>
{!hideBackground && (
<div className="absolute -inset-[min(600px,max(400px,60vw))] -z-[1] flex items-center justify-center opacity-70">
<img
src={backgroundPattern}
alt="background pattern"
className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
style={{
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
}}
/>
</div>
)}
<div className="absolute -inset-[min(600px,max(400px,60vw))] -z-[1] flex items-center justify-center opacity-70">
<img
src={backgroundPattern}
alt="background pattern"
className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
style={{
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
}}
/>
</div>
<div className="relative w-full">
<Outlet />
@@ -1,339 +0,0 @@
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import type {
TGetQrSignatureSessionResponse,
TQrSignatureSessionContext,
} from '@documenso/trpc/server/signature-router/qr/get-qr-signature-session.types';
import { Button } from '@documenso/ui/primitives/button';
import { Sheet, SheetContent, SheetTitle } from '@documenso/ui/primitives/sheet';
import { SignaturePadDraw } from '@documenso/ui/primitives/signature-pad/signature-pad-draw';
import { i18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { CheckCircle2Icon, ClockIcon, FileTextIcon, Loader2Icon, PenLineIcon, XCircleIcon } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { match } from 'ts-pattern';
import type { Route } from './+types/mobile-signature.$token';
export function meta() {
return [
{ title: i18n._(msg`Sign on mobile - Documenso`) },
{ name: 'robots', content: 'noindex, nofollow, noarchive, nosnippet, noimageindex' },
];
}
export default function MobileSignaturePage({ params }: Route.ComponentProps) {
const { token } = params;
const {
data: session,
isError: isSessionError,
isLoading: isSessionLoading,
} = trpc.signature.qr.getSession.useQuery(
{
token,
},
{
// Do not refetch the session.
staleTime: Number.POSITIVE_INFINITY,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
},
);
if (isSessionLoading || !session) {
return (
<div className="flex w-full flex-col items-center text-center">
<Loader2Icon className="size-8 animate-spin text-muted-foreground" />
<span className="sr-only">
<Trans>Loading</Trans>
</span>
</div>
);
}
if (session.status !== 'VALID' || isSessionError) {
return <QrSignatureError reason={session.status !== 'VALID' ? session.status : undefined} />;
}
return (
<div className="w-screen max-w-lg select-none px-4">
<QrSignature token={token} context={session.context} />
</div>
);
}
type QrSignatureState = 'SIGNING' | 'SUCCESS' | 'EXPIRED' | 'ALREADY_SUBMITTED';
type QrSignatureProps = {
token: string;
context: TQrSignatureSessionContext;
};
const QrSignature = ({ token, context }: QrSignatureProps) => {
const { t } = useLingui();
const [signature, setSignature] = useState('');
const [hasSubmissionError, setHasSubmissionError] = useState(false);
const [state, setState] = useState<QrSignatureState>('SIGNING');
// Portrait renders the pad in a bottom sheet beneath the document context;
// landscape renders a single card. This component only ever renders on the
// client (behind the session query), so the initial value can be read
// synchronously - no flicker on landscape devices.
const [isPortrait, setIsPortrait] = useState(
() => typeof window === 'undefined' || window.matchMedia('(orientation: portrait)').matches,
);
useEffect(() => {
const mediaQuery = window.matchMedia('(orientation: portrait)');
setIsPortrait(mediaQuery.matches);
const onOrientationChange = (event: MediaQueryListEvent) => {
setIsPortrait(event.matches);
};
mediaQuery.addEventListener('change', onOrientationChange);
return () => {
mediaQuery.removeEventListener('change', onOrientationChange);
};
}, []);
const { mutateAsync: completeQrSignature, isPending } = trpc.signature.qr.complete.useMutation({
// The session query must not refetch on completion: it would resolve to
// ALREADY_SUBMITTED and replace the success screen with an error card.
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
});
const contextInfo = useMemo(
() =>
match(context)
.with({ type: 'DOCUMENT_SIGNATURE' }, (documentContext) => ({
title: documentContext.documentTitle,
subtitle: `${documentContext.teamName} · ${t`Signature requested`}`,
icon: <FileTextIcon className="size-6 text-primary" />,
}))
.with({ type: 'PROFILE_SIGNATURE' }, () => ({
title: t`Your signature`,
subtitle: t`Signature requested`,
icon: <PenLineIcon className="size-6 text-primary" />,
}))
// Context-less sessions show no subtitle, which would just repeat the title.
.with({ type: 'NONE' }, () => ({
title: t`Signature requested`,
subtitle: null,
icon: <PenLineIcon className="size-6 text-primary" />,
}))
.exhaustive(),
[context, t],
);
const onSubmitClick = async () => {
setHasSubmissionError(false);
try {
await completeQrSignature({
token,
signature,
});
setState('SUCCESS');
} catch (err) {
const error = AppError.parseError(err);
if (error.code === AppErrorCode.EXPIRED_CODE || error.code === AppErrorCode.NOT_FOUND) {
setState('EXPIRED');
return;
}
if (error.code === AppErrorCode.INVALID_REQUEST) {
setState('ALREADY_SUBMITTED');
return;
}
setHasSubmissionError(true);
}
};
if (state === 'EXPIRED' || state === 'ALREADY_SUBMITTED') {
return <QrSignatureError reason={state} />;
}
if (state === 'SUCCESS') {
return (
<div className="flex w-full flex-col items-center text-center">
<CheckCircle2Icon className="size-10 text-primary" />
<h1 className="mt-4 font-semibold text-2xl">
<Trans>Success</Trans>
</h1>
<p className="mt-2 text-muted-foreground text-sm">
<Trans>You can now return to your main device to continue.</Trans>
</p>
</div>
);
}
if (isPortrait) {
return (
<>
{/* Document context hero. */}
<div className="flex flex-col items-center pb-[45svh] text-center">
<div className="flex size-14 items-center justify-center rounded-xl border border-primary/30 bg-primary/10">
{contextInfo.icon}
</div>
<h1 className="mt-4 font-semibold text-2xl">{contextInfo.title}</h1>
{contextInfo.subtitle && <p className="mt-2 text-muted-foreground text-sm">{contextInfo.subtitle}</p>}
<div className="mt-4 flex items-center gap-2 rounded-md border border-border bg-background px-3 py-1.5 text-muted-foreground text-xs">
<span className="size-2 rounded-full bg-primary" />
<Trans>Connected</Trans>
</div>
</div>
{/* Persistent signing sheet - cannot be dismissed. */}
<Sheet open>
<SheetContent
position="bottom"
size="content"
showOverlay={false}
className="h-auto select-none rounded-t-2xl border-t px-4 pt-4 pb-6 [&>button:last-child]:hidden"
onEscapeKeyDown={(event) => event.preventDefault()}
onPointerDownOutside={(event) => event.preventDefault()}
onInteractOutside={(event) => event.preventDefault()}
>
<div className="mx-auto mb-3 h-1 w-10 rounded-full bg-muted" />
<SheetTitle className="font-semibold text-lg">
<Trans>Draw your signature</Trans>
</SheetTitle>
<div className="relative mt-3 flex aspect-signature-pad items-center justify-center rounded-md border border-border bg-muted/25">
<SignaturePadDraw className="h-full w-full" value={signature} onChange={(value) => setSignature(value)} />
</div>
{hasSubmissionError && (
<p className="mt-2 text-destructive text-sm">
<Trans>Something went wrong. Please try again.</Trans>
</p>
)}
<div className="mt-4 flex">
<Button
type="button"
className="flex-1"
disabled={!signature}
loading={isPending}
onClick={() => void onSubmitClick()}
>
<Trans>Next</Trans>
</Button>
</div>
</SheetContent>
</Sheet>
</>
);
}
// Landscape: a single card, no sheet.
return (
// Need this to override the parent layout styling.
<div className="fixed inset-0 z-50 flex select-none items-center justify-center bg-background p-2">
{/* The column width IS the pad width: all height left beneath the fixed
h-12 header (100svh - 2*p-2 - h-12 - mb-2 = 100svh - 4.5rem) is
converted through the pad's 16/7 aspect ratio, clamped by the
viewport width. The header is w-full of the same column, so it always
matches the pad width exactly. */}
<div className="flex max-h-full w-[min(100%,calc((100svh-4.5rem)*16/7))] max-w-lg flex-col">
<div className="mb-2 flex h-12 w-full shrink-0 items-center justify-between rounded-lg border border-border bg-muted/25 px-2">
<div className="flex min-w-0 items-center gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-primary/30 bg-primary/10">
{contextInfo.icon}
</div>
<div className="min-w-0">
<h1 className="truncate font-semibold text-sm">{contextInfo.title}</h1>
<p className="truncate text-muted-foreground text-xs">{contextInfo.subtitle}</p>
</div>
</div>
<Button
type="button"
size="sm"
className="ml-2 flex-shrink-0 px-6"
disabled={!signature}
loading={isPending}
onClick={() => void onSubmitClick()}
>
<Trans>Next</Trans>
</Button>
</div>
<div className="relative flex aspect-signature-pad w-full items-center justify-center rounded-md border border-border bg-muted/25">
<SignaturePadDraw className="h-full w-full" value={signature} onChange={(value) => setSignature(value)} />
</div>
{hasSubmissionError && (
<p className="mt-2 text-destructive text-sm">
<Trans>Something went wrong. Please try again.</Trans>
</p>
)}
</div>
</div>
);
};
type QrSignatureErrorReason = Exclude<TGetQrSignatureSessionResponse['status'], 'VALID'>;
type QrSignatureErrorProps = {
reason?: QrSignatureErrorReason;
};
const QrSignatureError = ({ reason }: QrSignatureErrorProps) => {
const content = match(reason)
.with('EXPIRED', () => ({
icon: <ClockIcon className="size-10 text-yellow-500" />,
title: <Trans>This link has expired</Trans>,
description: <Trans>Generate a new QR code on the original device and scan it again.</Trans>,
}))
.with('ALREADY_SUBMITTED', () => ({
icon: <CheckCircle2Icon className="size-10 text-primary" />,
title: <Trans>Signature already sent</Trans>,
description: <Trans>This link has already been used. Return to your computer to continue.</Trans>,
}))
.with('INVALID', () => ({
icon: <XCircleIcon className="size-10 text-muted-foreground" />,
title: <Trans>This signing request is invalid</Trans>,
description: (
<Trans>
The request is invalid or no longer exists. Scan the new QR code on the original device to try again.
</Trans>
),
}))
.with(undefined, () => ({
icon: <XCircleIcon className="size-10 text-muted-foreground" />,
title: <Trans>Something went wrong</Trans>,
description: <Trans>We couldn't load this signing request. Please refresh the page to try again.</Trans>,
}))
.exhaustive();
return (
<div className="flex w-full flex-col items-center text-center">
{content.icon}
<h1 className="mt-2 font-semibold text-2xl">{content.title}</h1>
<p className="mt-2 text-muted-foreground text-sm">{content.description}</p>
</div>
);
};
@@ -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,23 +12,12 @@ type HandleSignatureFieldClickOptions = {
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
qrSignatureEnabled?: boolean;
recipientToken?: string;
};
export const handleSignatureFieldClick = async (
options: HandleSignatureFieldClickOptions,
): Promise<Extract<TSignEnvelopeFieldValue, { type: typeof FieldType.SIGNATURE }> | null> => {
const {
field,
fullName,
signature,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
qrSignatureEnabled,
recipientToken,
} = options;
const { field, fullName, signature, typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled } = options;
if (field.type !== FieldType.SIGNATURE) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
@@ -51,8 +40,6 @@ export const handleSignatureFieldClick = async (
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
qrSignatureEnabled,
qrSignatureContext: recipientToken ? { type: 'DOCUMENT_SIGNATURE', recipientToken } : undefined,
});
}
-1
View File
@@ -33049,7 +33049,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
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,
},
-3
View File
@@ -170,8 +170,6 @@ export const ZCreateDocumentMutationSchema = z.object({
typedSignatureEnabled: z.boolean().optional().default(true),
uploadSignatureEnabled: z.boolean().optional().default(true),
drawSignatureEnabled: z.boolean().optional().default(true),
// No default: omission must fall through to team/org settings.
qrSignatureEnabled: z.boolean().optional(),
distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional(),
emailSettings: ZDocumentEmailSettingsSchema.optional(),
})
@@ -342,7 +340,6 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
typedSignatureEnabled: z.boolean(),
uploadSignatureEnabled: z.boolean(),
drawSignatureEnabled: z.boolean(),
qrSignatureEnabled: z.boolean(),
emailSettings: ZDocumentEmailSettingsSchema,
})
.partial()
@@ -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);
@@ -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();
});
@@ -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);
@@ -68,7 +68,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`);
@@ -103,7 +102,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);
@@ -119,7 +117,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');
@@ -1,220 +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.
// A realistic landscape-phone viewport: the pad sizes itself dynamically to
// the viewport, and the primitive's minimum-coverage check is a percentage
// of the canvas area - a desktop-sized context would demand far more ink
// than the drawn zigzag provides.
const mobileContext = await browser.newContext({ viewport: { width: 844, height: 390 } });
const mobilePage = await mobileContext.newPage();
await mobilePage.goto(handoffUrl ?? '');
// The phone page renders the signing card (landscape layout at the default
// test viewport) with Next disabled until a valid signature is drawn.
await expect(mobilePage.getByTestId('signature-pad-draw')).toBeVisible();
await expect(mobilePage.getByRole('button', { name: 'Next' })).toBeDisabled();
await drawOnSignaturePad(mobilePage);
await mobilePage.getByRole('button', { name: 'Next' }).click();
await expect(mobilePage.getByText('Success')).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();
});
-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;
-2
View File
@@ -21,7 +21,6 @@ import { ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION } from './definitions/inte
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';
@@ -65,7 +64,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,
ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION,
@@ -1,36 +0,0 @@
import { prisma } from '@documenso/prisma';
import type { JobRunIO } from '../../client/_internal/job';
import type { TCleanupAnonymousTokensJobDefinition } from './cleanup-anonymous-tokens';
const BATCH_SIZE = 10_000;
export const run = async ({ io }: { payload: TCleanupAnonymousTokensJobDefinition; io: JobRunIO }) => {
// Snapshot the cutoff so the run is bounded by the rows that were already
// expired when it started, rather than chasing rows expiring mid-run.
const cutoff = new Date();
let totalDeleted = 0;
let deleted = 0;
do {
// Postgres doesn't support DELETE with LIMIT, so batch via ctid to avoid
// long-running transactions that could lock the table.
deleted = await prisma.$executeRaw`
DELETE FROM "AnonymousVerificationToken"
WHERE ctid IN (
SELECT ctid FROM "AnonymousVerificationToken"
WHERE "expiresAt" < ${cutoff}
LIMIT ${BATCH_SIZE}
)
`;
totalDeleted += deleted;
} while (deleted >= BATCH_SIZE);
if (totalDeleted > 0) {
io.logger.info(`Cleaned up ${totalDeleted} expired anonymous verification tokens`);
} else {
io.logger.info('No expired anonymous verification tokens to clean up');
}
};
@@ -1,28 +0,0 @@
import { z } from 'zod';
import type { JobDefinition } from '../../client/_internal/job';
const CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION_ID = 'internal.cleanup-anonymous-tokens';
const CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION_SCHEMA = z.object({});
export type TCleanupAnonymousTokensJobDefinition = z.infer<typeof CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION_SCHEMA>;
export const CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION = {
id: CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION_ID,
name: 'Cleanup Anonymous Verification Tokens',
version: '1.0.0',
trigger: {
name: CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION_ID,
schema: CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION_SCHEMA,
cron: '0 */2 * * *', // Every 2 hours.
},
handler: async ({ payload, io }) => {
const handler = await import('./cleanup-anonymous-tokens.handler');
await handler.run({ payload, io });
},
} as const satisfies JobDefinition<
typeof CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION_ID,
TCleanupAnonymousTokensJobDefinition
>;
@@ -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,
},
});
@@ -57,7 +57,10 @@ export const UNSAFE_createEnvelopeItems = async ({
flattenForm: envelope.type !== 'TEMPLATE',
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized, {
envelopeId: envelope.id,
fileName: file.name,
});
const { documentData } = await putPdfFileServerSide({
name: file.name,
@@ -85,7 +85,10 @@ export const UNSAFE_replaceEnvelopeItemPdf = async ({
flattenForm: envelope.type !== 'TEMPLATE',
});
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized, {
envelopeId: envelope.id,
fileName: data.file.name,
});
// Upload the new PDF and get a new DocumentData record.
const { documentData: newDocumentData, filePageCount } = await putPdfFileServerSide({
@@ -47,7 +47,6 @@ export const ZEnvelopeForSigningResponse = z.object({
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
qrSignatureEnabled: true,
allowDictateNextSigner: true,
language: true,
}),
@@ -1,8 +1,15 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { type TFieldAndMeta, ZEnvelopeFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { logger } from '@documenso/lib/utils/logger';
import { PDF, rgb } from '@libpdf/core';
import type { FieldType, Recipient } from '@prisma/client';
import { parseFieldMetaFromPlaceholder, parseFieldTypeFromPlaceholder } from './helpers';
import {
parseFieldMetaFromPlaceholder,
parseFieldTypeFromPlaceholder,
parsePlaceholderData,
parseRawFieldMetaFromPlaceholder,
} from './helpers';
const PLACEHOLDER_REGEX = /\{\{([^}]+)\}\}/g;
const DEFAULT_FIELD_HEIGHT_PERCENT = 2;
@@ -61,7 +68,15 @@ export type FieldToCreate = TFieldAndMeta & {
height: number;
};
export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<PlaceholderInfo[]> => {
type ExtractPlaceholdersLogContext = {
envelopeId?: string;
fileName?: string;
};
export const extractPlaceholdersFromPDF = async (
pdf: Buffer,
logContext?: ExtractPlaceholdersLogContext,
): Promise<PlaceholderInfo[]> => {
const pdfDoc = await PDF.load(new Uint8Array(pdf));
const placeholders: PlaceholderInfo[] = [];
@@ -85,7 +100,7 @@ export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<Placehold
continue;
}
const placeholderData = innerMatch[1].split(',').map((property) => property.trim());
const placeholderData = parsePlaceholderData(innerMatch[1]);
const [fieldTypeString, recipientOrMeta, ...fieldMetaData] = placeholderData;
let fieldType: FieldType;
@@ -109,14 +124,51 @@ export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<Placehold
const recipient = recipientOrMeta;
const rawFieldMeta = Object.fromEntries(fieldMetaData.map((property) => property.split('=')));
/*
Parse and validate the field metadata. A malformed selection placeholder
(e.g. an unknown validation rule or a default value that doesn't match an
option) is skipped like an invalid field type rather than aborting the whole
upload, which may contain other valid placeholders and files.
*/
let fieldAndMeta: TFieldAndMeta;
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
try {
const rawFieldMeta = parseRawFieldMetaFromPlaceholder(fieldMetaData);
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
const fieldAndMeta: TFieldAndMeta = ZEnvelopeFieldAndMetaSchema.parse({
type: fieldType,
fieldMeta: parsedFieldMeta,
});
const parsedFieldAndMeta = ZEnvelopeFieldAndMetaSchema.safeParse({
type: fieldType,
fieldMeta: parsedFieldMeta,
});
/*
Surface schema failures as INVALID_BODY (400) instead of letting the raw
ZodError bubble up to the caller as an INTERNAL_SERVER_ERROR (500).
*/
if (!parsedFieldAndMeta.success) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid field metadata for placeholder "${placeholder}": ${parsedFieldAndMeta.error.message}`,
});
}
fieldAndMeta = parsedFieldAndMeta.data;
} catch (error) {
const appError = AppError.parseError(error);
logger.warn(
{
envelopeId: logContext?.envelopeId,
fileName: logContext?.fileName,
placeholder,
page: page.index + 1,
code: appError.code,
message: appError.message,
},
'Skipping placeholder with invalid field metadata',
);
continue;
}
/*
LibPDF returns bbox in points with bottom-left origin.
@@ -182,8 +234,9 @@ export const removePlaceholdersFromPDF = async (pdf: Buffer, placeholders?: Plac
*/
export const extractPdfPlaceholders = async (
pdf: Buffer,
logContext?: ExtractPlaceholdersLogContext,
): Promise<{ cleanedPdf: Buffer; placeholders: PlaceholderInfo[] }> => {
const placeholders = await extractPlaceholdersFromPDF(pdf);
const placeholders = await extractPlaceholdersFromPDF(pdf, logContext);
if (placeholders.length === 0) {
return { cleanedPdf: pdf, placeholders: [] };
@@ -0,0 +1,235 @@
import { FieldType } from '@prisma/client';
import { describe, expect, it } from 'vitest';
import { AppError, AppErrorCode } from '../../errors/app-error';
import {
parseFieldMetaFromPlaceholder,
parseFieldTypeFromPlaceholder,
parsePlaceholderData,
parseRawFieldMetaFromPlaceholder,
} from './helpers';
const expectInvalidBody = (fn: () => unknown) => {
try {
fn();
expect.unreachable('Expected an AppError to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(AppError);
expect((error as AppError).code).toBe(AppErrorCode.INVALID_BODY);
}
};
describe('parseFieldTypeFromPlaceholder function', () => {
it('maps known field type strings to the FieldType enum', () => {
expect(parseFieldTypeFromPlaceholder('signature')).toBe(FieldType.SIGNATURE);
expect(parseFieldTypeFromPlaceholder('radio')).toBe(FieldType.RADIO);
expect(parseFieldTypeFromPlaceholder('checkbox')).toBe(FieldType.CHECKBOX);
expect(parseFieldTypeFromPlaceholder('dropdown')).toBe(FieldType.DROPDOWN);
});
it('is case-insensitive and trims surrounding whitespace', () => {
expect(parseFieldTypeFromPlaceholder(' SiGnAtUrE ')).toBe(FieldType.SIGNATURE);
expect(parseFieldTypeFromPlaceholder('RADIO')).toBe(FieldType.RADIO);
});
it('throws INVALID_BODY for an unknown field type', () => {
expectInvalidBody(() => parseFieldTypeFromPlaceholder('FILE'));
});
});
describe('parsePlaceholderData function', () => {
it('splits top-level parts on commas and trims each token', () => {
expect(parsePlaceholderData('SIGNATURE, r1, required=true')).toEqual(['SIGNATURE', 'r1', 'required=true']);
});
it('does not split on escaped commas', () => {
expect(parsePlaceholderData('dropdown, r1, options=Legal\\, Compliance|Sales')).toEqual([
'dropdown',
'r1',
'options=Legal\\, Compliance|Sales',
]);
});
});
describe('parseRawFieldMetaFromPlaceholder function', () => {
it('splits each token into a key/value entry', () => {
expect(parseRawFieldMetaFromPlaceholder(['required=true', 'fontSize=12'])).toEqual({
required: 'true',
fontSize: '12',
});
});
it('only splits on the first unescaped equals sign', () => {
expect(parseRawFieldMetaFromPlaceholder(['label=a=b'])).toEqual({ label: 'a=b' });
});
it('drops tokens without a value and overwrites duplicate keys with the last', () => {
expect(parseRawFieldMetaFromPlaceholder(['required', 'fontSize=12', 'fontSize=14'])).toEqual({
fontSize: '14',
});
});
});
describe('parseFieldMetaFromPlaceholder function', () => {
describe('non-field-meta cases', () => {
it('returns undefined for signature and free signature fields', () => {
expect(parseFieldMetaFromPlaceholder({ required: 'true' }, FieldType.SIGNATURE)).toBeUndefined();
expect(parseFieldMetaFromPlaceholder({}, FieldType.FREE_SIGNATURE)).toBeUndefined();
});
it('returns undefined when there is no metadata', () => {
expect(parseFieldMetaFromPlaceholder({}, FieldType.TEXT)).toBeUndefined();
});
});
describe('generic metadata', () => {
it('coerces required/readOnly to booleans (case-insensitive)', () => {
expect(parseFieldMetaFromPlaceholder({ required: 'TRUE', readOnly: 'false' }, FieldType.TEXT)).toEqual({
type: 'text',
required: true,
readOnly: false,
});
});
it('coerces numeric properties to numbers', () => {
expect(parseFieldMetaFromPlaceholder({ fontSize: '14' }, FieldType.TEXT)).toEqual({
type: 'text',
fontSize: 14,
});
});
it('drops numeric properties that are not a number', () => {
const parsed = parseFieldMetaFromPlaceholder({ fontSize: 'abc' }, FieldType.TEXT);
expect(parsed).toEqual({ type: 'text' });
expect(parsed).not.toHaveProperty('fontSize');
});
it('keeps label/placeholder for non-selection fields', () => {
expect(parseFieldMetaFromPlaceholder({ label: 'Company Name', placeholder: 'Acme' }, FieldType.TEXT)).toEqual({
type: 'text',
label: 'Company Name',
placeholder: 'Acme',
});
});
});
describe('radio fields', () => {
it('builds stable values from options', () => {
expect(parseFieldMetaFromPlaceholder({ options: 'Yes|No|Maybe' }, FieldType.RADIO)).toEqual({
type: 'radio',
values: [
{ id: 1, checked: false, value: 'Yes' },
{ id: 2, checked: false, value: 'No' },
{ id: 3, checked: false, value: 'Maybe' },
],
});
});
it('marks only the selected option as checked', () => {
const parsed = parseFieldMetaFromPlaceholder({ options: 'Yes|No|Maybe', selected: 'No' }, FieldType.RADIO);
expect(parsed).toEqual({
type: 'radio',
values: [
{ id: 1, checked: false, value: 'Yes' },
{ id: 2, checked: true, value: 'No' },
{ id: 3, checked: false, value: 'Maybe' },
],
});
});
it('throws when a default value is provided without options', () => {
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ selected: 'No' }, FieldType.RADIO));
});
it('throws when the default value does not match an option', () => {
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ options: 'Yes|No', selected: 'Maybe' }, FieldType.RADIO));
});
it('throws when options is empty', () => {
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ options: '' }, FieldType.RADIO));
});
});
describe('checkbox fields', () => {
it('builds values with checked state, validation rule alias and length', () => {
const parsed = parseFieldMetaFromPlaceholder(
{
options: 'Email|SMS|Phone',
checked: 'Email|Phone',
validationRule: 'atLeast',
validationLength: '1',
},
FieldType.CHECKBOX,
);
expect(parsed).toEqual({
type: 'checkbox',
validationRule: 'Select at least',
validationLength: 1,
values: [
{ id: 1, checked: true, value: 'Email' },
{ id: 2, checked: false, value: 'SMS' },
{ id: 3, checked: true, value: 'Phone' },
],
});
});
it('throws for an unknown validation rule', () => {
expectInvalidBody(() =>
parseFieldMetaFromPlaceholder({ options: 'A|B', validationRule: 'nope' }, FieldType.CHECKBOX),
);
});
it('throws when checked values are provided without options', () => {
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ checked: 'A' }, FieldType.CHECKBOX));
});
it('throws when a checked value does not match an option', () => {
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ options: 'A|B', checked: 'C' }, FieldType.CHECKBOX));
});
});
describe('dropdown fields', () => {
it('builds values and sets a matching default value', () => {
expect(
parseFieldMetaFromPlaceholder(
{ options: 'United States|Canada|United Kingdom', defaultValue: 'Canada' },
FieldType.DROPDOWN,
),
).toEqual({
type: 'dropdown',
values: [{ value: 'United States' }, { value: 'Canada' }, { value: 'United Kingdom' }],
defaultValue: 'Canada',
});
});
it('throws when the default value does not match an option', () => {
expectInvalidBody(() => parseFieldMetaFromPlaceholder({ options: 'A|B', defaultValue: 'C' }, FieldType.DROPDOWN));
});
});
describe('selection field options parsing', () => {
it('trims option values and drops empty entries', () => {
expect(parseFieldMetaFromPlaceholder({ options: ' A || B ' }, FieldType.DROPDOWN)).toEqual({
type: 'dropdown',
values: [{ value: 'A' }, { value: 'B' }],
});
});
it('parses escaped delimiters through the full placeholder pipeline', () => {
const [, , ...fieldMetaData] = parsePlaceholderData(
'dropdown, r1, options=Sales\\|Ops|Legal\\, Compliance|A\\=B',
);
const rawFieldMeta = parseRawFieldMetaFromPlaceholder(fieldMetaData);
const parsed = parseFieldMetaFromPlaceholder(rawFieldMeta, FieldType.DROPDOWN);
expect(parsed).toEqual({
type: 'dropdown',
values: [{ value: 'Sales|Ops' }, { value: 'Legal, Compliance' }, { value: 'A=B' }],
});
});
});
});
+311 -5
View File
@@ -45,6 +45,134 @@ type RecipientPlaceholderInfo = {
recipientIndex: number;
};
const CHECKBOX_VALIDATION_RULE_BY_ALIAS: Record<string, string> = {
atLeast: 'Select at least',
exactly: 'Select exactly',
atMost: 'Select at most',
};
/*
Split a string on a delimiter, treating `\` as an escape for the next character.
Delimiters preceded by `\` are kept in the output instead of splitting (e.g. `\,`, `\=`, `\|`).
With delimiter ',' (top-level placeholder parts):
'radio, r1, options=Card/Check|Bank Transfer, selected=Bank Transfer'
-> ['radio', ' r1', ' options=Card/Check|Bank Transfer', ' selected=Bank Transfer']
With delimiter '=' (split one field metadata token into key + value):
'options=Card/Check|Bank Transfer'
-> ['options', 'Card/Check|Bank Transfer']
With delimiter '|' (split option list inside 'options='):
'Card/Check|Bank Transfer'
-> ['Card/Check', 'Bank Transfer']
*/
const splitPlaceholderToken = (value: string, delimiter: string): string[] => {
const parts: string[] = [];
let currentPart = '';
for (let index = 0; index < value.length; index++) {
const char = value[index];
const nextChar = value[index + 1];
if (char === '\\' && nextChar) {
currentPart += char + nextChar;
index++;
continue;
}
if (char === delimiter) {
parts.push(currentPart);
currentPart = '';
continue;
}
currentPart += char;
}
parts.push(currentPart);
return parts;
};
/*
Removes the escape backslashes left over after splitting, so \,=, \|, \\ become their literal characters.
E.g.
'Legal\, Compliance' -> 'Legal, Compliance'
'Card\|Check' -> 'Card|Check'
'A\=B' -> 'A=B'
'C\D' -> 'C\D'
*/
const unescapePlaceholderValue = (value: string): string => {
return value.replace(/\\([,=|\\])/g, '$1');
};
/*
Cleans up a selection option/default after splitting:
unescapes literal delimiters, collapses repeated whitespace, and trims the ends.
E.g.
' Legal\, Compliance ' -> 'Legal, Compliance'
*/
const normalizePlaceholderSelectionValue = (value: string): string => {
return unescapePlaceholderValue(value).replace(/\s+/g, ' ').trim();
};
/*
Split an options string into individual choices.
Splits on unescaped '|', then unescapes, trims, and drops empty entries.
E.g.
'Card/Check|Bank Transfer' -> ['Card/Check', 'Bank Transfer']
'Card\\|Check|Bank Transfer' -> ['Card|Check', 'Bank Transfer']
*/
const parsePlaceholderOptions = (value: string): string[] => {
return splitPlaceholderToken(value, '|')
.map((option) => normalizePlaceholderSelectionValue(option))
.filter((option) => option.length > 0);
};
/*
Split a placeholder string into top-level parts (field type, recipient, metadata).
Splits on unescaped commas, then trims whitespace.
E.g.
'SIGNATURE, r1, required=true'
-> ['SIGNATURE', 'r1', 'required=true']
*/
export const parsePlaceholderData = (value: string): string[] => {
return splitPlaceholderToken(value, ',').map((token) => token.trim());
};
/*
Transforms the field metadata string array into a record of key/value pairs.
Each token is split on the first unescaped '='; tokens with no key or no '=' are dropped.
E.g.
['required=true', 'fontSize=12', 'label=a=b']
-> { required: 'true', fontSize: '12', label: 'a=b' }
*/
export const parseRawFieldMetaFromPlaceholder = (fieldMetaData: string[]): Record<string, string> => {
const rawFieldMeta: Record<string, string> = {};
for (const fieldMeta of fieldMetaData) {
// Split on the first '=' only; any further '=' stays part of the value (e.g. 'label=a=b').
const [rawKey, ...valueParts] = splitPlaceholderToken(fieldMeta, '=');
if (!rawKey || valueParts.length === 0) {
continue;
}
const key = rawKey.trim();
const value = valueParts.join('=').trim();
rawFieldMeta[key] = value;
}
return rawFieldMeta;
};
/*
Parse field type string to FieldType enum.
Normalizes the input (uppercase, trim) and validates it's a valid field type.
@@ -72,6 +200,169 @@ export const parseFieldTypeFromPlaceholder = (fieldTypeString: string): FieldTyp
});
};
const getDefaultFieldMetaValue = (rawFieldMeta: Record<string, string>) => {
const defaultValue = rawFieldMeta.defaultValue ?? rawFieldMeta.default ?? rawFieldMeta.selected;
return defaultValue ? normalizePlaceholderSelectionValue(defaultValue) : undefined;
};
const parseCheckboxValidationRule = (value: string): string => {
const validationRule = CHECKBOX_VALIDATION_RULE_BY_ALIAS[value];
if (!validationRule) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Invalid checkbox placeholder validation rule: ${value}`,
});
}
return validationRule;
};
const parseSelectionFieldOptions = (
rawFieldMeta: Record<string, string>,
fieldType: FieldType,
): string[] | undefined => {
const rawOptions = rawFieldMeta.options;
if (rawOptions === undefined) {
return;
}
const parsedOptions = parsePlaceholderOptions(rawOptions);
if (parsedOptions.length === 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `${fieldType} placeholder options must contain at least one value`,
});
}
return parsedOptions;
};
const applyRadioFieldOptions = (parsedFieldMeta: Record<string, unknown>, rawFieldMeta: Record<string, string>) => {
const options = parseSelectionFieldOptions(rawFieldMeta, FieldType.RADIO);
const defaultValue = getDefaultFieldMetaValue(rawFieldMeta);
if (!options && defaultValue) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Radio placeholder default value requires options',
});
}
if (!options) {
return;
}
const selectedOptionIndex = defaultValue ? options.findIndex((option) => option === defaultValue) : -1;
if (defaultValue && selectedOptionIndex === -1) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Radio placeholder default value "${defaultValue}" must match one of the options`,
});
}
parsedFieldMeta.values = options.map((option, index) => ({
id: index + 1,
checked: index === selectedOptionIndex,
value: option,
}));
};
const applyCheckboxFieldOptions = (parsedFieldMeta: Record<string, unknown>, rawFieldMeta: Record<string, string>) => {
const options = parseSelectionFieldOptions(rawFieldMeta, FieldType.CHECKBOX);
const checkedValues = rawFieldMeta.checked ? parsePlaceholderOptions(rawFieldMeta.checked) : [];
if (!options && checkedValues.length > 0) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Checkbox placeholder checked values require options',
});
}
if (!options) {
return;
}
const unmatchedCheckedValues = checkedValues.filter((checkedValue) => !options.includes(checkedValue));
if (unmatchedCheckedValues.length > 0) {
const unmatchedCheckedValue = unmatchedCheckedValues[0];
throw new AppError(AppErrorCode.INVALID_BODY, {
message: [`Checkbox placeholder checked value "${unmatchedCheckedValue}"`, 'must match one of the options'].join(
' ',
),
});
}
parsedFieldMeta.values = options.map((option, index) => ({
id: index + 1,
checked: checkedValues.includes(option),
value: option,
}));
};
const applyDropdownFieldOptions = (parsedFieldMeta: Record<string, unknown>, rawFieldMeta: Record<string, string>) => {
const options = parseSelectionFieldOptions(rawFieldMeta, FieldType.DROPDOWN);
const defaultValue = getDefaultFieldMetaValue(rawFieldMeta);
if (!options && defaultValue) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Dropdown placeholder default value requires options',
});
}
if (!options) {
return;
}
if (defaultValue && !options.includes(defaultValue)) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: `Dropdown placeholder default value "${defaultValue}" must match one of the options`,
});
}
parsedFieldMeta.values = options.map((option) => ({
value: option,
}));
if (defaultValue) {
parsedFieldMeta.defaultValue = defaultValue;
}
};
/*
Generic field metadata properties are simple properties consisting of a key and a value.
E.g. 'required=true', 'fontSize=12', 'textAlign=left'
They don't require special handling.
Special field metadata properties are complex properties consisting of a key and a value with multiple parts.
E.g. 'options=Card/Check|Bank Transfer', 'checked=Card|Check', 'selected=Bank Transfer'
They require special handling.
*/
const shouldSkipGenericFieldMetaParsing = (property: string, fieldType: FieldType): boolean => {
if (property === 'options' || property === 'default' || property === 'selected') {
return true;
}
const isSelectionField =
fieldType === FieldType.CHECKBOX || fieldType === FieldType.RADIO || fieldType === FieldType.DROPDOWN;
if (!isSelectionField) {
return false;
}
if (
property === 'label' ||
property === 'placeholder' ||
property === 'defaultValue' ||
(fieldType === FieldType.CHECKBOX && property === 'checked')
) {
return true;
}
return false;
};
/*
Transform raw field metadata from placeholder format to schema format.
Users should provide properly capitalized property names (e.g., readOnly, fontSize, textAlign).
@@ -91,7 +382,7 @@ export const parseFieldMetaFromPlaceholder = (
const fieldTypeString = String(fieldType).toLowerCase();
const parsedFieldMeta: Record<string, boolean | number | string> = {
const parsedFieldMeta: Record<string, unknown> = {
type: fieldTypeString,
};
@@ -104,24 +395,39 @@ export const parseFieldMetaFromPlaceholder = (
const rawFieldMetaEntries = Object.entries(rawFieldMeta);
for (const [property, value] of rawFieldMetaEntries) {
if (shouldSkipGenericFieldMetaParsing(property, fieldType)) {
continue;
}
const unescapedValue = unescapePlaceholderValue(value);
if (property === 'readOnly' || property === 'required') {
parsedFieldMeta[property] = value === 'true';
parsedFieldMeta[property] = unescapedValue.toLowerCase() === 'true';
} else if (property === 'validationRule' && fieldType === FieldType.CHECKBOX) {
parsedFieldMeta[property] = parseCheckboxValidationRule(unescapedValue);
} else if (
property === 'fontSize' ||
property === 'maxValue' ||
property === 'minValue' ||
property === 'characterLimit'
property === 'characterLimit' ||
property === 'validationLength'
) {
const numValue = Number(value);
const numValue = Number(unescapedValue);
if (!Number.isNaN(numValue)) {
parsedFieldMeta[property] = numValue;
}
} else {
parsedFieldMeta[property] = value;
parsedFieldMeta[property] = unescapedValue;
}
}
match(fieldType)
.with(FieldType.RADIO, () => applyRadioFieldOptions(parsedFieldMeta, rawFieldMeta))
.with(FieldType.CHECKBOX, () => applyCheckboxFieldOptions(parsedFieldMeta, rawFieldMeta))
.with(FieldType.DROPDOWN, () => applyDropdownFieldOptions(parsedFieldMeta, rawFieldMeta))
.otherwise(() => undefined);
return parsedFieldMeta;
};
@@ -72,21 +72,6 @@ export const reportSenderRateLimit = createRateLimit({
window: '7d',
});
// ---- Signature (QR mobile handoff) ----
export const qrSignatureCreateRateLimit = createRateLimit({
action: 'signature.qr-create',
max: 20,
window: '15m',
});
export const qrSignatureCompleteRateLimit = createRateLimit({
action: 'signature.qr-complete',
max: 20,
globalMax: 60,
window: '15m',
});
// ---- Billing ----
export const syncSubscriptionRateLimit = createRateLimit({
@@ -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,
-23
View File
@@ -1,23 +0,0 @@
import { z } from 'zod';
/**
* The context a QR signature session is created for.
*
* - `PROFILE_SIGNATURE`: a standalone signature, e.g. the profile or signup
* forms. Carries no additional data.
* - `DOCUMENT_SIGNATURE`: a signature for a document signing flow. Carries the
* recipient token so the mobile page can render the document context.
*/
export const ZQrSignatureContextSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('PROFILE_SIGNATURE'),
}),
z.object({
type: z.literal('DOCUMENT_SIGNATURE'),
recipientToken: z.string().min(1).max(64),
}),
]);
export type TQrSignatureContext = z.infer<typeof ZQrSignatureContextSchema>;
export type TQrSignatureContextType = TQrSignatureContext['type'];
-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,
@@ -1,15 +0,0 @@
-- CreateEnum
CREATE TYPE "AnonymousVerificationTokenType" AS ENUM ('PASSKEY', 'QR_SIGNATURE');
-- AlterTable: add "type" as nullable, backfill existing rows (all are passkey
-- challenges today), then enforce NOT NULL.
ALTER TABLE "AnonymousVerificationToken" ADD COLUMN "type" "AnonymousVerificationTokenType";
UPDATE "AnonymousVerificationToken" SET "type" = 'PASSKEY';
ALTER TABLE "AnonymousVerificationToken" ALTER COLUMN "type" SET NOT NULL;
-- AlterTable
ALTER TABLE "AnonymousVerificationToken" ADD COLUMN "value" TEXT;
ALTER TABLE "AnonymousVerificationToken" ADD COLUMN "metadata" JSONB;
@@ -1,10 +0,0 @@
-- AlterTable: add with DEFAULT false so every existing row is backfilled to
-- disabled, then flip the column default to true so new rows are enabled.
ALTER TABLE "DocumentMeta" ADD COLUMN "qrSignatureEnabled" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "DocumentMeta" ALTER COLUMN "qrSignatureEnabled" SET DEFAULT true;
ALTER TABLE "OrganisationGlobalSettings" ADD COLUMN "qrSignatureEnabled" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "OrganisationGlobalSettings" ALTER COLUMN "qrSignatureEnabled" SET DEFAULT true;
-- Existing teams stay NULL (inherit from organisation).
ALTER TABLE "TeamGlobalSettings" ADD COLUMN "qrSignatureEnabled" BOOLEAN;
+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)
@@ -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(),
@@ -124,7 +124,9 @@ export const createEnvelopeRouteCaller = async ({
});
// Todo: Embeds - Might need to add this for client-side embeds in the future.
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized);
const { cleanedPdf, placeholders } = await extractPdfPlaceholders(normalized, {
fileName: file.name,
});
const { documentData } = await putPdfFileServerSide({
name: file.name,
@@ -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',
@@ -168,7 +165,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(),
-2
View File
@@ -10,7 +10,6 @@ import { folderRouter } from './folder-router/router';
import { organisationRouter } from './organisation-router/router';
import { profileRouter } from './profile-router/router';
import { recipientRouter } from './recipient-router/router';
import { signatureRouter } from './signature-router/router';
import { teamRouter } from './team-router/router';
import { templateRouter } from './template-router/router';
import { router } from './trpc';
@@ -25,7 +24,6 @@ export const appRouter = router({
field: fieldRouter,
folder: folderRouter,
recipient: recipientRouter,
signature: signatureRouter,
admin: adminRouter,
organisation: organisationRouter,
apiToken: apiTokenRouter,
@@ -1,72 +0,0 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { assertRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
import { qrSignatureCompleteRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
import { prisma } from '@documenso/prisma';
import { AnonymousVerificationTokenType } from '@prisma/client';
import { procedure } from '../../trpc';
import { ZCompleteQrSignatureRequestSchema, ZCompleteQrSignatureResponseSchema } from './complete-qr-signature.types';
/**
* NOTE: THIS IS A PUBLIC (UNAUTHENTICATED) PROCEDURE.
*
* Called from the mobile signing page to attach a drawn signature to a QR
* signature session. The desktop pad picks it up by polling `qr.get`.
*/
export const completeQrSignatureRoute = procedure
.input(ZCompleteQrSignatureRequestSchema)
.output(ZCompleteQrSignatureResponseSchema)
.mutation(async ({ input, ctx }) => {
const { token, signature } = input;
const { ipAddress } = ctx.metadata.requestMetadata;
const rateLimitResult = await qrSignatureCompleteRateLimit.check({
ip: ipAddress ?? 'unknown',
identifier: token,
});
assertRateLimit(rateLimitResult);
const qrSignatureSession = await prisma.anonymousVerificationToken.findFirst({
where: {
token,
type: AnonymousVerificationTokenType.QR_SIGNATURE,
},
});
if (!qrSignatureSession) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'QR signature session not found or expired',
});
}
if (qrSignatureSession.expiresAt < new Date()) {
throw new AppError(AppErrorCode.EXPIRED_CODE, {
message: 'QR signature session has expired',
});
}
if (qrSignatureSession.value) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'A signature has already been submitted for this session',
});
}
const { count: updatedCount } = await prisma.anonymousVerificationToken.updateMany({
where: {
id: qrSignatureSession.id,
type: AnonymousVerificationTokenType.QR_SIGNATURE,
value: null,
},
data: {
value: signature,
},
});
if (updatedCount === 0) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'A signature has already been submitted for this session',
});
}
});
@@ -1,17 +0,0 @@
import { isBase64Image } from '@documenso/lib/constants/signatures';
import { z } from 'zod';
export const ZCompleteQrSignatureRequestSchema = z.object({
token: z.string().min(1).max(64).describe('The QR signature session token'),
signature: z
.string()
.min(1)
.max(1_000_000)
.refine((value) => isBase64Image(value), {
message: 'Signature must be a base64 encoded PNG image',
}),
});
export const ZCompleteQrSignatureResponseSchema = z.void();
export type TCompleteQrSignatureRequest = z.infer<typeof ZCompleteQrSignatureRequestSchema>;
@@ -1,64 +0,0 @@
import { QR_SIGNATURE_TOKEN_EXPIRY_MINUTES } from '@documenso/lib/constants/signatures';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { assertRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
import { qrSignatureCreateRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { AnonymousVerificationTokenType } from '@prisma/client';
import { DateTime } from 'luxon';
import { procedure } from '../../trpc';
import { ZCreateQrSignatureRequestSchema, ZCreateQrSignatureResponseSchema } from './create-qr-signature.types';
/**
* NOTE: THIS IS A PUBLIC (UNAUTHENTICATED) PROCEDURE.
*
* Creates a short-lived anonymous session which allows a signature drawn on a
* mobile device to be handed off to the desktop signature pad. The token is
* the sole authorization for the session.
*/
export const createQrSignatureRoute = procedure
.input(ZCreateQrSignatureRequestSchema)
.output(ZCreateQrSignatureResponseSchema)
.mutation(async ({ input, ctx }) => {
const { context } = input;
const { ipAddress } = ctx.metadata.requestMetadata;
const rateLimitResult = await qrSignatureCreateRateLimit.check({
ip: ipAddress ?? 'unknown',
});
assertRateLimit(rateLimitResult);
if (context?.type === 'DOCUMENT_SIGNATURE') {
const recipient = await prisma.recipient.findFirst({
where: {
token: context.recipientToken,
},
select: {
id: true,
},
});
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found for the provided token',
});
}
}
const qrSignatureSession = await prisma.anonymousVerificationToken.create({
data: {
type: AnonymousVerificationTokenType.QR_SIGNATURE,
token: nanoid(),
metadata: context ? { context } : undefined,
expiresAt: DateTime.now().plus({ minutes: QR_SIGNATURE_TOKEN_EXPIRY_MINUTES }).toJSDate(),
},
});
return {
token: qrSignatureSession.token,
expiresAt: qrSignatureSession.expiresAt,
};
});
@@ -1,14 +0,0 @@
import { ZQrSignatureContextSchema } from '@documenso/lib/types/qr-signature';
import { z } from 'zod';
export const ZCreateQrSignatureRequestSchema = z.object({
context: ZQrSignatureContextSchema.nullish(),
});
export const ZCreateQrSignatureResponseSchema = z.object({
token: z.string(),
expiresAt: z.date(),
});
export type TCreateQrSignatureRequest = z.infer<typeof ZCreateQrSignatureRequestSchema>;
export type TCreateQrSignatureResponse = z.infer<typeof ZCreateQrSignatureResponseSchema>;
@@ -1,99 +0,0 @@
import { ZQrSignatureContextSchema } from '@documenso/lib/types/qr-signature';
import { prisma } from '@documenso/prisma';
import { AnonymousVerificationTokenType } from '@prisma/client';
import { z } from 'zod';
import { procedure } from '../../trpc';
import {
ZGetQrSignatureSessionRequestSchema,
ZGetQrSignatureSessionResponseSchema,
} from './get-qr-signature-session.types';
const ZSessionMetadataSchema = z.object({
context: ZQrSignatureContextSchema,
});
/**
* NOTE: THIS IS A PUBLIC (UNAUTHENTICATED) PROCEDURE.
*
* Classify a QR signature session token for the mobile signing page and
* resolve the context stored on the session.
*
* A missing row is indistinguishable from an expired one by design.
*
* Called once per page load; the global trpc rate limit covers it, matching
* the polling `qr.get` route.
*/
export const getQrSignatureSessionRoute = procedure
.input(ZGetQrSignatureSessionRequestSchema)
.output(ZGetQrSignatureSessionResponseSchema)
.query(async ({ input }) => {
const { token } = input;
const qrSignatureSession = await prisma.anonymousVerificationToken.findUnique({
where: {
token,
type: AnonymousVerificationTokenType.QR_SIGNATURE,
},
});
if (!qrSignatureSession || qrSignatureSession.expiresAt < new Date()) {
return { status: 'EXPIRED' } as const;
}
if (qrSignatureSession.value) {
return { status: 'ALREADY_SUBMITTED' } as const;
}
const parsedMetadata = ZSessionMetadataSchema.nullish().safeParse(qrSignatureSession.metadata);
if (!parsedMetadata.success) {
return { status: 'INVALID' } as const;
}
// Sessions created without a context are valid, but generic.
if (!parsedMetadata.data) {
return { status: 'VALID', context: { type: 'NONE' } } as const;
}
const { context } = parsedMetadata.data;
if (context.type === 'PROFILE_SIGNATURE') {
return { status: 'VALID', context: { type: context.type } } as const;
}
if (context.recipientToken.length < 1) {
return { status: 'INVALID' } as const;
}
const recipient = await prisma.recipient.findFirst({
where: {
token: context.recipientToken,
},
select: {
envelope: {
select: {
title: true,
team: {
select: {
name: true,
},
},
},
},
},
});
if (!recipient) {
return { status: 'INVALID' } as const;
}
return {
status: 'VALID',
context: {
type: 'DOCUMENT_SIGNATURE',
documentTitle: recipient.envelope.title,
teamName: recipient.envelope.team.name,
},
} as const;
});
@@ -1,47 +0,0 @@
import { z } from 'zod';
export const ZGetQrSignatureSessionRequestSchema = z.object({
token: z.string().min(1).max(64).describe('The QR signature session token'),
});
/**
* The resolved context of a valid QR signature session.
*
* `NONE` is a session created without any context, in which case the mobile
* page shows a generic "Signature requested".
*/
export const ZQrSignatureSessionContextSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('NONE'),
}),
z.object({
type: z.literal('PROFILE_SIGNATURE'),
}),
z.object({
type: z.literal('DOCUMENT_SIGNATURE'),
documentTitle: z.string(),
teamName: z.string(),
}),
]);
export const ZGetQrSignatureSessionResponseSchema = z.discriminatedUnion('status', [
z.object({
status: z.literal('EXPIRED'),
}),
z.object({
status: z.literal('ALREADY_SUBMITTED'),
}),
z.object({
// The session references a signing flow that no longer exists, or carries
// malformed metadata.
status: z.literal('INVALID'),
}),
z.object({
status: z.literal('VALID'),
context: ZQrSignatureSessionContextSchema,
}),
]);
export type TGetQrSignatureSessionRequest = z.infer<typeof ZGetQrSignatureSessionRequestSchema>;
export type TGetQrSignatureSessionResponse = z.infer<typeof ZGetQrSignatureSessionResponseSchema>;
export type TQrSignatureSessionContext = z.infer<typeof ZQrSignatureSessionContextSchema>;
@@ -1,57 +0,0 @@
import { prisma } from '@documenso/prisma';
import { AnonymousVerificationTokenType } from '@prisma/client';
import { procedure } from '../../trpc';
import { ZGetQrSignatureRequestSchema, ZGetQrSignatureResponseSchema } from './get-qr-signature.types';
/**
* NOTE: THIS IS A PUBLIC (UNAUTHENTICATED) PROCEDURE.
*
* Polled by the desktop signature pad while waiting for a mobile signature.
*
* A missing row is indistinguishable from an expired one by design, so we
* return EXPIRED for both. Once the signature is returned the row is deleted,
* making the token single-use.
*/
export const getQrSignatureRoute = procedure
.input(ZGetQrSignatureRequestSchema)
.output(ZGetQrSignatureResponseSchema)
.query(async ({ input }) => {
const { token } = input;
const qrSignatureSession = await prisma.anonymousVerificationToken.findUnique({
where: {
token,
type: AnonymousVerificationTokenType.QR_SIGNATURE,
},
});
if (!qrSignatureSession || qrSignatureSession.expiresAt < new Date()) {
return {
status: 'EXPIRED',
} as const;
}
if (!qrSignatureSession.value) {
return {
status: 'PENDING',
} as const;
}
const { count: deletedCount } = await prisma.anonymousVerificationToken.deleteMany({
where: {
id: qrSignatureSession.id,
},
});
if (deletedCount === 0) {
return {
status: 'EXPIRED',
} as const;
}
return {
status: 'COMPLETED',
signature: qrSignatureSession.value,
} as const;
});
@@ -1,21 +0,0 @@
import { z } from 'zod';
export const ZGetQrSignatureRequestSchema = z.object({
token: z.string().min(1).max(64).describe('The QR signature session token to poll'),
});
export const ZGetQrSignatureResponseSchema = z.discriminatedUnion('status', [
z.object({
status: z.literal('PENDING'),
}),
z.object({
status: z.literal('EXPIRED'),
}),
z.object({
status: z.literal('COMPLETED'),
signature: z.string(),
}),
]);
export type TGetQrSignatureRequest = z.infer<typeof ZGetQrSignatureRequestSchema>;
export type TGetQrSignatureResponse = z.infer<typeof ZGetQrSignatureResponseSchema>;
@@ -1,14 +0,0 @@
import { router } from '../trpc';
import { completeQrSignatureRoute } from './qr/complete-qr-signature';
import { createQrSignatureRoute } from './qr/create-qr-signature';
import { getQrSignatureRoute } from './qr/get-qr-signature';
import { getQrSignatureSessionRoute } from './qr/get-qr-signature-session';
export const signatureRouter = router({
qr: {
create: createQrSignatureRoute,
get: getQrSignatureRoute,
getSession: getQrSignatureSessionRoute,
complete: completeQrSignatureRoute,
},
});
@@ -36,7 +36,6 @@ export const updateTeamSettingsRoute = authenticatedProcedure
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
qrSignatureEnabled,
delegateDocumentOwnership,
envelopeExpirationPeriod,
reminderSettings,
@@ -67,12 +66,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
}
// Signatures will only be inherited if all are NULL.
if (
typedSignatureEnabled === false &&
uploadSignatureEnabled === false &&
drawSignatureEnabled === false &&
qrSignatureEnabled === false
) {
if (typedSignatureEnabled === false && uploadSignatureEnabled === false && drawSignatureEnabled === false) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'At least one signature type must be enabled',
});
@@ -174,7 +168,6 @@ export const updateTeamSettingsRoute = authenticatedProcedure
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
qrSignatureEnabled,
delegateDocumentOwnership,
envelopeExpirationPeriod: envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod,
reminderSettings: reminderSettings === null ? Prisma.DbNull : reminderSettings,
@@ -29,7 +29,6 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
typedSignatureEnabled: z.boolean().nullish(),
uploadSignatureEnabled: z.boolean().nullish(),
drawSignatureEnabled: z.boolean().nullish(),
qrSignatureEnabled: z.boolean().nullish(),
delegateDocumentOwnership: z.boolean().nullish(),
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
reminderSettings: ZEnvelopeReminderSettings.nullish(),

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