mirror of
https://github.com/documenso/documenso.git
synced 2026-08-23 23:02:22 +10:00
feat: add qr signatures
This commit is contained in:
@@ -465,6 +465,7 @@ const response = await fetch(`${BASE_URL}/template/use`, {
|
|||||||
typedSignatureEnabled: true,
|
typedSignatureEnabled: true,
|
||||||
uploadSignatureEnabled: false,
|
uploadSignatureEnabled: false,
|
||||||
drawSignatureEnabled: true,
|
drawSignatureEnabled: true,
|
||||||
|
qrSignatureEnabled: true,
|
||||||
},
|
},
|
||||||
distributeDocument: true,
|
distributeDocument: true,
|
||||||
}),
|
}),
|
||||||
@@ -485,6 +486,7 @@ const response = await fetch(`${BASE_URL}/template/use`, {
|
|||||||
| `typedSignatureEnabled` | boolean | Allow typed signatures |
|
| `typedSignatureEnabled` | boolean | Allow typed signatures |
|
||||||
| `uploadSignatureEnabled` | boolean | Allow uploaded signature images |
|
| `uploadSignatureEnabled` | boolean | Allow uploaded signature images |
|
||||||
| `drawSignatureEnabled` | boolean | Allow drawn signatures |
|
| `drawSignatureEnabled` | boolean | Allow drawn signatures |
|
||||||
|
| `qrSignatureEnabled` | boolean | Allow QR code handoff to a mobile device |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -390,6 +390,7 @@ const response = await fetch(`${BASE_URL}/template/update`, {
|
|||||||
typedSignatureEnabled: true, // Allow typed signatures
|
typedSignatureEnabled: true, // Allow typed signatures
|
||||||
drawSignatureEnabled: true, // Allow drawn signatures
|
drawSignatureEnabled: true, // Allow drawn signatures
|
||||||
uploadSignatureEnabled: false, // Disable uploaded signatures
|
uploadSignatureEnabled: false, // Disable uploaded signatures
|
||||||
|
qrSignatureEnabled: true, // Allow QR code handoff to a mobile device
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ All webhook events share a common structure:
|
|||||||
| `typedSignatureEnabled` | boolean | Whether typed signatures are allowed |
|
| `typedSignatureEnabled` | boolean | Whether typed signatures are allowed |
|
||||||
| `uploadSignatureEnabled` | boolean | Whether uploaded signatures are allowed |
|
| `uploadSignatureEnabled` | boolean | Whether uploaded signatures are allowed |
|
||||||
| `drawSignatureEnabled` | boolean | Whether drawn 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 |
|
| `language` | string | Document language code |
|
||||||
| `distributionMethod` | string | How document is distributed |
|
| `distributionMethod` | string | How document is distributed |
|
||||||
| `emailSettings` | object? | Custom email settings for this document |
|
| `emailSettings` | object? | Custom email settings for this document |
|
||||||
@@ -141,6 +142,7 @@ Triggered when a new document is created.
|
|||||||
"typedSignatureEnabled": true,
|
"typedSignatureEnabled": true,
|
||||||
"uploadSignatureEnabled": true,
|
"uploadSignatureEnabled": true,
|
||||||
"drawSignatureEnabled": true,
|
"drawSignatureEnabled": true,
|
||||||
|
"qrSignatureEnabled": true,
|
||||||
"language": "en",
|
"language": "en",
|
||||||
"distributionMethod": "EMAIL",
|
"distributionMethod": "EMAIL",
|
||||||
"emailSettings": null
|
"emailSettings": null
|
||||||
@@ -235,6 +237,7 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
|
|||||||
"typedSignatureEnabled": true,
|
"typedSignatureEnabled": true,
|
||||||
"uploadSignatureEnabled": true,
|
"uploadSignatureEnabled": true,
|
||||||
"drawSignatureEnabled": true,
|
"drawSignatureEnabled": true,
|
||||||
|
"qrSignatureEnabled": true,
|
||||||
"language": "en",
|
"language": "en",
|
||||||
"distributionMethod": "EMAIL",
|
"distributionMethod": "EMAIL",
|
||||||
"emailSettings": null
|
"emailSettings": null
|
||||||
@@ -435,6 +438,7 @@ The document status changes to `COMPLETED` and `completedAt` is set.
|
|||||||
"typedSignatureEnabled": true,
|
"typedSignatureEnabled": true,
|
||||||
"uploadSignatureEnabled": true,
|
"uploadSignatureEnabled": true,
|
||||||
"drawSignatureEnabled": true,
|
"drawSignatureEnabled": true,
|
||||||
|
"qrSignatureEnabled": true,
|
||||||
"language": "en",
|
"language": "en",
|
||||||
"distributionMethod": "EMAIL",
|
"distributionMethod": "EMAIL",
|
||||||
"emailSettings": null
|
"emailSettings": null
|
||||||
@@ -618,6 +622,7 @@ This event is **not** triggered when a recipient hides a document from their inb
|
|||||||
"typedSignatureEnabled": true,
|
"typedSignatureEnabled": true,
|
||||||
"uploadSignatureEnabled": true,
|
"uploadSignatureEnabled": true,
|
||||||
"drawSignatureEnabled": true,
|
"drawSignatureEnabled": true,
|
||||||
|
"qrSignatureEnabled": true,
|
||||||
"language": "en",
|
"language": "en",
|
||||||
"distributionMethod": "EMAIL",
|
"distributionMethod": "EMAIL",
|
||||||
"emailSettings": null
|
"emailSettings": null
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { TQrSignatureContext } from '@documenso/lib/types/qr-signature';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||||
@@ -13,10 +14,21 @@ export type SignFieldSignatureDialogProps = {
|
|||||||
typedSignatureEnabled?: boolean;
|
typedSignatureEnabled?: boolean;
|
||||||
uploadSignatureEnabled?: boolean;
|
uploadSignatureEnabled?: boolean;
|
||||||
drawSignatureEnabled?: boolean;
|
drawSignatureEnabled?: boolean;
|
||||||
|
qrSignatureEnabled?: boolean;
|
||||||
|
qrSignatureContext?: TQrSignatureContext;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SignFieldSignatureDialog = createCallable<SignFieldSignatureDialogProps, string | null>(
|
export const SignFieldSignatureDialog = createCallable<SignFieldSignatureDialogProps, string | null>(
|
||||||
({ call, fullName, typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled, initialSignature }) => {
|
({
|
||||||
|
call,
|
||||||
|
fullName,
|
||||||
|
typedSignatureEnabled,
|
||||||
|
uploadSignatureEnabled,
|
||||||
|
drawSignatureEnabled,
|
||||||
|
qrSignatureEnabled,
|
||||||
|
qrSignatureContext,
|
||||||
|
initialSignature,
|
||||||
|
}) => {
|
||||||
const [localSignature, setLocalSignature] = useState(initialSignature);
|
const [localSignature, setLocalSignature] = useState(initialSignature);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -36,6 +48,8 @@ export const SignFieldSignatureDialog = createCallable<SignFieldSignatureDialogP
|
|||||||
typedSignatureEnabled={typedSignatureEnabled}
|
typedSignatureEnabled={typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={uploadSignatureEnabled}
|
uploadSignatureEnabled={uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={drawSignatureEnabled}
|
drawSignatureEnabled={drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={qrSignatureEnabled}
|
||||||
|
qrSignatureContext={qrSignatureContext}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -470,6 +470,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={metadata?.qrSignatureEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -33,7 +33,12 @@ export type EmbedDocumentFieldsProps = {
|
|||||||
fields: Field[];
|
fields: Field[];
|
||||||
metadata?: Pick<
|
metadata?: Pick<
|
||||||
DocumentMeta,
|
DocumentMeta,
|
||||||
'timezone' | 'dateFormat' | 'typedSignatureEnabled' | 'uploadSignatureEnabled' | 'drawSignatureEnabled'
|
| 'timezone'
|
||||||
|
| 'dateFormat'
|
||||||
|
| 'typedSignatureEnabled'
|
||||||
|
| 'uploadSignatureEnabled'
|
||||||
|
| 'drawSignatureEnabled'
|
||||||
|
| 'qrSignatureEnabled'
|
||||||
> | null;
|
> | null;
|
||||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||||
@@ -53,6 +58,7 @@ export const EmbedDocumentFields = ({ fields, metadata, onSignField, onUnsignFie
|
|||||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={metadata?.qrSignatureEnabled}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
.with(FieldType.INITIALS, () => (
|
.with(FieldType.INITIALS, () => (
|
||||||
|
|||||||
@@ -461,6 +461,8 @@ export const EmbedSignDocumentV1ClientPage = ({
|
|||||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={metadata?.qrSignatureEnabled}
|
||||||
|
qrSignatureContext={{ type: 'DOCUMENT_SIGNATURE', recipientToken: recipient.token }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -313,6 +313,7 @@ export const MultiSignDocumentSigningView = ({
|
|||||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={document.documentMeta?.qrSignatureEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ type SettingsSubset = Pick<
|
|||||||
| 'typedSignatureEnabled'
|
| 'typedSignatureEnabled'
|
||||||
| 'uploadSignatureEnabled'
|
| 'uploadSignatureEnabled'
|
||||||
| 'drawSignatureEnabled'
|
| 'drawSignatureEnabled'
|
||||||
|
| 'qrSignatureEnabled'
|
||||||
| 'defaultRecipients'
|
| 'defaultRecipients'
|
||||||
| 'delegateDocumentOwnership'
|
| 'delegateDocumentOwnership'
|
||||||
| 'aiFeaturesEnabled'
|
| 'aiFeaturesEnabled'
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<SignaturePadDialog
|
<SignaturePadDialog
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
|
qrSignatureContext={{ type: 'PROFILE_SIGNATURE' }}
|
||||||
fullName={user.name ?? ''}
|
fullName={user.name ?? ''}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(v) => onChange(v ?? '')}
|
onChange={(v) => onChange(v ?? '')}
|
||||||
|
|||||||
@@ -314,6 +314,7 @@ export const SignUpForm = ({
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<SignaturePadDialog
|
<SignaturePadDialog
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
|
qrSignatureContext={{ type: 'PROFILE_SIGNATURE' }}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(v) => onChange(v ?? '')}
|
onChange={(v) => onChange(v ?? '')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -156,6 +156,10 @@ export const AdminGlobalSettingsSection = ({
|
|||||||
</DetailsValue>
|
</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|
||||||
|
<DetailsCard label={<Trans>QR signature</Trans>}>
|
||||||
|
<DetailsValue>{booleanValue(settings.qrSignatureEnabled, inheritedSettings?.qrSignatureEnabled)}</DetailsValue>
|
||||||
|
</DetailsCard>
|
||||||
|
|
||||||
<DetailsCard label={<Trans>Branding</Trans>}>
|
<DetailsCard label={<Trans>Branding</Trans>}>
|
||||||
<DetailsValue>{booleanValue(settings.brandingEnabled, inheritedSettings?.brandingEnabled)}</DetailsValue>
|
<DetailsValue>{booleanValue(settings.brandingEnabled, inheritedSettings?.brandingEnabled)}</DetailsValue>
|
||||||
</DetailsCard>
|
</DetailsCard>
|
||||||
|
|||||||
@@ -269,6 +269,7 @@ export const DirectTemplateSigningForm = ({
|
|||||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={template.templateMeta?.qrSignatureEnabled}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
.with(FieldType.INITIALS, () => (
|
.with(FieldType.INITIALS, () => (
|
||||||
@@ -408,6 +409,7 @@ export const DirectTemplateSigningForm = ({
|
|||||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={template.templateMeta?.qrSignatureEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -254,6 +254,8 @@ export const DocumentSigningForm = ({
|
|||||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={document.documentMeta?.qrSignatureEnabled}
|
||||||
|
qrSignatureContext={{ type: 'DOCUMENT_SIGNATURE', recipientToken: recipient.token }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -408,6 +408,7 @@ export const DocumentSigningPageViewV1 = ({
|
|||||||
typedSignatureEnabled={documentMeta?.typedSignatureEnabled}
|
typedSignatureEnabled={documentMeta?.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={documentMeta?.uploadSignatureEnabled}
|
uploadSignatureEnabled={documentMeta?.uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={documentMeta?.drawSignatureEnabled}
|
drawSignatureEnabled={documentMeta?.drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={documentMeta?.qrSignatureEnabled}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
.with(FieldType.INITIALS, () => <DocumentSigningInitialsField key={field.id} field={field} />)
|
.with(FieldType.INITIALS, () => <DocumentSigningInitialsField key={field.id} field={field} />)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export interface DocumentSigningProviderProps {
|
|||||||
typedSignatureEnabled?: boolean;
|
typedSignatureEnabled?: boolean;
|
||||||
uploadSignatureEnabled?: boolean;
|
uploadSignatureEnabled?: boolean;
|
||||||
drawSignatureEnabled?: boolean;
|
drawSignatureEnabled?: boolean;
|
||||||
|
qrSignatureEnabled?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ export const DocumentSigningProvider = ({
|
|||||||
typedSignatureEnabled = true,
|
typedSignatureEnabled = true,
|
||||||
uploadSignatureEnabled = true,
|
uploadSignatureEnabled = true,
|
||||||
drawSignatureEnabled = true,
|
drawSignatureEnabled = true,
|
||||||
|
qrSignatureEnabled = true,
|
||||||
children,
|
children,
|
||||||
}: DocumentSigningProviderProps) => {
|
}: DocumentSigningProviderProps) => {
|
||||||
const [fullName, setFullName] = useState(initialFullName || '');
|
const [fullName, setFullName] = useState(initialFullName || '');
|
||||||
@@ -54,7 +56,7 @@ export const DocumentSigningProvider = ({
|
|||||||
const sig = initialSignature || '';
|
const sig = initialSignature || '';
|
||||||
const isBase64 = isBase64Image(sig);
|
const isBase64 = isBase64Image(sig);
|
||||||
|
|
||||||
if (isBase64 && (uploadSignatureEnabled || drawSignatureEnabled)) {
|
if (isBase64 && (uploadSignatureEnabled || drawSignatureEnabled || qrSignatureEnabled)) {
|
||||||
return sig;
|
return sig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
@@ -34,6 +34,7 @@ export type DocumentSigningSignatureFieldProps = {
|
|||||||
typedSignatureEnabled?: boolean;
|
typedSignatureEnabled?: boolean;
|
||||||
uploadSignatureEnabled?: boolean;
|
uploadSignatureEnabled?: boolean;
|
||||||
drawSignatureEnabled?: boolean;
|
drawSignatureEnabled?: boolean;
|
||||||
|
qrSignatureEnabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentSigningSignatureField = ({
|
export const DocumentSigningSignatureField = ({
|
||||||
@@ -43,6 +44,7 @@ export const DocumentSigningSignatureField = ({
|
|||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
uploadSignatureEnabled,
|
uploadSignatureEnabled,
|
||||||
drawSignatureEnabled,
|
drawSignatureEnabled,
|
||||||
|
qrSignatureEnabled,
|
||||||
}: DocumentSigningSignatureFieldProps) => {
|
}: DocumentSigningSignatureFieldProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -279,6 +281,8 @@ export const DocumentSigningSignatureField = ({
|
|||||||
typedSignatureEnabled={typedSignatureEnabled}
|
typedSignatureEnabled={typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={uploadSignatureEnabled}
|
uploadSignatureEnabled={uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={drawSignatureEnabled}
|
drawSignatureEnabled={drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={qrSignatureEnabled}
|
||||||
|
qrSignatureContext={{ type: 'DOCUMENT_SIGNATURE', recipientToken: recipient.token }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DocumentSigningDisclosure />
|
<DocumentSigningDisclosure />
|
||||||
|
|||||||
@@ -172,7 +172,9 @@ export const EnvelopeSigningProvider = ({
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
!sig &&
|
!sig &&
|
||||||
(envelope.documentMeta.uploadSignatureEnabled || envelope.documentMeta.drawSignatureEnabled) &&
|
(envelope.documentMeta.uploadSignatureEnabled ||
|
||||||
|
envelope.documentMeta.drawSignatureEnabled ||
|
||||||
|
envelope.documentMeta.qrSignatureEnabled) &&
|
||||||
envelopeData.recipientSignature?.signatureImageAsBase64
|
envelopeData.recipientSignature?.signatureImageAsBase64
|
||||||
) {
|
) {
|
||||||
return envelopeData.recipientSignature.signatureImageAsBase64;
|
return envelopeData.recipientSignature.signatureImageAsBase64;
|
||||||
@@ -182,7 +184,12 @@ export const EnvelopeSigningProvider = ({
|
|||||||
return envelopeData.recipientSignature.typedSignature;
|
return envelopeData.recipientSignature.typedSignature;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isBase64 && (envelope.documentMeta.uploadSignatureEnabled || envelope.documentMeta.drawSignatureEnabled)) {
|
if (
|
||||||
|
isBase64 &&
|
||||||
|
(envelope.documentMeta.uploadSignatureEnabled ||
|
||||||
|
envelope.documentMeta.drawSignatureEnabled ||
|
||||||
|
envelope.documentMeta.qrSignatureEnabled)
|
||||||
|
) {
|
||||||
return sig;
|
return sig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ export const DocumentEditForm = ({ className, initialDocument, documentRootPath
|
|||||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
|
qrSignatureEnabled: signatureTypes.includes(DocumentSignatureType.QR),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -278,6 +278,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
|
|||||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
|
qrSignatureEnabled: signatureTypes.includes(DocumentSignatureType.QR),
|
||||||
envelopeExpirationPeriod,
|
envelopeExpirationPeriod,
|
||||||
reminderSettings,
|
reminderSettings,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ export default function EnvelopeSignerForm() {
|
|||||||
typedSignatureEnabled={envelope.documentMeta.typedSignatureEnabled}
|
typedSignatureEnabled={envelope.documentMeta.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={envelope.documentMeta.uploadSignatureEnabled}
|
uploadSignatureEnabled={envelope.documentMeta.uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={envelope.documentMeta.drawSignatureEnabled}
|
drawSignatureEnabled={envelope.documentMeta.drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={envelope.documentMeta.qrSignatureEnabled}
|
||||||
|
qrSignatureContext={{ type: 'DOCUMENT_SIGNATURE', recipientToken: recipient.token }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -384,6 +384,8 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
|||||||
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled,
|
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled,
|
||||||
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled,
|
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled,
|
||||||
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled,
|
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled,
|
||||||
|
qrSignatureEnabled: envelope.documentMeta.qrSignatureEnabled,
|
||||||
|
recipientToken: envelopeData.recipient.token,
|
||||||
})
|
})
|
||||||
.then(async (payload) => {
|
.then(async (payload) => {
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ export const TemplateEditForm = ({ initialTemplate, className, templateRootPath
|
|||||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
|
qrSignatureEnabled: signatureTypes.includes(DocumentSignatureType.QR),
|
||||||
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
|
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export default function OrganisationSettingsDocumentPage() {
|
|||||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
|
qrSignatureEnabled: signatureTypes.includes(DocumentSignatureType.QR),
|
||||||
delegateDocumentOwnership,
|
delegateDocumentOwnership,
|
||||||
aiFeaturesEnabled,
|
aiFeaturesEnabled,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -50,11 +50,13 @@ export default function TeamsSettingsPage() {
|
|||||||
typedSignatureEnabled: null,
|
typedSignatureEnabled: null,
|
||||||
uploadSignatureEnabled: null,
|
uploadSignatureEnabled: null,
|
||||||
drawSignatureEnabled: null,
|
drawSignatureEnabled: null,
|
||||||
|
qrSignatureEnabled: null,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
|
qrSignatureEnabled: signatureTypes.includes(DocumentSignatureType.QR),
|
||||||
}),
|
}),
|
||||||
delegateDocumentOwnership,
|
delegateDocumentOwnership,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV
|
|||||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={template.templateMeta?.qrSignatureEnabled}
|
||||||
>
|
>
|
||||||
<DocumentSigningAuthProvider
|
<DocumentSigningAuthProvider
|
||||||
documentAuthOptions={template.authOptions}
|
documentAuthOptions={template.authOptions}
|
||||||
|
|||||||
@@ -474,6 +474,7 @@ const SigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loade
|
|||||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={document.documentMeta?.qrSignatureEnabled}
|
||||||
>
|
>
|
||||||
<DocumentSigningAuthProvider documentAuthOptions={document.authOptions} recipient={recipient} user={user}>
|
<DocumentSigningAuthProvider documentAuthOptions={document.authOptions} recipient={recipient} user={user}>
|
||||||
{sessionData?.user && <AuthenticatedHeader />}
|
{sessionData?.user && <AuthenticatedHeader />}
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
|
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
|
||||||
import { Outlet } from 'react-router';
|
import { Outlet, useLocation } from 'react-router';
|
||||||
|
|
||||||
export default function Layout() {
|
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 (
|
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">
|
<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>
|
<div>
|
||||||
<div className="absolute -inset-[min(600px,max(400px,60vw))] -z-[1] flex items-center justify-center opacity-70">
|
{!hideBackground && (
|
||||||
<img
|
<div className="absolute -inset-[min(600px,max(400px,60vw))] -z-[1] flex items-center justify-center opacity-70">
|
||||||
src={backgroundPattern}
|
<img
|
||||||
alt="background pattern"
|
src={backgroundPattern}
|
||||||
className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
|
alt="background pattern"
|
||||||
style={{
|
className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
|
||||||
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
style={{
|
||||||
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
||||||
}}
|
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
||||||
/>
|
}}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
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,6 +266,7 @@ const EmbedDirectTemplatePageV1 = ({ data }: { data: Awaited<ReturnType<typeof h
|
|||||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={template.templateMeta?.qrSignatureEnabled}
|
||||||
>
|
>
|
||||||
<DocumentSigningAuthProvider documentAuthOptions={template.authOptions} recipient={recipient} user={user}>
|
<DocumentSigningAuthProvider documentAuthOptions={template.authOptions} recipient={recipient} user={user}>
|
||||||
<DocumentSigningRecipientProvider recipient={recipient}>
|
<DocumentSigningRecipientProvider recipient={recipient}>
|
||||||
|
|||||||
@@ -354,6 +354,7 @@ const EmbedSignDocumentPageV1 = ({ data }: { data: Awaited<ReturnType<typeof han
|
|||||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={document.documentMeta?.qrSignatureEnabled}
|
||||||
>
|
>
|
||||||
<DocumentSigningAuthProvider documentAuthOptions={document.authOptions} recipient={recipient} user={user}>
|
<DocumentSigningAuthProvider documentAuthOptions={document.authOptions} recipient={recipient} user={user}>
|
||||||
<EmbedSignDocumentV1ClientPage
|
<EmbedSignDocumentV1ClientPage
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export default function EmbeddingAuthoringDocumentCreatePage() {
|
|||||||
drawSignatureEnabled: signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.DRAW),
|
drawSignatureEnabled: signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||||
typedSignatureEnabled: signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.TYPE),
|
typedSignatureEnabled: signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||||
uploadSignatureEnabled: signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
uploadSignatureEnabled: signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||||
|
qrSignatureEnabled: signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.QR),
|
||||||
},
|
},
|
||||||
recipients: configuration.signers.map((signer) => ({
|
recipients: configuration.signers.map((signer) => ({
|
||||||
name: signer.name,
|
name: signer.name,
|
||||||
|
|||||||
@@ -101,6 +101,10 @@ export default function EmbeddingAuthoringDocumentEditPage() {
|
|||||||
types.push(DocumentSignatureType.UPLOAD);
|
types.push(DocumentSignatureType.UPLOAD);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (document.documentMeta?.qrSignatureEnabled) {
|
||||||
|
types.push(DocumentSignatureType.QR);
|
||||||
|
}
|
||||||
|
|
||||||
return types;
|
return types;
|
||||||
}, [document.documentMeta]);
|
}, [document.documentMeta]);
|
||||||
|
|
||||||
@@ -216,6 +220,10 @@ export default function EmbeddingAuthoringDocumentEditPage() {
|
|||||||
? configuration.meta.signatureTypes.length === 0 ||
|
? configuration.meta.signatureTypes.length === 0 ||
|
||||||
configuration.meta.signatureTypes.includes(DocumentSignatureType.UPLOAD)
|
configuration.meta.signatureTypes.includes(DocumentSignatureType.UPLOAD)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
qrSignatureEnabled: configuration.meta.signatureTypes
|
||||||
|
? configuration.meta.signatureTypes.length === 0 ||
|
||||||
|
configuration.meta.signatureTypes.includes(DocumentSignatureType.QR)
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
recipients: configuration.signers.map((signer) => ({
|
recipients: configuration.signers.map((signer) => ({
|
||||||
id: signer.nativeId,
|
id: signer.nativeId,
|
||||||
|
|||||||
@@ -101,6 +101,10 @@ export default function EmbeddingAuthoringTemplateEditPage() {
|
|||||||
types.push(DocumentSignatureType.UPLOAD);
|
types.push(DocumentSignatureType.UPLOAD);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (template.templateMeta?.qrSignatureEnabled) {
|
||||||
|
types.push(DocumentSignatureType.QR);
|
||||||
|
}
|
||||||
|
|
||||||
return types;
|
return types;
|
||||||
}, [template.templateMeta]);
|
}, [template.templateMeta]);
|
||||||
|
|
||||||
@@ -215,6 +219,10 @@ export default function EmbeddingAuthoringTemplateEditPage() {
|
|||||||
? configuration.meta.signatureTypes.length === 0 ||
|
? configuration.meta.signatureTypes.length === 0 ||
|
||||||
configuration.meta.signatureTypes.includes(DocumentSignatureType.UPLOAD)
|
configuration.meta.signatureTypes.includes(DocumentSignatureType.UPLOAD)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
qrSignatureEnabled: configuration.meta.signatureTypes
|
||||||
|
? configuration.meta.signatureTypes.length === 0 ||
|
||||||
|
configuration.meta.signatureTypes.includes(DocumentSignatureType.QR)
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
recipients: configuration.signers.map((signer) => ({
|
recipients: configuration.signers.map((signer) => ({
|
||||||
id: signer.nativeId,
|
id: signer.nativeId,
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ export default function MultisignPage() {
|
|||||||
typedSignatureEnabled={selectedDocument.documentMeta?.typedSignatureEnabled}
|
typedSignatureEnabled={selectedDocument.documentMeta?.typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={selectedDocument.documentMeta?.uploadSignatureEnabled}
|
uploadSignatureEnabled={selectedDocument.documentMeta?.uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={selectedDocument.documentMeta?.drawSignatureEnabled}
|
drawSignatureEnabled={selectedDocument.documentMeta?.drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={selectedDocument.documentMeta?.qrSignatureEnabled}
|
||||||
>
|
>
|
||||||
<DocumentSigningAuthProvider
|
<DocumentSigningAuthProvider
|
||||||
documentAuthOptions={selectedDocument.authOptions}
|
documentAuthOptions={selectedDocument.authOptions}
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
|
|||||||
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled ?? undefined,
|
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled ?? undefined,
|
||||||
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled ?? undefined,
|
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled ?? undefined,
|
||||||
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled ?? undefined,
|
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled ?? undefined,
|
||||||
|
qrSignatureEnabled: envelope.documentMeta.qrSignatureEnabled ?? undefined,
|
||||||
dateFormat: (envelope.documentMeta.dateFormat as TDocumentMetaDateFormat) ?? undefined,
|
dateFormat: (envelope.documentMeta.dateFormat as TDocumentMetaDateFormat) ?? undefined,
|
||||||
language: envelope.documentMeta.language as SupportedLanguageCodes,
|
language: envelope.documentMeta.language as SupportedLanguageCodes,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -239,6 +239,7 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
|
|||||||
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled, //
|
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled, //
|
||||||
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled, //
|
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled, //
|
||||||
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled, //
|
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled, //
|
||||||
|
qrSignatureEnabled: envelope.documentMeta.qrSignatureEnabled, //
|
||||||
dateFormat: (envelope.documentMeta.dateFormat as TDocumentMetaDateFormat) ?? undefined,
|
dateFormat: (envelope.documentMeta.dateFormat as TDocumentMetaDateFormat) ?? undefined,
|
||||||
language: envelope.documentMeta.language as SupportedLanguageCodes,
|
language: envelope.documentMeta.language as SupportedLanguageCodes,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,12 +12,23 @@ type HandleSignatureFieldClickOptions = {
|
|||||||
typedSignatureEnabled?: boolean;
|
typedSignatureEnabled?: boolean;
|
||||||
uploadSignatureEnabled?: boolean;
|
uploadSignatureEnabled?: boolean;
|
||||||
drawSignatureEnabled?: boolean;
|
drawSignatureEnabled?: boolean;
|
||||||
|
qrSignatureEnabled?: boolean;
|
||||||
|
recipientToken?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleSignatureFieldClick = async (
|
export const handleSignatureFieldClick = async (
|
||||||
options: HandleSignatureFieldClickOptions,
|
options: HandleSignatureFieldClickOptions,
|
||||||
): Promise<Extract<TSignEnvelopeFieldValue, { type: typeof FieldType.SIGNATURE }> | null> => {
|
): Promise<Extract<TSignEnvelopeFieldValue, { type: typeof FieldType.SIGNATURE }> | null> => {
|
||||||
const { field, fullName, signature, typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled } = options;
|
const {
|
||||||
|
field,
|
||||||
|
fullName,
|
||||||
|
signature,
|
||||||
|
typedSignatureEnabled,
|
||||||
|
uploadSignatureEnabled,
|
||||||
|
drawSignatureEnabled,
|
||||||
|
qrSignatureEnabled,
|
||||||
|
recipientToken,
|
||||||
|
} = options;
|
||||||
|
|
||||||
if (field.type !== FieldType.SIGNATURE) {
|
if (field.type !== FieldType.SIGNATURE) {
|
||||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
@@ -40,6 +51,8 @@ export const handleSignatureFieldClick = async (
|
|||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
uploadSignatureEnabled,
|
uploadSignatureEnabled,
|
||||||
drawSignatureEnabled,
|
drawSignatureEnabled,
|
||||||
|
qrSignatureEnabled,
|
||||||
|
qrSignatureContext: recipientToken ? { type: 'DOCUMENT_SIGNATURE', recipientToken } : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+1
@@ -33049,6 +33049,7 @@
|
|||||||
"tailwind-merge": "^1.14.0",
|
"tailwind-merge": "^1.14.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"ts-pattern": "^5.9.0",
|
"ts-pattern": "^5.9.0",
|
||||||
|
"uqr": "^0.1.2",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -438,6 +438,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
|||||||
typedSignatureEnabled: body.meta.typedSignatureEnabled,
|
typedSignatureEnabled: body.meta.typedSignatureEnabled,
|
||||||
uploadSignatureEnabled: body.meta.uploadSignatureEnabled,
|
uploadSignatureEnabled: body.meta.uploadSignatureEnabled,
|
||||||
drawSignatureEnabled: body.meta.drawSignatureEnabled,
|
drawSignatureEnabled: body.meta.drawSignatureEnabled,
|
||||||
|
qrSignatureEnabled: body.meta.qrSignatureEnabled,
|
||||||
distributionMethod: body.meta.distributionMethod,
|
distributionMethod: body.meta.distributionMethod,
|
||||||
emailSettings: body.meta.emailSettings,
|
emailSettings: body.meta.emailSettings,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -170,6 +170,8 @@ export const ZCreateDocumentMutationSchema = z.object({
|
|||||||
typedSignatureEnabled: z.boolean().optional().default(true),
|
typedSignatureEnabled: z.boolean().optional().default(true),
|
||||||
uploadSignatureEnabled: z.boolean().optional().default(true),
|
uploadSignatureEnabled: z.boolean().optional().default(true),
|
||||||
drawSignatureEnabled: 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(),
|
distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional(),
|
||||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||||
})
|
})
|
||||||
@@ -340,6 +342,7 @@ export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
|
|||||||
typedSignatureEnabled: z.boolean(),
|
typedSignatureEnabled: z.boolean(),
|
||||||
uploadSignatureEnabled: z.boolean(),
|
uploadSignatureEnabled: z.boolean(),
|
||||||
drawSignatureEnabled: z.boolean(),
|
drawSignatureEnabled: z.boolean(),
|
||||||
|
qrSignatureEnabled: z.boolean(),
|
||||||
emailSettings: ZDocumentEmailSettingsSchema,
|
emailSettings: ZDocumentEmailSettingsSchema,
|
||||||
})
|
})
|
||||||
.partial()
|
.partial()
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ test.describe('API V2 Envelopes', () => {
|
|||||||
typedSignatureEnabled: true,
|
typedSignatureEnabled: true,
|
||||||
uploadSignatureEnabled: false,
|
uploadSignatureEnabled: false,
|
||||||
drawSignatureEnabled: false,
|
drawSignatureEnabled: false,
|
||||||
|
qrSignatureEnabled: false,
|
||||||
emailReplyTo: userA.email,
|
emailReplyTo: userA.email,
|
||||||
emailSettings: {
|
emailSettings: {
|
||||||
recipientSigningRequest: false,
|
recipientSigningRequest: false,
|
||||||
@@ -295,6 +296,7 @@ test.describe('API V2 Envelopes', () => {
|
|||||||
expect(envelope.documentMeta.typedSignatureEnabled).toBe(payload.meta.typedSignatureEnabled);
|
expect(envelope.documentMeta.typedSignatureEnabled).toBe(payload.meta.typedSignatureEnabled);
|
||||||
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(payload.meta.uploadSignatureEnabled);
|
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(payload.meta.uploadSignatureEnabled);
|
||||||
expect(envelope.documentMeta.drawSignatureEnabled).toBe(payload.meta.drawSignatureEnabled);
|
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.emailReplyTo).toBe(payload.meta.emailReplyTo);
|
||||||
expect(envelope.documentMeta.emailSettings).toEqual(payload.meta.emailSettings);
|
expect(envelope.documentMeta.emailSettings).toEqual(payload.meta.emailSettings);
|
||||||
|
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ test.describe('AutoSave Settings Step', () => {
|
|||||||
expect(retrieved.documentMeta?.drawSignatureEnabled).toBe(false);
|
expect(retrieved.documentMeta?.drawSignatureEnabled).toBe(false);
|
||||||
expect(retrieved.documentMeta?.typedSignatureEnabled).toBe(false);
|
expect(retrieved.documentMeta?.typedSignatureEnabled).toBe(false);
|
||||||
expect(retrieved.documentMeta?.uploadSignatureEnabled).toBe(true);
|
expect(retrieved.documentMeta?.uploadSignatureEnabled).toBe(true);
|
||||||
|
expect(retrieved.documentMeta?.qrSignatureEnabled).toBe(true);
|
||||||
}).toPass();
|
}).toPass();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -384,6 +384,7 @@ const assertEnvelopeSettingsPersistedInDatabase = async ({
|
|||||||
expect(envelope.documentMeta.drawSignatureEnabled).toBe(true);
|
expect(envelope.documentMeta.drawSignatureEnabled).toBe(true);
|
||||||
expect(envelope.documentMeta.typedSignatureEnabled).toBe(true);
|
expect(envelope.documentMeta.typedSignatureEnabled).toBe(true);
|
||||||
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(false);
|
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(false);
|
||||||
|
expect(envelope.documentMeta.qrSignatureEnabled).toBe(true);
|
||||||
expect(envelope.documentMeta.emailSettings).toMatchObject(DB_EXPECTED_VALUES.emailSettings);
|
expect(envelope.documentMeta.emailSettings).toMatchObject(DB_EXPECTED_VALUES.emailSettings);
|
||||||
|
|
||||||
const authOptions = parseAuthOptions(envelope.authOptions);
|
const authOptions = parseAuthOptions(envelope.authOptions);
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
|||||||
expect(teamSettings.typedSignatureEnabled).toEqual(true);
|
expect(teamSettings.typedSignatureEnabled).toEqual(true);
|
||||||
expect(teamSettings.uploadSignatureEnabled).toEqual(false);
|
expect(teamSettings.uploadSignatureEnabled).toEqual(false);
|
||||||
expect(teamSettings.drawSignatureEnabled).toEqual(false);
|
expect(teamSettings.drawSignatureEnabled).toEqual(false);
|
||||||
|
expect(teamSettings.qrSignatureEnabled).toEqual(true);
|
||||||
|
|
||||||
// Edit the team settings
|
// Edit the team settings
|
||||||
await page.goto(`/t/${team.url}/settings/document`);
|
await page.goto(`/t/${team.url}/settings/document`);
|
||||||
@@ -102,6 +103,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
|||||||
expect(updatedTeamSettings.typedSignatureEnabled).toEqual(true);
|
expect(updatedTeamSettings.typedSignatureEnabled).toEqual(true);
|
||||||
expect(updatedTeamSettings.uploadSignatureEnabled).toEqual(false);
|
expect(updatedTeamSettings.uploadSignatureEnabled).toEqual(false);
|
||||||
expect(updatedTeamSettings.drawSignatureEnabled).toEqual(false);
|
expect(updatedTeamSettings.drawSignatureEnabled).toEqual(false);
|
||||||
|
expect(updatedTeamSettings.qrSignatureEnabled).toEqual(true);
|
||||||
|
|
||||||
const document = await seedTeamDocumentWithMeta(team);
|
const document = await seedTeamDocumentWithMeta(team);
|
||||||
|
|
||||||
@@ -117,6 +119,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
|||||||
expect(documentMeta.typedSignatureEnabled).toEqual(true);
|
expect(documentMeta.typedSignatureEnabled).toEqual(true);
|
||||||
expect(documentMeta.uploadSignatureEnabled).toEqual(false);
|
expect(documentMeta.uploadSignatureEnabled).toEqual(false);
|
||||||
expect(documentMeta.drawSignatureEnabled).toEqual(false);
|
expect(documentMeta.drawSignatureEnabled).toEqual(false);
|
||||||
|
expect(documentMeta.qrSignatureEnabled).toEqual(true);
|
||||||
expect(documentMeta.language).toEqual('pl');
|
expect(documentMeta.language).toEqual('pl');
|
||||||
expect(documentMeta.timezone).toEqual('Europe/London');
|
expect(documentMeta.timezone).toEqual('Europe/London');
|
||||||
expect(documentMeta.dateFormat).toEqual('MM/dd/yyyy');
|
expect(documentMeta.dateFormat).toEqual('MM/dd/yyyy');
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
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,6 +25,7 @@ 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: 'Type' })).toBeVisible();
|
||||||
await expect(page.getByRole('combobox').filter({ hasText: 'Upload' })).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: 'Draw' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('combobox').filter({ hasText: 'QR code' })).toBeVisible();
|
||||||
|
|
||||||
// Go to document and check that the signatured tabs are correct.
|
// Go to document and check that the signatured tabs are correct.
|
||||||
await page.goto(`/sign/${document.recipients[0].token}`);
|
await page.goto(`/sign/${document.recipients[0].token}`);
|
||||||
@@ -34,6 +35,7 @@ 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: 'Type' })).toBeVisible();
|
||||||
await expect(page.getByRole('tab', { name: 'Upload' })).toBeVisible();
|
await expect(page.getByRole('tab', { name: 'Upload' })).toBeVisible();
|
||||||
await expect(page.getByRole('tab', { name: 'Draw' })).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 }) => {
|
test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
|
||||||
@@ -45,8 +47,11 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
|
|||||||
redirectPath: `/t/${team.url}/settings/document`,
|
redirectPath: `/t/${team.url}/settings/document`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const allTabs = ['Type', 'Upload', 'Draw'];
|
// The 'QR code' signature type is surfaced as the 'Mobile' tab on the signing dialog.
|
||||||
const tabTest = [['Type', 'Upload', 'Draw'], ['Type', 'Upload'], ['Type']];
|
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']];
|
||||||
|
|
||||||
for (const tabs of tabTest) {
|
for (const tabs of tabTest) {
|
||||||
await page.goto(`/t/${team.url}/settings/document`);
|
await page.goto(`/t/${team.url}/settings/document`);
|
||||||
@@ -57,9 +62,10 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
|
|||||||
await expect(page.getByRole('option', { name: 'Type' })).toBeVisible();
|
await expect(page.getByRole('option', { name: 'Type' })).toBeVisible();
|
||||||
await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible();
|
await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible();
|
||||||
await expect(page.getByRole('option', { name: 'Draw' })).toBeVisible();
|
await expect(page.getByRole('option', { name: 'Draw' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('option', { name: 'QR code' })).toBeVisible();
|
||||||
|
|
||||||
// Clear all selected items.
|
// Clear all selected items.
|
||||||
for (const tab of allTabs) {
|
for (const tab of allSignatureOptions) {
|
||||||
const item = page.getByRole('option', { name: tab });
|
const item = page.getByRole('option', { name: tab });
|
||||||
|
|
||||||
const isSelected = (await item.innerHTML()).includes('opacity-100');
|
const isSelected = (await item.innerHTML()).includes('opacity-100');
|
||||||
@@ -90,12 +96,13 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
|
|||||||
await page.waitForSelector('[role="dialog"]');
|
await page.waitForSelector('[role="dialog"]');
|
||||||
|
|
||||||
// Check the tab values
|
// Check the tab values
|
||||||
for (const tab of allTabs) {
|
for (const option of allSignatureOptions) {
|
||||||
if (tabs.includes(tab)) {
|
const tabName = tabNameForOption(option);
|
||||||
await expect(page.getByRole('tab', { name: tab })).toBeVisible();
|
|
||||||
|
if (tabs.includes(option)) {
|
||||||
|
await expect(page.getByRole('tab', { name: tabName })).toBeVisible();
|
||||||
} else {
|
} else {
|
||||||
// await expect(page.getByRole('tab', { name: tab })).not.toBeVisible();
|
await expect(page.getByRole('tab', { name: tabName })).toHaveCount(0);
|
||||||
await expect(page.getByRole('tab', { name: tab })).toHaveCount(0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,8 +117,8 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
|
|||||||
redirectPath: `/t/${team.url}/settings/document`,
|
redirectPath: `/t/${team.url}/settings/document`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const allTabs = ['Type', 'Upload', 'Draw'];
|
const allSignatureOptions = ['Type', 'Upload', 'Draw', 'QR code'];
|
||||||
const tabTest = [['Type', 'Upload', 'Draw'], ['Type', 'Upload'], ['Type']];
|
const tabTest = [['Type', 'Upload', 'Draw', 'QR code'], ['Type', 'Upload'], ['Type']];
|
||||||
|
|
||||||
for (const tabs of tabTest) {
|
for (const tabs of tabTest) {
|
||||||
await page.goto(`/t/${team.url}/settings/document`);
|
await page.goto(`/t/${team.url}/settings/document`);
|
||||||
@@ -122,9 +129,10 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
|
|||||||
await expect(page.getByRole('option', { name: 'Type' })).toBeVisible();
|
await expect(page.getByRole('option', { name: 'Type' })).toBeVisible();
|
||||||
await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible();
|
await expect(page.getByRole('option', { name: 'Upload' })).toBeVisible();
|
||||||
await expect(page.getByRole('option', { name: 'Draw' })).toBeVisible();
|
await expect(page.getByRole('option', { name: 'Draw' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('option', { name: 'QR code' })).toBeVisible();
|
||||||
|
|
||||||
// Clear all selected items.
|
// Clear all selected items.
|
||||||
for (const tab of allTabs) {
|
for (const tab of allSignatureOptions) {
|
||||||
const item = page.getByRole('option', { name: tab });
|
const item = page.getByRole('option', { name: tab });
|
||||||
|
|
||||||
const isSelected = (await item.innerHTML()).includes('opacity-100');
|
const isSelected = (await item.innerHTML()).includes('opacity-100');
|
||||||
@@ -176,5 +184,6 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
|
|||||||
expect(document?.documentMeta?.typedSignatureEnabled).toEqual(tabs.includes('Type'));
|
expect(document?.documentMeta?.typedSignatureEnabled).toEqual(tabs.includes('Type'));
|
||||||
expect(document?.documentMeta?.uploadSignatureEnabled).toEqual(tabs.includes('Upload'));
|
expect(document?.documentMeta?.uploadSignatureEnabled).toEqual(tabs.includes('Upload'));
|
||||||
expect(document?.documentMeta?.drawSignatureEnabled).toEqual(tabs.includes('Draw'));
|
expect(document?.documentMeta?.drawSignatureEnabled).toEqual(tabs.includes('Draw'));
|
||||||
|
expect(document?.documentMeta?.qrSignatureEnabled).toEqual(tabs.includes('QR code'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ test.describe('AutoSave Settings Step - Templates', () => {
|
|||||||
expect(retrievedTemplate.templateMeta?.drawSignatureEnabled).toBe(false);
|
expect(retrievedTemplate.templateMeta?.drawSignatureEnabled).toBe(false);
|
||||||
expect(retrievedTemplate.templateMeta?.typedSignatureEnabled).toBe(false);
|
expect(retrievedTemplate.templateMeta?.typedSignatureEnabled).toBe(false);
|
||||||
expect(retrievedTemplate.templateMeta?.uploadSignatureEnabled).toBe(true);
|
expect(retrievedTemplate.templateMeta?.uploadSignatureEnabled).toBe(true);
|
||||||
|
expect(retrievedTemplate.templateMeta?.qrSignatureEnabled).toBe(true);
|
||||||
}).toPass();
|
}).toPass();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -77,4 +77,11 @@ export const DOCUMENT_SIGNATURE_TYPES = {
|
|||||||
}),
|
}),
|
||||||
value: DocumentSignatureType.UPLOAD,
|
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>;
|
} satisfies Record<DocumentSignatureType, DocumentSignatureTypeData>;
|
||||||
|
|||||||
@@ -2,3 +2,5 @@ export const SIGNATURE_CANVAS_DPI = 2;
|
|||||||
export const SIGNATURE_MIN_COVERAGE_THRESHOLD = 0.01;
|
export const SIGNATURE_MIN_COVERAGE_THRESHOLD = 0.01;
|
||||||
|
|
||||||
export const isBase64Image = (value: string) => value.startsWith('data:image/png;base64,');
|
export const isBase64Image = (value: string) => value.startsWith('data:image/png;base64,');
|
||||||
|
|
||||||
|
export const QR_SIGNATURE_TOKEN_EXPIRY_MINUTES = 10;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION } from './definitions/inte
|
|||||||
import { BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION } from './definitions/internal/backport-subscription-claims';
|
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 { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
|
||||||
import { CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION } from './definitions/internal/cancel-organisation-subscription';
|
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 { CLEANUP_RATE_LIMITS_JOB_DEFINITION } from './definitions/internal/cleanup-rate-limits';
|
||||||
import { EXECUTE_WEBHOOK_JOB_DEFINITION } from './definitions/internal/execute-webhook';
|
import { EXECUTE_WEBHOOK_JOB_DEFINITION } from './definitions/internal/execute-webhook';
|
||||||
import { EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION } from './definitions/internal/expire-recipients-sweep';
|
import { EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION } from './definitions/internal/expire-recipients-sweep';
|
||||||
@@ -64,6 +65,7 @@ export const jobsClient = new JobClient([
|
|||||||
SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION,
|
SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION,
|
||||||
PROCESS_SIGNING_REMINDER_JOB_DEFINITION,
|
PROCESS_SIGNING_REMINDER_JOB_DEFINITION,
|
||||||
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
|
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
|
||||||
|
CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION,
|
||||||
SYNC_EMAIL_DOMAINS_JOB_DEFINITION,
|
SYNC_EMAIL_DOMAINS_JOB_DEFINITION,
|
||||||
ADMIN_DELETE_ORGANISATION_JOB_DEFINITION,
|
ADMIN_DELETE_ORGANISATION_JOB_DEFINITION,
|
||||||
ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION,
|
ALERT_ORGANISATION_SEAT_DRIFT_JOB_DEFINITION,
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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,4 +1,5 @@
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
import { AnonymousVerificationTokenType } from '@prisma/client';
|
||||||
import { generateAuthenticationOptions } from '@simplewebauthn/server';
|
import { generateAuthenticationOptions } from '@simplewebauthn/server';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
@@ -24,12 +25,14 @@ export const createPasskeySigninOptions = async ({ sessionId }: CreatePasskeySig
|
|||||||
id: sessionId,
|
id: sessionId,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
|
type: AnonymousVerificationTokenType.PASSKEY,
|
||||||
token: challenge,
|
token: challenge,
|
||||||
expiresAt: DateTime.now().plus({ minutes: 2 }).toJSDate(),
|
expiresAt: DateTime.now().plus({ minutes: 2 }).toJSDate(),
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
|
type: AnonymousVerificationTokenType.PASSKEY,
|
||||||
token: challenge,
|
token: challenge,
|
||||||
expiresAt: DateTime.now().plus({ minutes: 2 }).toJSDate(),
|
expiresAt: DateTime.now().plus({ minutes: 2 }).toJSDate(),
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export type CreateDocumentMetaOptions = {
|
|||||||
typedSignatureEnabled?: boolean;
|
typedSignatureEnabled?: boolean;
|
||||||
uploadSignatureEnabled?: boolean;
|
uploadSignatureEnabled?: boolean;
|
||||||
drawSignatureEnabled?: boolean;
|
drawSignatureEnabled?: boolean;
|
||||||
|
qrSignatureEnabled?: boolean;
|
||||||
language?: SupportedLanguageCodes;
|
language?: SupportedLanguageCodes;
|
||||||
requestMetadata: ApiRequestMetadata;
|
requestMetadata: ApiRequestMetadata;
|
||||||
};
|
};
|
||||||
@@ -53,6 +54,7 @@ export const updateDocumentMeta = async ({
|
|||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
uploadSignatureEnabled,
|
uploadSignatureEnabled,
|
||||||
drawSignatureEnabled,
|
drawSignatureEnabled,
|
||||||
|
qrSignatureEnabled,
|
||||||
language,
|
language,
|
||||||
requestMetadata,
|
requestMetadata,
|
||||||
}: CreateDocumentMetaOptions) => {
|
}: CreateDocumentMetaOptions) => {
|
||||||
@@ -132,6 +134,7 @@ export const updateDocumentMeta = async ({
|
|||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
uploadSignatureEnabled,
|
uploadSignatureEnabled,
|
||||||
drawSignatureEnabled,
|
drawSignatureEnabled,
|
||||||
|
qrSignatureEnabled,
|
||||||
language,
|
language,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export const ZEnvelopeForSigningResponse = z.object({
|
|||||||
typedSignatureEnabled: true,
|
typedSignatureEnabled: true,
|
||||||
uploadSignatureEnabled: true,
|
uploadSignatureEnabled: true,
|
||||||
drawSignatureEnabled: true,
|
drawSignatureEnabled: true,
|
||||||
|
qrSignatureEnabled: true,
|
||||||
allowDictateNextSigner: true,
|
allowDictateNextSigner: true,
|
||||||
language: true,
|
language: true,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -72,6 +72,21 @@ export const reportSenderRateLimit = createRateLimit({
|
|||||||
window: '7d',
|
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 ----
|
// ---- Billing ----
|
||||||
|
|
||||||
export const syncSubscriptionRateLimit = createRateLimit({
|
export const syncSubscriptionRateLimit = createRateLimit({
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ export type CreateDocumentFromTemplateOptions = {
|
|||||||
typedSignatureEnabled?: boolean;
|
typedSignatureEnabled?: boolean;
|
||||||
uploadSignatureEnabled?: boolean;
|
uploadSignatureEnabled?: boolean;
|
||||||
drawSignatureEnabled?: boolean;
|
drawSignatureEnabled?: boolean;
|
||||||
|
qrSignatureEnabled?: boolean;
|
||||||
envelopeExpirationPeriod?: TEnvelopeExpirationPeriod | null;
|
envelopeExpirationPeriod?: TEnvelopeExpirationPeriod | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -540,6 +541,7 @@ export const createDocumentFromTemplate = async ({
|
|||||||
typedSignatureEnabled: override?.typedSignatureEnabled ?? template.documentMeta?.typedSignatureEnabled,
|
typedSignatureEnabled: override?.typedSignatureEnabled ?? template.documentMeta?.typedSignatureEnabled,
|
||||||
uploadSignatureEnabled: override?.uploadSignatureEnabled ?? template.documentMeta?.uploadSignatureEnabled,
|
uploadSignatureEnabled: override?.uploadSignatureEnabled ?? template.documentMeta?.uploadSignatureEnabled,
|
||||||
drawSignatureEnabled: override?.drawSignatureEnabled ?? template.documentMeta?.drawSignatureEnabled,
|
drawSignatureEnabled: override?.drawSignatureEnabled ?? template.documentMeta?.drawSignatureEnabled,
|
||||||
|
qrSignatureEnabled: override?.qrSignatureEnabled ?? template.documentMeta?.qrSignatureEnabled,
|
||||||
allowDictateNextSigner: override?.allowDictateNextSigner ?? template.documentMeta?.allowDictateNextSigner,
|
allowDictateNextSigner: override?.allowDictateNextSigner ?? template.documentMeta?.allowDictateNextSigner,
|
||||||
envelopeExpirationPeriod: override?.envelopeExpirationPeriod ?? template.documentMeta?.envelopeExpirationPeriod,
|
envelopeExpirationPeriod: override?.envelopeExpirationPeriod ?? template.documentMeta?.envelopeExpirationPeriod,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
|
|||||||
typedSignatureEnabled: true,
|
typedSignatureEnabled: true,
|
||||||
uploadSignatureEnabled: true,
|
uploadSignatureEnabled: true,
|
||||||
drawSignatureEnabled: true,
|
drawSignatureEnabled: true,
|
||||||
|
qrSignatureEnabled: true,
|
||||||
language: 'en',
|
language: 'en',
|
||||||
distributionMethod: DocumentDistributionMethod.EMAIL,
|
distributionMethod: DocumentDistributionMethod.EMAIL,
|
||||||
emailSettings: null,
|
emailSettings: null,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const ZDocumentMetaSchema = DocumentMetaSchema.pick({
|
|||||||
typedSignatureEnabled: true,
|
typedSignatureEnabled: true,
|
||||||
uploadSignatureEnabled: true,
|
uploadSignatureEnabled: true,
|
||||||
drawSignatureEnabled: true,
|
drawSignatureEnabled: true,
|
||||||
|
qrSignatureEnabled: true,
|
||||||
language: true,
|
language: true,
|
||||||
emailSettings: true,
|
emailSettings: true,
|
||||||
});
|
});
|
||||||
@@ -105,6 +106,10 @@ export const ZDocumentMetaUploadSignatureEnabledSchema = z
|
|||||||
.boolean()
|
.boolean()
|
||||||
.describe('Whether to allow recipients to sign using an uploaded signature.');
|
.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
|
* 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
|
* all corresponding areas where this is used (some places that use this needs to pass
|
||||||
@@ -123,6 +128,7 @@ export const ZDocumentMetaCreateSchema = z.object({
|
|||||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||||
|
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
|
||||||
emailId: z.string().nullish(),
|
emailId: z.string().nullish(),
|
||||||
emailReplyTo: zEmail().nullish(),
|
emailReplyTo: zEmail().nullish(),
|
||||||
emailSettings: ZDocumentEmailSettingsSchema.nullish(),
|
emailSettings: ZDocumentEmailSettingsSchema.nullish(),
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
|
|||||||
typedSignatureEnabled: true,
|
typedSignatureEnabled: true,
|
||||||
uploadSignatureEnabled: true,
|
uploadSignatureEnabled: true,
|
||||||
drawSignatureEnabled: true,
|
drawSignatureEnabled: true,
|
||||||
|
qrSignatureEnabled: true,
|
||||||
allowDictateNextSigner: true,
|
allowDictateNextSigner: true,
|
||||||
language: true,
|
language: true,
|
||||||
emailSettings: true,
|
emailSettings: true,
|
||||||
|
|||||||
@@ -279,6 +279,7 @@ export const ZEditorEnvelopeSchema = EnvelopeSchema.pick({
|
|||||||
typedSignatureEnabled: true,
|
typedSignatureEnabled: true,
|
||||||
uploadSignatureEnabled: true,
|
uploadSignatureEnabled: true,
|
||||||
drawSignatureEnabled: true,
|
drawSignatureEnabled: true,
|
||||||
|
qrSignatureEnabled: true,
|
||||||
allowDictateNextSigner: true,
|
allowDictateNextSigner: true,
|
||||||
language: true,
|
language: true,
|
||||||
emailSettings: true,
|
emailSettings: true,
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({
|
|||||||
typedSignatureEnabled: true,
|
typedSignatureEnabled: true,
|
||||||
uploadSignatureEnabled: true,
|
uploadSignatureEnabled: true,
|
||||||
drawSignatureEnabled: true,
|
drawSignatureEnabled: true,
|
||||||
|
qrSignatureEnabled: true,
|
||||||
allowDictateNextSigner: true,
|
allowDictateNextSigner: true,
|
||||||
language: true,
|
language: true,
|
||||||
emailSettings: true,
|
emailSettings: true,
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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'];
|
||||||
@@ -54,6 +54,7 @@ export const ZTemplateSchema = TemplateSchema.pick({
|
|||||||
typedSignatureEnabled: true,
|
typedSignatureEnabled: true,
|
||||||
uploadSignatureEnabled: true,
|
uploadSignatureEnabled: true,
|
||||||
drawSignatureEnabled: true,
|
drawSignatureEnabled: true,
|
||||||
|
qrSignatureEnabled: true,
|
||||||
allowDictateNextSigner: true,
|
allowDictateNextSigner: true,
|
||||||
distributionMethod: true,
|
distributionMethod: true,
|
||||||
redirectUrl: true,
|
redirectUrl: true,
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export const ZWebhookDocumentMetaSchema = z.object({
|
|||||||
typedSignatureEnabled: z.boolean(),
|
typedSignatureEnabled: z.boolean(),
|
||||||
uploadSignatureEnabled: z.boolean(),
|
uploadSignatureEnabled: z.boolean(),
|
||||||
drawSignatureEnabled: z.boolean(),
|
drawSignatureEnabled: z.boolean(),
|
||||||
|
qrSignatureEnabled: z.boolean(),
|
||||||
language: z.string(),
|
language: z.string(),
|
||||||
distributionMethod: z.nativeEnum(DocumentDistributionMethod),
|
distributionMethod: z.nativeEnum(DocumentDistributionMethod),
|
||||||
emailSettings: z.any().nullable(),
|
emailSettings: z.any().nullable(),
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export const extractDerivedDocumentMeta = (
|
|||||||
typedSignatureEnabled: meta.typedSignatureEnabled ?? settings.typedSignatureEnabled,
|
typedSignatureEnabled: meta.typedSignatureEnabled ?? settings.typedSignatureEnabled,
|
||||||
uploadSignatureEnabled: meta.uploadSignatureEnabled ?? settings.uploadSignatureEnabled,
|
uploadSignatureEnabled: meta.uploadSignatureEnabled ?? settings.uploadSignatureEnabled,
|
||||||
drawSignatureEnabled: meta.drawSignatureEnabled ?? settings.drawSignatureEnabled,
|
drawSignatureEnabled: meta.drawSignatureEnabled ?? settings.drawSignatureEnabled,
|
||||||
|
qrSignatureEnabled: meta.qrSignatureEnabled ?? settings.qrSignatureEnabled,
|
||||||
|
|
||||||
// Email settings.
|
// Email settings.
|
||||||
emailId: meta.emailId ?? settings.emailId,
|
emailId: meta.emailId ?? settings.emailId,
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export const generateDefaultOrganisationSettings = (): Omit<OrganisationGlobalSe
|
|||||||
typedSignatureEnabled: true,
|
typedSignatureEnabled: true,
|
||||||
uploadSignatureEnabled: true,
|
uploadSignatureEnabled: true,
|
||||||
drawSignatureEnabled: true,
|
drawSignatureEnabled: true,
|
||||||
|
qrSignatureEnabled: true,
|
||||||
|
|
||||||
brandingEnabled: false,
|
brandingEnabled: false,
|
||||||
brandingLogo: '',
|
brandingLogo: '',
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export enum DocumentSignatureType {
|
|||||||
DRAW = 'draw',
|
DRAW = 'draw',
|
||||||
TYPE = 'type',
|
TYPE = 'type',
|
||||||
UPLOAD = 'upload',
|
UPLOAD = 'upload',
|
||||||
|
QR = 'qr',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const formatTeamUrl = (teamUrl: string, baseUrl?: string) => {
|
export const formatTeamUrl = (teamUrl: string, baseUrl?: string) => {
|
||||||
@@ -93,10 +94,16 @@ export const extractTeamSignatureSettings = (
|
|||||||
typedSignatureEnabled: boolean | null;
|
typedSignatureEnabled: boolean | null;
|
||||||
drawSignatureEnabled: boolean | null;
|
drawSignatureEnabled: boolean | null;
|
||||||
uploadSignatureEnabled: boolean | null;
|
uploadSignatureEnabled: boolean | null;
|
||||||
|
qrSignatureEnabled: boolean | null;
|
||||||
} | null,
|
} | null,
|
||||||
) => {
|
) => {
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
return [DocumentSignatureType.TYPE, DocumentSignatureType.UPLOAD, DocumentSignatureType.DRAW];
|
return [
|
||||||
|
DocumentSignatureType.TYPE,
|
||||||
|
DocumentSignatureType.UPLOAD,
|
||||||
|
DocumentSignatureType.DRAW,
|
||||||
|
DocumentSignatureType.QR,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const signatureTypes: DocumentSignatureType[] = [];
|
const signatureTypes: DocumentSignatureType[] = [];
|
||||||
@@ -113,6 +120,10 @@ export const extractTeamSignatureSettings = (
|
|||||||
signatureTypes.push(DocumentSignatureType.UPLOAD);
|
signatureTypes.push(DocumentSignatureType.UPLOAD);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (settings.qrSignatureEnabled) {
|
||||||
|
signatureTypes.push(DocumentSignatureType.QR);
|
||||||
|
}
|
||||||
|
|
||||||
return signatureTypes;
|
return signatureTypes;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -186,6 +197,7 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
|
|||||||
typedSignatureEnabled: null,
|
typedSignatureEnabled: null,
|
||||||
uploadSignatureEnabled: null,
|
uploadSignatureEnabled: null,
|
||||||
drawSignatureEnabled: null,
|
drawSignatureEnabled: null,
|
||||||
|
qrSignatureEnabled: null,
|
||||||
|
|
||||||
brandingEnabled: null,
|
brandingEnabled: null,
|
||||||
brandingLogo: null,
|
brandingLogo: null,
|
||||||
|
|||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- 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;
|
||||||
@@ -144,9 +144,18 @@ model Passkey {
|
|||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AnonymousVerificationTokenType {
|
||||||
|
PASSKEY
|
||||||
|
QR_SIGNATURE
|
||||||
|
}
|
||||||
|
|
||||||
model AnonymousVerificationToken {
|
model AnonymousVerificationToken {
|
||||||
id String @id @unique @default(cuid())
|
id String @id @unique @default(cuid())
|
||||||
token String @unique
|
type AnonymousVerificationTokenType
|
||||||
|
token String @unique
|
||||||
|
value String?
|
||||||
|
metadata Json?
|
||||||
|
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
@@ -570,6 +579,7 @@ model DocumentMeta {
|
|||||||
typedSignatureEnabled Boolean @default(true)
|
typedSignatureEnabled Boolean @default(true)
|
||||||
uploadSignatureEnabled Boolean @default(true)
|
uploadSignatureEnabled Boolean @default(true)
|
||||||
drawSignatureEnabled Boolean @default(true)
|
drawSignatureEnabled Boolean @default(true)
|
||||||
|
qrSignatureEnabled Boolean @default(true)
|
||||||
|
|
||||||
language String @default("en")
|
language String @default("en")
|
||||||
distributionMethod DocumentDistributionMethod @default(EMAIL)
|
distributionMethod DocumentDistributionMethod @default(EMAIL)
|
||||||
@@ -969,6 +979,7 @@ model OrganisationGlobalSettings {
|
|||||||
typedSignatureEnabled Boolean @default(true)
|
typedSignatureEnabled Boolean @default(true)
|
||||||
uploadSignatureEnabled Boolean @default(true)
|
uploadSignatureEnabled Boolean @default(true)
|
||||||
drawSignatureEnabled Boolean @default(true)
|
drawSignatureEnabled Boolean @default(true)
|
||||||
|
qrSignatureEnabled Boolean @default(true)
|
||||||
|
|
||||||
defaultRecipients Json? /// [DefaultRecipient[]] @zod.custom.use(ZDefaultRecipientsSchema)
|
defaultRecipients Json? /// [DefaultRecipient[]] @zod.custom.use(ZDefaultRecipientsSchema)
|
||||||
|
|
||||||
@@ -1012,6 +1023,7 @@ model TeamGlobalSettings {
|
|||||||
typedSignatureEnabled Boolean?
|
typedSignatureEnabled Boolean?
|
||||||
uploadSignatureEnabled Boolean?
|
uploadSignatureEnabled Boolean?
|
||||||
drawSignatureEnabled Boolean?
|
drawSignatureEnabled Boolean?
|
||||||
|
qrSignatureEnabled Boolean?
|
||||||
|
|
||||||
defaultRecipients Json? /// [DefaultRecipient[]] @zod.custom.use(ZDefaultRecipientsSchema)
|
defaultRecipients Json? /// [DefaultRecipient[]] @zod.custom.use(ZDefaultRecipientsSchema)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||||
ZDocumentMetaLanguageSchema,
|
ZDocumentMetaLanguageSchema,
|
||||||
ZDocumentMetaMessageSchema,
|
ZDocumentMetaMessageSchema,
|
||||||
|
ZDocumentMetaQrSignatureEnabledSchema,
|
||||||
ZDocumentMetaRedirectUrlSchema,
|
ZDocumentMetaRedirectUrlSchema,
|
||||||
ZDocumentMetaSubjectSchema,
|
ZDocumentMetaSubjectSchema,
|
||||||
ZDocumentMetaTimezoneSchema,
|
ZDocumentMetaTimezoneSchema,
|
||||||
@@ -65,6 +66,7 @@ export const ZCreateEmbeddingDocumentRequestSchema = z.object({
|
|||||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||||
|
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
|
||||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||||
ZDocumentMetaLanguageSchema,
|
ZDocumentMetaLanguageSchema,
|
||||||
ZDocumentMetaMessageSchema,
|
ZDocumentMetaMessageSchema,
|
||||||
|
ZDocumentMetaQrSignatureEnabledSchema,
|
||||||
ZDocumentMetaRedirectUrlSchema,
|
ZDocumentMetaRedirectUrlSchema,
|
||||||
ZDocumentMetaSubjectSchema,
|
ZDocumentMetaSubjectSchema,
|
||||||
ZDocumentMetaTimezoneSchema,
|
ZDocumentMetaTimezoneSchema,
|
||||||
@@ -62,6 +63,7 @@ export const ZCreateEmbeddingTemplateRequestSchema = z.object({
|
|||||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||||
|
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
|
||||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const ZGetMultiSignDocumentResponseSchema = ZDocumentLiteSchema.extend({
|
|||||||
typedSignatureEnabled: true,
|
typedSignatureEnabled: true,
|
||||||
uploadSignatureEnabled: true,
|
uploadSignatureEnabled: true,
|
||||||
drawSignatureEnabled: true,
|
drawSignatureEnabled: true,
|
||||||
|
qrSignatureEnabled: true,
|
||||||
allowDictateNextSigner: true,
|
allowDictateNextSigner: true,
|
||||||
language: true,
|
language: true,
|
||||||
emailSettings: true,
|
emailSettings: true,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||||
ZDocumentMetaLanguageSchema,
|
ZDocumentMetaLanguageSchema,
|
||||||
ZDocumentMetaMessageSchema,
|
ZDocumentMetaMessageSchema,
|
||||||
|
ZDocumentMetaQrSignatureEnabledSchema,
|
||||||
ZDocumentMetaRedirectUrlSchema,
|
ZDocumentMetaRedirectUrlSchema,
|
||||||
ZDocumentMetaSubjectSchema,
|
ZDocumentMetaSubjectSchema,
|
||||||
ZDocumentMetaTimezoneSchema,
|
ZDocumentMetaTimezoneSchema,
|
||||||
@@ -66,6 +67,7 @@ export const ZUpdateEmbeddingDocumentRequestSchema = z.object({
|
|||||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||||
|
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
|
||||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||||
ZDocumentMetaLanguageSchema,
|
ZDocumentMetaLanguageSchema,
|
||||||
ZDocumentMetaMessageSchema,
|
ZDocumentMetaMessageSchema,
|
||||||
|
ZDocumentMetaQrSignatureEnabledSchema,
|
||||||
ZDocumentMetaRedirectUrlSchema,
|
ZDocumentMetaRedirectUrlSchema,
|
||||||
ZDocumentMetaSubjectSchema,
|
ZDocumentMetaSubjectSchema,
|
||||||
ZDocumentMetaTimezoneSchema,
|
ZDocumentMetaTimezoneSchema,
|
||||||
@@ -66,6 +67,7 @@ export const ZUpdateEmbeddingTemplateRequestSchema = z.object({
|
|||||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||||
|
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
|
||||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||||
ZDocumentMetaLanguageSchema,
|
ZDocumentMetaLanguageSchema,
|
||||||
ZDocumentMetaMessageSchema,
|
ZDocumentMetaMessageSchema,
|
||||||
|
ZDocumentMetaQrSignatureEnabledSchema,
|
||||||
ZDocumentMetaRedirectUrlSchema,
|
ZDocumentMetaRedirectUrlSchema,
|
||||||
ZDocumentMetaSubjectSchema,
|
ZDocumentMetaSubjectSchema,
|
||||||
ZDocumentMetaTimezoneSchema,
|
ZDocumentMetaTimezoneSchema,
|
||||||
@@ -94,6 +95,7 @@ export const ZUseEnvelopePayloadSchema = z.object({
|
|||||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||||
|
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
|
||||||
allowDictateNextSigner: z.boolean().optional(),
|
allowDictateNextSigner: z.boolean().optional(),
|
||||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
|
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
|||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
uploadSignatureEnabled,
|
uploadSignatureEnabled,
|
||||||
drawSignatureEnabled,
|
drawSignatureEnabled,
|
||||||
|
qrSignatureEnabled,
|
||||||
defaultRecipients,
|
defaultRecipients,
|
||||||
delegateDocumentOwnership,
|
delegateDocumentOwnership,
|
||||||
envelopeExpirationPeriod,
|
envelopeExpirationPeriod,
|
||||||
@@ -104,6 +105,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
|||||||
uploadSignatureEnabled ?? organisation.organisationGlobalSettings.uploadSignatureEnabled;
|
uploadSignatureEnabled ?? organisation.organisationGlobalSettings.uploadSignatureEnabled;
|
||||||
const derivedDrawSignatureEnabled =
|
const derivedDrawSignatureEnabled =
|
||||||
drawSignatureEnabled ?? organisation.organisationGlobalSettings.drawSignatureEnabled;
|
drawSignatureEnabled ?? organisation.organisationGlobalSettings.drawSignatureEnabled;
|
||||||
|
const derivedQrSignatureEnabled = qrSignatureEnabled ?? organisation.organisationGlobalSettings.qrSignatureEnabled;
|
||||||
|
|
||||||
const derivedDelegateDocumentOwnership =
|
const derivedDelegateDocumentOwnership =
|
||||||
delegateDocumentOwnership ?? organisation.organisationGlobalSettings.delegateDocumentOwnership;
|
delegateDocumentOwnership ?? organisation.organisationGlobalSettings.delegateDocumentOwnership;
|
||||||
@@ -111,7 +113,8 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
|||||||
if (
|
if (
|
||||||
derivedTypedSignatureEnabled === false &&
|
derivedTypedSignatureEnabled === false &&
|
||||||
derivedUploadSignatureEnabled === false &&
|
derivedUploadSignatureEnabled === false &&
|
||||||
derivedDrawSignatureEnabled === false
|
derivedDrawSignatureEnabled === false &&
|
||||||
|
derivedQrSignatureEnabled === false
|
||||||
) {
|
) {
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||||
message: 'At least one signature type must be enabled',
|
message: 'At least one signature type must be enabled',
|
||||||
@@ -165,6 +168,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
|||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
uploadSignatureEnabled,
|
uploadSignatureEnabled,
|
||||||
drawSignatureEnabled,
|
drawSignatureEnabled,
|
||||||
|
qrSignatureEnabled,
|
||||||
defaultRecipients: defaultRecipients === null ? Prisma.DbNull : defaultRecipients,
|
defaultRecipients: defaultRecipients === null ? Prisma.DbNull : defaultRecipients,
|
||||||
delegateDocumentOwnership: derivedDelegateDocumentOwnership,
|
delegateDocumentOwnership: derivedDelegateDocumentOwnership,
|
||||||
envelopeExpirationPeriod: envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod,
|
envelopeExpirationPeriod: envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
|
|||||||
typedSignatureEnabled: z.boolean().optional(),
|
typedSignatureEnabled: z.boolean().optional(),
|
||||||
uploadSignatureEnabled: z.boolean().optional(),
|
uploadSignatureEnabled: z.boolean().optional(),
|
||||||
drawSignatureEnabled: z.boolean().optional(),
|
drawSignatureEnabled: z.boolean().optional(),
|
||||||
|
qrSignatureEnabled: z.boolean().optional(),
|
||||||
defaultRecipients: ZDefaultRecipientsSchema.nullish(),
|
defaultRecipients: ZDefaultRecipientsSchema.nullish(),
|
||||||
delegateDocumentOwnership: z.boolean().nullish(),
|
delegateDocumentOwnership: z.boolean().nullish(),
|
||||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.optional(),
|
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.optional(),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { folderRouter } from './folder-router/router';
|
|||||||
import { organisationRouter } from './organisation-router/router';
|
import { organisationRouter } from './organisation-router/router';
|
||||||
import { profileRouter } from './profile-router/router';
|
import { profileRouter } from './profile-router/router';
|
||||||
import { recipientRouter } from './recipient-router/router';
|
import { recipientRouter } from './recipient-router/router';
|
||||||
|
import { signatureRouter } from './signature-router/router';
|
||||||
import { teamRouter } from './team-router/router';
|
import { teamRouter } from './team-router/router';
|
||||||
import { templateRouter } from './template-router/router';
|
import { templateRouter } from './template-router/router';
|
||||||
import { router } from './trpc';
|
import { router } from './trpc';
|
||||||
@@ -24,6 +25,7 @@ export const appRouter = router({
|
|||||||
field: fieldRouter,
|
field: fieldRouter,
|
||||||
folder: folderRouter,
|
folder: folderRouter,
|
||||||
recipient: recipientRouter,
|
recipient: recipientRouter,
|
||||||
|
signature: signatureRouter,
|
||||||
admin: adminRouter,
|
admin: adminRouter,
|
||||||
organisation: organisationRouter,
|
organisation: organisationRouter,
|
||||||
apiToken: apiTokenRouter,
|
apiToken: apiTokenRouter,
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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>;
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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>;
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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;
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
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>;
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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;
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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>;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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,6 +36,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
|
|||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
uploadSignatureEnabled,
|
uploadSignatureEnabled,
|
||||||
drawSignatureEnabled,
|
drawSignatureEnabled,
|
||||||
|
qrSignatureEnabled,
|
||||||
delegateDocumentOwnership,
|
delegateDocumentOwnership,
|
||||||
envelopeExpirationPeriod,
|
envelopeExpirationPeriod,
|
||||||
reminderSettings,
|
reminderSettings,
|
||||||
@@ -66,7 +67,12 @@ export const updateTeamSettingsRoute = authenticatedProcedure
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Signatures will only be inherited if all are NULL.
|
// Signatures will only be inherited if all are NULL.
|
||||||
if (typedSignatureEnabled === false && uploadSignatureEnabled === false && drawSignatureEnabled === false) {
|
if (
|
||||||
|
typedSignatureEnabled === false &&
|
||||||
|
uploadSignatureEnabled === false &&
|
||||||
|
drawSignatureEnabled === false &&
|
||||||
|
qrSignatureEnabled === false
|
||||||
|
) {
|
||||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||||
message: 'At least one signature type must be enabled',
|
message: 'At least one signature type must be enabled',
|
||||||
});
|
});
|
||||||
@@ -168,6 +174,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
|
|||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
uploadSignatureEnabled,
|
uploadSignatureEnabled,
|
||||||
drawSignatureEnabled,
|
drawSignatureEnabled,
|
||||||
|
qrSignatureEnabled,
|
||||||
delegateDocumentOwnership,
|
delegateDocumentOwnership,
|
||||||
envelopeExpirationPeriod: envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod,
|
envelopeExpirationPeriod: envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod,
|
||||||
reminderSettings: reminderSettings === null ? Prisma.DbNull : reminderSettings,
|
reminderSettings: reminderSettings === null ? Prisma.DbNull : reminderSettings,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
|
|||||||
typedSignatureEnabled: z.boolean().nullish(),
|
typedSignatureEnabled: z.boolean().nullish(),
|
||||||
uploadSignatureEnabled: z.boolean().nullish(),
|
uploadSignatureEnabled: z.boolean().nullish(),
|
||||||
drawSignatureEnabled: z.boolean().nullish(),
|
drawSignatureEnabled: z.boolean().nullish(),
|
||||||
|
qrSignatureEnabled: z.boolean().nullish(),
|
||||||
delegateDocumentOwnership: z.boolean().nullish(),
|
delegateDocumentOwnership: z.boolean().nullish(),
|
||||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
|
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
|
||||||
reminderSettings: ZEnvelopeReminderSettings.nullish(),
|
reminderSettings: ZEnvelopeReminderSettings.nullish(),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||||
ZDocumentMetaLanguageSchema,
|
ZDocumentMetaLanguageSchema,
|
||||||
ZDocumentMetaMessageSchema,
|
ZDocumentMetaMessageSchema,
|
||||||
|
ZDocumentMetaQrSignatureEnabledSchema,
|
||||||
ZDocumentMetaRedirectUrlSchema,
|
ZDocumentMetaRedirectUrlSchema,
|
||||||
ZDocumentMetaSubjectSchema,
|
ZDocumentMetaSubjectSchema,
|
||||||
ZDocumentMetaTimezoneSchema,
|
ZDocumentMetaTimezoneSchema,
|
||||||
@@ -66,6 +67,7 @@ export const ZTemplateMetaUpsertSchema = z.object({
|
|||||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||||
|
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
|
||||||
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
||||||
allowDictateNextSigner: z.boolean().optional(),
|
allowDictateNextSigner: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
@@ -147,6 +149,7 @@ export const ZCreateDocumentFromTemplateRequestSchema = z.object({
|
|||||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||||
|
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
|
||||||
allowDictateNextSigner: z.boolean().optional(),
|
allowDictateNextSigner: z.boolean().optional(),
|
||||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
|
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
"tailwind-merge": "^1.14.0",
|
"tailwind-merge": "^1.14.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"ts-pattern": "^5.9.0",
|
"ts-pattern": "^5.9.0",
|
||||||
|
"uqr": "^0.1.2",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,9 +77,20 @@ export class Point implements PointLike {
|
|||||||
let x = Math.min(Math.max(left, clientX), right) - left;
|
let x = Math.min(Math.max(left, clientX), right) - left;
|
||||||
let y = Math.min(Math.max(top, clientY), bottom) - top;
|
let y = Math.min(Math.max(top, clientY), bottom) - top;
|
||||||
|
|
||||||
// adjust for DPI
|
// Adjust for DPI. Canvas bitmaps are sized once at mount, so if the element
|
||||||
x *= dpi;
|
// has been resized since (fluid container, device rotation) the nominal dpi
|
||||||
y *= dpi;
|
// no longer matches reality — use the actual bitmap / CSS box ratio so the
|
||||||
|
// ink always lands under the pointer.
|
||||||
|
let scaleX = dpi;
|
||||||
|
let scaleY = dpi;
|
||||||
|
|
||||||
|
if (target instanceof HTMLCanvasElement && right - left > 0 && bottom - top > 0) {
|
||||||
|
scaleX = target.width / (right - left);
|
||||||
|
scaleY = target.height / (bottom - top);
|
||||||
|
}
|
||||||
|
|
||||||
|
x *= scaleX;
|
||||||
|
y *= scaleY;
|
||||||
|
|
||||||
return new Point(x, y);
|
return new Point(x, y);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { TQrSignatureContext } from '@documenso/lib/types/qr-signature';
|
||||||
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
|
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
|
||||||
import { Dialog, DialogClose, DialogContent, DialogFooter } from '@documenso/ui/primitives/dialog';
|
import { Dialog, DialogClose, DialogContent, DialogFooter } from '@documenso/ui/primitives/dialog';
|
||||||
|
|
||||||
@@ -22,6 +23,8 @@ export type SignaturePadDialogProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'o
|
|||||||
typedSignatureEnabled?: boolean;
|
typedSignatureEnabled?: boolean;
|
||||||
uploadSignatureEnabled?: boolean;
|
uploadSignatureEnabled?: boolean;
|
||||||
drawSignatureEnabled?: boolean;
|
drawSignatureEnabled?: boolean;
|
||||||
|
qrSignatureEnabled?: boolean;
|
||||||
|
qrSignatureContext?: TQrSignatureContext;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SignaturePadDialog = ({
|
export const SignaturePadDialog = ({
|
||||||
@@ -34,6 +37,8 @@ export const SignaturePadDialog = ({
|
|||||||
typedSignatureEnabled,
|
typedSignatureEnabled,
|
||||||
uploadSignatureEnabled,
|
uploadSignatureEnabled,
|
||||||
drawSignatureEnabled,
|
drawSignatureEnabled,
|
||||||
|
qrSignatureEnabled,
|
||||||
|
qrSignatureContext,
|
||||||
dialogConfirmText,
|
dialogConfirmText,
|
||||||
}: SignaturePadDialogProps) => {
|
}: SignaturePadDialogProps) => {
|
||||||
const { i18n } = useLingui();
|
const { i18n } = useLingui();
|
||||||
@@ -121,6 +126,8 @@ export const SignaturePadDialog = ({
|
|||||||
typedSignatureEnabled={typedSignatureEnabled}
|
typedSignatureEnabled={typedSignatureEnabled}
|
||||||
uploadSignatureEnabled={uploadSignatureEnabled}
|
uploadSignatureEnabled={uploadSignatureEnabled}
|
||||||
drawSignatureEnabled={drawSignatureEnabled}
|
drawSignatureEnabled={drawSignatureEnabled}
|
||||||
|
qrSignatureEnabled={qrSignatureEnabled}
|
||||||
|
qrSignatureContext={qrSignatureContext}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -260,14 +260,14 @@ export const SignaturePadDraw = ({ className, value, onChange, ...props }: Signa
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('h-full w-full', className)}>
|
<div className={cn('h-full w-full select-none', className)}>
|
||||||
<canvas
|
<canvas
|
||||||
data-testid="signature-pad-draw"
|
data-testid="signature-pad-draw"
|
||||||
ref={$el}
|
ref={$el}
|
||||||
className={cn('h-full w-full', {
|
className={cn('h-full w-full', {
|
||||||
'dark:hue-rotate-180 dark:invert': selectedColor === 'black',
|
'dark:hue-rotate-180 dark:invert': selectedColor === 'black',
|
||||||
})}
|
})}
|
||||||
style={{ touchAction: 'none' }}
|
style={{ touchAction: 'none', WebkitTouchCallout: 'none' }}
|
||||||
onPointerMove={(event) => onMouseMove(event)}
|
onPointerMove={(event) => onMouseMove(event)}
|
||||||
onPointerDown={(event) => onMouseDown(event)}
|
onPointerDown={(event) => onMouseDown(event)}
|
||||||
onPointerUp={(event) => onMouseUp(event)}
|
onPointerUp={(event) => onMouseUp(event)}
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||||
|
import { SIGNATURE_CANVAS_DPI } from '@documenso/lib/constants/signatures';
|
||||||
|
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
||||||
|
import type { TQrSignatureContext } from '@documenso/lib/types/qr-signature';
|
||||||
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
|
||||||
|
import { Trans, useLingui } from '@lingui/react/macro';
|
||||||
|
import { Loader2Icon, RefreshCwIcon } from 'lucide-react';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { renderSVG } from 'uqr';
|
||||||
|
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import { Button } from '../button';
|
||||||
|
import { SignatureRender } from './signature-render';
|
||||||
|
|
||||||
|
export type QrSignatureSession = {
|
||||||
|
token: string;
|
||||||
|
expiresAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redraw a signature onto a canvas of the given size, scaled to fit and
|
||||||
|
* centered.
|
||||||
|
*
|
||||||
|
* The phone pad's canvas has different dimensions to the local draw pad, and
|
||||||
|
* the draw pad renders its value at natural size without scaling - committing
|
||||||
|
* the phone's PNG directly would make it render smaller (or larger) than the
|
||||||
|
* preview. Normalising to the local pad's dimensions keeps every consumer of
|
||||||
|
* the value untouched.
|
||||||
|
*/
|
||||||
|
const normalizeSignatureSize = async (dataUrl: string, targetWidth: number, targetHeight: number): Promise<string> =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
|
||||||
|
canvas.width = targetWidth;
|
||||||
|
canvas.height = targetHeight;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
if (!ctx) {
|
||||||
|
resolve(dataUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.imageSmoothingQuality = 'high';
|
||||||
|
|
||||||
|
const scale = Math.min(targetWidth / img.width, targetHeight / img.height);
|
||||||
|
|
||||||
|
const scaledWidth = img.width * scale;
|
||||||
|
const scaledHeight = img.height * scale;
|
||||||
|
|
||||||
|
ctx.drawImage(img, (targetWidth - scaledWidth) / 2, (targetHeight - scaledHeight) / 2, scaledWidth, scaledHeight);
|
||||||
|
|
||||||
|
resolve(canvas.toDataURL());
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => resolve(dataUrl);
|
||||||
|
|
||||||
|
img.src = dataUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SignaturePadQrProps = {
|
||||||
|
className?: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (_signatureDataUrl: string) => void;
|
||||||
|
session: QrSignatureSession | null;
|
||||||
|
onSessionChange: (_session: QrSignatureSession | null) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the handoff signature is for. Rendered on the mobile signing page so
|
||||||
|
* the signer can see the context of what they are signing. When omitted the
|
||||||
|
* mobile page shows a generic "Signature requested".
|
||||||
|
*/
|
||||||
|
context?: TQrSignatureContext;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "Mobile" tab of the signature pad.
|
||||||
|
*
|
||||||
|
* Displays a QR code linking to a public mobile drawing page, then polls until
|
||||||
|
* the phone submits a signature. The received signature is committed as a
|
||||||
|
* drawn (base64 PNG) signature via `onChange`.
|
||||||
|
*
|
||||||
|
* The session lives in the parent so that switching tabs does not invalidate
|
||||||
|
* an in-flight handoff (tab contents unmount when inactive).
|
||||||
|
*/
|
||||||
|
export const SignaturePadQr = ({
|
||||||
|
className,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
session,
|
||||||
|
onSessionChange,
|
||||||
|
context,
|
||||||
|
}: SignaturePadQrProps) => {
|
||||||
|
const { t } = useLingui();
|
||||||
|
|
||||||
|
const hasFiredCreateRef = useRef(false);
|
||||||
|
const $container = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Only show the preview for a signature received during this mount - a value
|
||||||
|
// drawn on another tab renders the QR code so the handoff stays available
|
||||||
|
// without destroying the committed signature.
|
||||||
|
const [hasReceivedSignature, setHasReceivedSignature] = useState(false);
|
||||||
|
|
||||||
|
const { mutate: createQrSignatureSession, isError: isCreateSessionError } = trpc.signature.qr.create.useMutation({
|
||||||
|
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
onSessionChange(data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: qrSignatureData } = trpc.signature.qr.get.useQuery(
|
||||||
|
{
|
||||||
|
token: session?.token ?? '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: Boolean(session),
|
||||||
|
refetchInterval: (query) =>
|
||||||
|
query.state.data?.status === 'COMPLETED' || query.state.data?.status === 'EXPIRED' ? false : 2500,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session && !hasFiredCreateRef.current) {
|
||||||
|
hasFiredCreateRef.current = true;
|
||||||
|
createQrSignatureSession({ context });
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (qrSignatureData?.status !== 'COMPLETED') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tab container has the same box as the draw tab, so its measured size
|
||||||
|
// matches the draw pad's canvas dimensions.
|
||||||
|
const container = $container.current;
|
||||||
|
|
||||||
|
const targetWidth = container ? Math.round(container.clientWidth * SIGNATURE_CANVAS_DPI) : 0;
|
||||||
|
const targetHeight = container ? Math.round(container.clientHeight * SIGNATURE_CANVAS_DPI) : 0;
|
||||||
|
|
||||||
|
if (targetWidth <= 0 || targetHeight <= 0) {
|
||||||
|
onChange(qrSignatureData.signature);
|
||||||
|
onSessionChange(null);
|
||||||
|
setHasReceivedSignature(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isCancelled = false;
|
||||||
|
|
||||||
|
void normalizeSignatureSize(qrSignatureData.signature, targetWidth, targetHeight).then((normalizedSignature) => {
|
||||||
|
if (!isCancelled) {
|
||||||
|
onChange(normalizedSignature);
|
||||||
|
onSessionChange(null);
|
||||||
|
setHasReceivedSignature(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
};
|
||||||
|
// Note: `onChange`/`onSessionChange` are fresh closures from the parent each
|
||||||
|
// render, so including them would re-fire this effect spuriously.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [qrSignatureData]);
|
||||||
|
|
||||||
|
const onGenerateNewCodeClick = () => {
|
||||||
|
onSessionChange(null);
|
||||||
|
createQrSignatureSession({ context });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onScanAgainClick = () => {
|
||||||
|
setHasReceivedSignature(false);
|
||||||
|
onSessionChange(null);
|
||||||
|
createQrSignatureSession({ context });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only a signature received via this tab shows the preview; any other
|
||||||
|
// value keeps the QR available.
|
||||||
|
if (value && hasReceivedSignature) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="signature-pad-qr-preview"
|
||||||
|
className={cn('relative flex h-full w-full flex-col items-center justify-center', className)}
|
||||||
|
>
|
||||||
|
<SignatureRender value={value} className="h-full w-full" />
|
||||||
|
|
||||||
|
<div className="absolute right-3 bottom-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-1 rounded-full p-0 text-[0.688rem] text-muted-foreground/60 ring-offset-background hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
onClick={() => onScanAgainClick()}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className="size-3" />
|
||||||
|
<Trans>Scan again</Trans>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCreateSessionError || qrSignatureData?.status === 'EXPIRED') {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex h-full w-full flex-col items-center justify-center gap-2', className)}>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{isCreateSessionError ? (
|
||||||
|
<Trans>Something went wrong. Please try again.</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>This QR code has expired.</Trans>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => onGenerateNewCodeClick()}>
|
||||||
|
<RefreshCwIcon className="mr-2 size-4" />
|
||||||
|
<Trans>Generate new code</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session is being created (or is about to be) - show the loader. This must
|
||||||
|
// never depend on the mutation's isPending, which can be stale after a
|
||||||
|
// StrictMode double-mount.
|
||||||
|
if (!session) {
|
||||||
|
return (
|
||||||
|
<div role="status" className={cn('flex h-full w-full items-center justify-center', className)}>
|
||||||
|
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
<span className="sr-only">
|
||||||
|
<Trans>Loading</Trans>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mobileSigningUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/mobile-signature/${session.token}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={$container}
|
||||||
|
data-testid="signature-pad-qr"
|
||||||
|
className={cn(
|
||||||
|
'flex h-full min-h-0 w-full flex-col items-center justify-center gap-2 overflow-hidden p-3',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* The QR absorbs whatever vertical space is left over, so the labels
|
||||||
|
below always keep their room and can never be pushed out of the pad. */}
|
||||||
|
<div className="flex min-h-0 flex-1 items-center justify-center">
|
||||||
|
<div
|
||||||
|
role="img"
|
||||||
|
aria-label={t`QR code for mobile signing`}
|
||||||
|
className="aspect-square h-full rounded-md bg-white p-1.5 [&>svg]:block [&>svg]:h-full [&>svg]:w-full"
|
||||||
|
// biome-ignore lint/security/noDangerouslySetInnerHtml: Expected usage to render QR.
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: renderSVG(mobileSigningUrl),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="shrink-0 text-muted-foreground text-xs">
|
||||||
|
<Trans>Scan with your phone to draw your signature</Trans>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p
|
||||||
|
data-testid="signature-pad-qr-url"
|
||||||
|
className="w-full shrink-0 truncate px-2 text-center text-[0.688rem] text-muted-foreground/60"
|
||||||
|
>
|
||||||
|
{mobileSigningUrl}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||||
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
||||||
|
import type { TQrSignatureContext } from '@documenso/lib/types/qr-signature';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { KeyboardIcon, UploadCloudIcon } from 'lucide-react';
|
import { KeyboardIcon, SmartphoneIcon, UploadCloudIcon } from 'lucide-react';
|
||||||
import type { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { match } from 'ts-pattern';
|
import { match, P } from 'ts-pattern';
|
||||||
|
|
||||||
import { SignatureIcon } from '../../icons/signature';
|
import { SignatureIcon } from '../../icons/signature';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
import { SignaturePadDraw } from './signature-pad-draw';
|
import { SignaturePadDraw } from './signature-pad-draw';
|
||||||
|
import type { QrSignatureSession } from './signature-pad-qr';
|
||||||
|
import { SignaturePadQr } from './signature-pad-qr';
|
||||||
import { SignaturePadType } from './signature-pad-type';
|
import { SignaturePadType } from './signature-pad-type';
|
||||||
import { SignaturePadUpload } from './signature-pad-upload';
|
import { SignaturePadUpload } from './signature-pad-upload';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './signature-tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from './signature-tabs';
|
||||||
@@ -29,6 +30,8 @@ export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChang
|
|||||||
typedSignatureEnabled?: boolean;
|
typedSignatureEnabled?: boolean;
|
||||||
uploadSignatureEnabled?: boolean;
|
uploadSignatureEnabled?: boolean;
|
||||||
drawSignatureEnabled?: boolean;
|
drawSignatureEnabled?: boolean;
|
||||||
|
qrSignatureEnabled?: boolean;
|
||||||
|
qrSignatureContext?: TQrSignatureContext;
|
||||||
|
|
||||||
onValidityChange?: (isValid: boolean) => void;
|
onValidityChange?: (isValid: boolean) => void;
|
||||||
};
|
};
|
||||||
@@ -41,11 +44,15 @@ export const SignaturePad = ({
|
|||||||
typedSignatureEnabled = true,
|
typedSignatureEnabled = true,
|
||||||
uploadSignatureEnabled = true,
|
uploadSignatureEnabled = true,
|
||||||
drawSignatureEnabled = true,
|
drawSignatureEnabled = true,
|
||||||
|
qrSignatureEnabled = true,
|
||||||
|
qrSignatureContext,
|
||||||
}: SignaturePadProps) => {
|
}: SignaturePadProps) => {
|
||||||
const [imageSignature, setImageSignature] = useState(isBase64Image(value) ? value : '');
|
const [imageSignature, setImageSignature] = useState(isBase64Image(value) ? value : '');
|
||||||
const [drawSignature, setDrawSignature] = useState(isBase64Image(value) ? value : '');
|
const [drawSignature, setDrawSignature] = useState(isBase64Image(value) ? value : '');
|
||||||
const [typedSignature, setTypedSignature] = useState(isBase64Image(value) ? '' : value);
|
const [typedSignature, setTypedSignature] = useState(isBase64Image(value) ? '' : value);
|
||||||
|
|
||||||
|
const [qrSession, setQrSession] = useState<QrSignatureSession | null>(null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is cooked.
|
* This is cooked.
|
||||||
*
|
*
|
||||||
@@ -53,7 +60,7 @@ export const SignaturePad = ({
|
|||||||
* the first enabled tab.
|
* the first enabled tab.
|
||||||
*/
|
*/
|
||||||
const [tab, setTab] = useState(
|
const [tab, setTab] = useState(
|
||||||
((): 'draw' | 'text' | 'image' => {
|
((): 'draw' | 'text' | 'image' | 'qr' => {
|
||||||
// First passthrough to check to see if there's a signature for a given tab.
|
// First passthrough to check to see if there's a signature for a given tab.
|
||||||
if (drawSignatureEnabled && drawSignature) {
|
if (drawSignatureEnabled && drawSignature) {
|
||||||
return 'draw';
|
return 'draw';
|
||||||
@@ -80,6 +87,10 @@ export const SignaturePad = ({
|
|||||||
return 'image';
|
return 'image';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (qrSignatureEnabled) {
|
||||||
|
return 'qr';
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error('No signature enabled');
|
throw new Error('No signature enabled');
|
||||||
})(),
|
})(),
|
||||||
);
|
);
|
||||||
@@ -111,7 +122,7 @@ export const SignaturePad = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTabChange = (value: 'draw' | 'text' | 'image') => {
|
const onTabChange = (value: 'draw' | 'text' | 'image' | 'qr') => {
|
||||||
if (disabled) {
|
if (disabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -119,7 +130,7 @@ export const SignaturePad = ({
|
|||||||
setTab(value);
|
setTab(value);
|
||||||
|
|
||||||
match(value)
|
match(value)
|
||||||
.with('draw', () => {
|
.with(P.union('draw', 'qr'), () => {
|
||||||
onDrawSignatureChange(drawSignature);
|
onDrawSignatureChange(drawSignature);
|
||||||
})
|
})
|
||||||
.with('text', () => {
|
.with('text', () => {
|
||||||
@@ -131,7 +142,7 @@ export const SignaturePad = ({
|
|||||||
.exhaustive();
|
.exhaustive();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!drawSignatureEnabled && !typedSignatureEnabled && !uploadSignatureEnabled) {
|
if (!drawSignatureEnabled && !typedSignatureEnabled && !uploadSignatureEnabled && !qrSignatureEnabled) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +153,7 @@ export const SignaturePad = ({
|
|||||||
'pointer-events-none': disabled,
|
'pointer-events-none': disabled,
|
||||||
})}
|
})}
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
onValueChange={(value) => onTabChange(value as 'draw' | 'text' | 'image')}
|
onValueChange={(value) => onTabChange(value as 'draw' | 'text' | 'image' | 'qr')}
|
||||||
>
|
>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
{drawSignatureEnabled && (
|
{drawSignatureEnabled && (
|
||||||
@@ -152,6 +163,13 @@ export const SignaturePad = ({
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{qrSignatureEnabled && (
|
||||||
|
<TabsTrigger value="qr" className="max-sm:hidden">
|
||||||
|
<SmartphoneIcon className="mr-2 size-4" />
|
||||||
|
<Trans context="Sign using a mobile phone">Mobile</Trans>
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
|
|
||||||
{typedSignatureEnabled && (
|
{typedSignatureEnabled && (
|
||||||
<TabsTrigger value="text">
|
<TabsTrigger value="text">
|
||||||
<KeyboardIcon className="mr-2 size-4" />
|
<KeyboardIcon className="mr-2 size-4" />
|
||||||
@@ -174,6 +192,19 @@ export const SignaturePad = ({
|
|||||||
<SignaturePadDraw className="h-full w-full" onChange={onDrawSignatureChange} value={drawSignature} />
|
<SignaturePadDraw className="h-full w-full" onChange={onDrawSignatureChange} value={drawSignature} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent
|
||||||
|
value="qr"
|
||||||
|
className="relative flex aspect-signature-pad items-center justify-center rounded-md border border-border bg-muted/25 text-center"
|
||||||
|
>
|
||||||
|
<SignaturePadQr
|
||||||
|
value={drawSignature}
|
||||||
|
onChange={onDrawSignatureChange}
|
||||||
|
session={qrSession}
|
||||||
|
context={qrSignatureContext}
|
||||||
|
onSessionChange={setQrSession}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent
|
<TabsContent
|
||||||
value="text"
|
value="text"
|
||||||
className="relative flex aspect-signature-pad items-center justify-center rounded-md border border-border bg-muted/25 text-center"
|
className="relative flex aspect-signature-pad items-center justify-center rounded-md border border-border bg-muted/25 text-center"
|
||||||
|
|||||||
Reference in New Issue
Block a user