mirror of
https://github.com/documenso/documenso.git
synced 2026-08-15 11:01:47 +10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c872c6f9be | ||
|
|
c767ec9fd9 |
@@ -463,6 +463,7 @@ const response = await fetch(`${BASE_URL}/template/use`, {
|
||||
typedSignatureEnabled: true,
|
||||
uploadSignatureEnabled: false,
|
||||
drawSignatureEnabled: true,
|
||||
qrSignatureEnabled: true,
|
||||
},
|
||||
distributeDocument: true,
|
||||
}),
|
||||
@@ -483,6 +484,7 @@ const response = await fetch(`${BASE_URL}/template/use`, {
|
||||
| `typedSignatureEnabled` | boolean | Allow typed signatures |
|
||||
| `uploadSignatureEnabled` | boolean | Allow uploaded signature images |
|
||||
| `drawSignatureEnabled` | boolean | Allow drawn signatures |
|
||||
| `qrSignatureEnabled` | boolean | Allow QR code handoff to a mobile device |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -390,6 +390,7 @@ const response = await fetch(`${BASE_URL}/template/update`, {
|
||||
typedSignatureEnabled: true, // Allow typed signatures
|
||||
drawSignatureEnabled: true, // Allow drawn signatures
|
||||
uploadSignatureEnabled: false, // Disable uploaded signatures
|
||||
qrSignatureEnabled: true, // Allow QR code handoff to a mobile device
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -68,6 +68,7 @@ All webhook events share a common structure:
|
||||
| `typedSignatureEnabled` | boolean | Whether typed signatures are allowed |
|
||||
| `uploadSignatureEnabled` | boolean | Whether uploaded signatures are allowed |
|
||||
| `drawSignatureEnabled` | boolean | Whether drawn signatures are allowed |
|
||||
| `qrSignatureEnabled` | boolean | Whether QR code handoff to a mobile device is allowed |
|
||||
| `language` | string | Document language code |
|
||||
| `distributionMethod` | string | How document is distributed |
|
||||
| `emailSettings` | object? | Custom email settings for this document |
|
||||
@@ -138,6 +139,7 @@ Triggered when a new document is created.
|
||||
"typedSignatureEnabled": true,
|
||||
"uploadSignatureEnabled": true,
|
||||
"drawSignatureEnabled": true,
|
||||
"qrSignatureEnabled": true,
|
||||
"language": "en",
|
||||
"distributionMethod": "EMAIL",
|
||||
"emailSettings": null
|
||||
@@ -230,6 +232,7 @@ The document status changes to `PENDING` and recipients have `sendStatus: "SENT"
|
||||
"typedSignatureEnabled": true,
|
||||
"uploadSignatureEnabled": true,
|
||||
"drawSignatureEnabled": true,
|
||||
"qrSignatureEnabled": true,
|
||||
"language": "en",
|
||||
"distributionMethod": "EMAIL",
|
||||
"emailSettings": null
|
||||
@@ -422,6 +425,7 @@ The document status changes to `COMPLETED` and `completedAt` is set.
|
||||
"typedSignatureEnabled": true,
|
||||
"uploadSignatureEnabled": true,
|
||||
"drawSignatureEnabled": true,
|
||||
"qrSignatureEnabled": true,
|
||||
"language": "en",
|
||||
"distributionMethod": "EMAIL",
|
||||
"emailSettings": null
|
||||
@@ -599,6 +603,7 @@ This event is **not** triggered when a recipient hides a document from their inb
|
||||
"typedSignatureEnabled": true,
|
||||
"uploadSignatureEnabled": true,
|
||||
"drawSignatureEnabled": true,
|
||||
"qrSignatureEnabled": true,
|
||||
"language": "en",
|
||||
"distributionMethod": "EMAIL",
|
||||
"emailSettings": null
|
||||
|
||||
@@ -13,10 +13,19 @@ export type SignFieldSignatureDialogProps = {
|
||||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
drawSignatureEnabled?: boolean;
|
||||
qrSignatureEnabled?: boolean;
|
||||
};
|
||||
|
||||
export const SignFieldSignatureDialog = createCallable<SignFieldSignatureDialogProps, string | null>(
|
||||
({ call, fullName, typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled, initialSignature }) => {
|
||||
({
|
||||
call,
|
||||
fullName,
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
qrSignatureEnabled,
|
||||
initialSignature,
|
||||
}) => {
|
||||
const [localSignature, setLocalSignature] = useState(initialSignature);
|
||||
|
||||
return (
|
||||
@@ -36,6 +45,7 @@ export const SignFieldSignatureDialog = createCallable<SignFieldSignatureDialogP
|
||||
typedSignatureEnabled={typedSignatureEnabled}
|
||||
uploadSignatureEnabled={uploadSignatureEnabled}
|
||||
drawSignatureEnabled={drawSignatureEnabled}
|
||||
qrSignatureEnabled={qrSignatureEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -470,6 +470,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
||||
qrSignatureEnabled={metadata?.qrSignatureEnabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -33,7 +33,12 @@ export type EmbedDocumentFieldsProps = {
|
||||
fields: Field[];
|
||||
metadata?: Pick<
|
||||
DocumentMeta,
|
||||
'timezone' | 'dateFormat' | 'typedSignatureEnabled' | 'uploadSignatureEnabled' | 'drawSignatureEnabled'
|
||||
| 'timezone'
|
||||
| 'dateFormat'
|
||||
| 'typedSignatureEnabled'
|
||||
| 'uploadSignatureEnabled'
|
||||
| 'drawSignatureEnabled'
|
||||
| 'qrSignatureEnabled'
|
||||
> | null;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
@@ -53,6 +58,7 @@ export const EmbedDocumentFields = ({ fields, metadata, onSignField, onUnsignFie
|
||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
||||
qrSignatureEnabled={metadata?.qrSignatureEnabled}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.INITIALS, () => (
|
||||
|
||||
@@ -461,6 +461,7 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
||||
qrSignatureEnabled={metadata?.qrSignatureEnabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -313,6 +313,7 @@ export const MultiSignDocumentSigningView = ({
|
||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
||||
qrSignatureEnabled={document.documentMeta?.qrSignatureEnabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -79,6 +79,7 @@ type SettingsSubset = Pick<
|
||||
| 'typedSignatureEnabled'
|
||||
| 'uploadSignatureEnabled'
|
||||
| 'drawSignatureEnabled'
|
||||
| 'qrSignatureEnabled'
|
||||
| 'defaultRecipients'
|
||||
| 'delegateDocumentOwnership'
|
||||
| 'aiFeaturesEnabled'
|
||||
|
||||
@@ -156,6 +156,10 @@ export const AdminGlobalSettingsSection = ({
|
||||
</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>QR signature</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.qrSignatureEnabled, inheritedSettings?.qrSignatureEnabled)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
<DetailsCard label={<Trans>Branding</Trans>}>
|
||||
<DetailsValue>{booleanValue(settings.brandingEnabled, inheritedSettings?.brandingEnabled)}</DetailsValue>
|
||||
</DetailsCard>
|
||||
|
||||
@@ -269,6 +269,7 @@ export const DirectTemplateSigningForm = ({
|
||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
||||
qrSignatureEnabled={template.templateMeta?.qrSignatureEnabled}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.INITIALS, () => (
|
||||
@@ -408,6 +409,7 @@ export const DirectTemplateSigningForm = ({
|
||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
||||
qrSignatureEnabled={template.templateMeta?.qrSignatureEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -254,6 +254,7 @@ export const DocumentSigningForm = ({
|
||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
||||
qrSignatureEnabled={document.documentMeta?.qrSignatureEnabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -408,6 +408,7 @@ export const DocumentSigningPageViewV1 = ({
|
||||
typedSignatureEnabled={documentMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={documentMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={documentMeta?.drawSignatureEnabled}
|
||||
qrSignatureEnabled={documentMeta?.qrSignatureEnabled}
|
||||
/>
|
||||
))
|
||||
.with(FieldType.INITIALS, () => <DocumentSigningInitialsField key={field.id} field={field} />)
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface DocumentSigningProviderProps {
|
||||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
drawSignatureEnabled?: boolean;
|
||||
qrSignatureEnabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -43,6 +44,7 @@ export const DocumentSigningProvider = ({
|
||||
typedSignatureEnabled = true,
|
||||
uploadSignatureEnabled = true,
|
||||
drawSignatureEnabled = true,
|
||||
qrSignatureEnabled = true,
|
||||
children,
|
||||
}: DocumentSigningProviderProps) => {
|
||||
const [fullName, setFullName] = useState(initialFullName || '');
|
||||
@@ -54,7 +56,7 @@ export const DocumentSigningProvider = ({
|
||||
const sig = initialSignature || '';
|
||||
const isBase64 = isBase64Image(sig);
|
||||
|
||||
if (isBase64 && (uploadSignatureEnabled || drawSignatureEnabled)) {
|
||||
if (isBase64 && (uploadSignatureEnabled || drawSignatureEnabled || qrSignatureEnabled)) {
|
||||
return sig;
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -34,6 +34,7 @@ export type DocumentSigningSignatureFieldProps = {
|
||||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
drawSignatureEnabled?: boolean;
|
||||
qrSignatureEnabled?: boolean;
|
||||
};
|
||||
|
||||
export const DocumentSigningSignatureField = ({
|
||||
@@ -43,6 +44,7 @@ export const DocumentSigningSignatureField = ({
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
qrSignatureEnabled,
|
||||
}: DocumentSigningSignatureFieldProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
@@ -279,6 +281,7 @@ export const DocumentSigningSignatureField = ({
|
||||
typedSignatureEnabled={typedSignatureEnabled}
|
||||
uploadSignatureEnabled={uploadSignatureEnabled}
|
||||
drawSignatureEnabled={drawSignatureEnabled}
|
||||
qrSignatureEnabled={qrSignatureEnabled}
|
||||
/>
|
||||
|
||||
<DocumentSigningDisclosure />
|
||||
|
||||
@@ -172,7 +172,9 @@ export const EnvelopeSigningProvider = ({
|
||||
|
||||
if (
|
||||
!sig &&
|
||||
(envelope.documentMeta.uploadSignatureEnabled || envelope.documentMeta.drawSignatureEnabled) &&
|
||||
(envelope.documentMeta.uploadSignatureEnabled ||
|
||||
envelope.documentMeta.drawSignatureEnabled ||
|
||||
envelope.documentMeta.qrSignatureEnabled) &&
|
||||
envelopeData.recipientSignature?.signatureImageAsBase64
|
||||
) {
|
||||
return envelopeData.recipientSignature.signatureImageAsBase64;
|
||||
@@ -182,7 +184,12 @@ export const EnvelopeSigningProvider = ({
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -174,6 +174,7 @@ export const DocumentEditForm = ({ className, initialDocument, documentRootPath
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
qrSignatureEnabled: signatureTypes.includes(DocumentSignatureType.QR),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -278,6 +278,7 @@ export const EnvelopeEditorSettingsDialog = ({ trigger, ...props }: EnvelopeEdit
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
qrSignatureEnabled: signatureTypes.includes(DocumentSignatureType.QR),
|
||||
envelopeExpirationPeriod,
|
||||
reminderSettings,
|
||||
},
|
||||
|
||||
@@ -121,6 +121,7 @@ export default function EnvelopeSignerForm() {
|
||||
typedSignatureEnabled={envelope.documentMeta.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={envelope.documentMeta.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={envelope.documentMeta.drawSignatureEnabled}
|
||||
qrSignatureEnabled={envelope.documentMeta.qrSignatureEnabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -384,6 +384,7 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled,
|
||||
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled,
|
||||
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled,
|
||||
qrSignatureEnabled: envelope.documentMeta.qrSignatureEnabled,
|
||||
})
|
||||
.then(async (payload) => {
|
||||
if (!payload) {
|
||||
|
||||
@@ -137,6 +137,7 @@ export const TemplateEditForm = ({ initialTemplate, className, templateRootPath
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
qrSignatureEnabled: signatureTypes.includes(DocumentSignatureType.QR),
|
||||
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -88,6 +88,7 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
qrSignatureEnabled: signatureTypes.includes(DocumentSignatureType.QR),
|
||||
delegateDocumentOwnership,
|
||||
aiFeaturesEnabled,
|
||||
envelopeExpirationPeriod: envelopeExpirationPeriod ?? undefined,
|
||||
|
||||
@@ -76,11 +76,13 @@ export default function TeamsSettingsPage() {
|
||||
typedSignatureEnabled: null,
|
||||
uploadSignatureEnabled: null,
|
||||
drawSignatureEnabled: null,
|
||||
qrSignatureEnabled: null,
|
||||
}
|
||||
: {
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
qrSignatureEnabled: signatureTypes.includes(DocumentSignatureType.QR),
|
||||
}),
|
||||
delegateDocumentOwnership,
|
||||
},
|
||||
|
||||
@@ -215,6 +215,7 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV
|
||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
||||
qrSignatureEnabled={template.templateMeta?.qrSignatureEnabled}
|
||||
>
|
||||
<DocumentSigningAuthProvider
|
||||
documentAuthOptions={template.authOptions}
|
||||
|
||||
@@ -474,6 +474,7 @@ const SigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loade
|
||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
||||
qrSignatureEnabled={document.documentMeta?.qrSignatureEnabled}
|
||||
>
|
||||
<DocumentSigningAuthProvider documentAuthOptions={document.authOptions} recipient={recipient} user={user}>
|
||||
{sessionData?.user && <AuthenticatedHeader />}
|
||||
|
||||
@@ -266,6 +266,7 @@ const EmbedDirectTemplatePageV1 = ({ data }: { data: Awaited<ReturnType<typeof h
|
||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
||||
qrSignatureEnabled={template.templateMeta?.qrSignatureEnabled}
|
||||
>
|
||||
<DocumentSigningAuthProvider documentAuthOptions={template.authOptions} recipient={recipient} user={user}>
|
||||
<DocumentSigningRecipientProvider recipient={recipient}>
|
||||
|
||||
@@ -354,6 +354,7 @@ const EmbedSignDocumentPageV1 = ({ data }: { data: Awaited<ReturnType<typeof han
|
||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
||||
qrSignatureEnabled={document.documentMeta?.qrSignatureEnabled}
|
||||
>
|
||||
<DocumentSigningAuthProvider documentAuthOptions={document.authOptions} recipient={recipient} user={user}>
|
||||
<EmbedSignDocumentV1ClientPage
|
||||
|
||||
@@ -83,6 +83,7 @@ export default function EmbeddingAuthoringDocumentCreatePage() {
|
||||
drawSignatureEnabled: signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
typedSignatureEnabled: signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
qrSignatureEnabled: signatureTypes.length === 0 || signatureTypes.includes(DocumentSignatureType.QR),
|
||||
},
|
||||
recipients: configuration.signers.map((signer) => ({
|
||||
name: signer.name,
|
||||
|
||||
@@ -101,6 +101,10 @@ export default function EmbeddingAuthoringDocumentEditPage() {
|
||||
types.push(DocumentSignatureType.UPLOAD);
|
||||
}
|
||||
|
||||
if (document.documentMeta?.qrSignatureEnabled) {
|
||||
types.push(DocumentSignatureType.QR);
|
||||
}
|
||||
|
||||
return types;
|
||||
}, [document.documentMeta]);
|
||||
|
||||
@@ -216,6 +220,10 @@ export default function EmbeddingAuthoringDocumentEditPage() {
|
||||
? configuration.meta.signatureTypes.length === 0 ||
|
||||
configuration.meta.signatureTypes.includes(DocumentSignatureType.UPLOAD)
|
||||
: undefined,
|
||||
qrSignatureEnabled: configuration.meta.signatureTypes
|
||||
? configuration.meta.signatureTypes.length === 0 ||
|
||||
configuration.meta.signatureTypes.includes(DocumentSignatureType.QR)
|
||||
: undefined,
|
||||
},
|
||||
recipients: configuration.signers.map((signer) => ({
|
||||
id: signer.nativeId,
|
||||
|
||||
@@ -101,6 +101,10 @@ export default function EmbeddingAuthoringTemplateEditPage() {
|
||||
types.push(DocumentSignatureType.UPLOAD);
|
||||
}
|
||||
|
||||
if (template.templateMeta?.qrSignatureEnabled) {
|
||||
types.push(DocumentSignatureType.QR);
|
||||
}
|
||||
|
||||
return types;
|
||||
}, [template.templateMeta]);
|
||||
|
||||
@@ -215,6 +219,10 @@ export default function EmbeddingAuthoringTemplateEditPage() {
|
||||
? configuration.meta.signatureTypes.length === 0 ||
|
||||
configuration.meta.signatureTypes.includes(DocumentSignatureType.UPLOAD)
|
||||
: undefined,
|
||||
qrSignatureEnabled: configuration.meta.signatureTypes
|
||||
? configuration.meta.signatureTypes.length === 0 ||
|
||||
configuration.meta.signatureTypes.includes(DocumentSignatureType.QR)
|
||||
: undefined,
|
||||
},
|
||||
recipients: configuration.signers.map((signer) => ({
|
||||
id: signer.nativeId,
|
||||
|
||||
@@ -238,6 +238,7 @@ export default function MultisignPage() {
|
||||
typedSignatureEnabled={selectedDocument.documentMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={selectedDocument.documentMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={selectedDocument.documentMeta?.drawSignatureEnabled}
|
||||
qrSignatureEnabled={selectedDocument.documentMeta?.qrSignatureEnabled}
|
||||
>
|
||||
<DocumentSigningAuthProvider
|
||||
documentAuthOptions={selectedDocument.authOptions}
|
||||
|
||||
@@ -224,6 +224,7 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
|
||||
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled ?? undefined,
|
||||
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled ?? undefined,
|
||||
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled ?? undefined,
|
||||
qrSignatureEnabled: envelope.documentMeta.qrSignatureEnabled ?? undefined,
|
||||
dateFormat: (envelope.documentMeta.dateFormat as TDocumentMetaDateFormat) ?? undefined,
|
||||
language: envelope.documentMeta.language as SupportedLanguageCodes,
|
||||
},
|
||||
|
||||
@@ -239,6 +239,7 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
|
||||
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled, //
|
||||
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled, //
|
||||
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled, //
|
||||
qrSignatureEnabled: envelope.documentMeta.qrSignatureEnabled, //
|
||||
dateFormat: (envelope.documentMeta.dateFormat as TDocumentMetaDateFormat) ?? undefined,
|
||||
language: envelope.documentMeta.language as SupportedLanguageCodes,
|
||||
},
|
||||
|
||||
@@ -12,12 +12,21 @@ type HandleSignatureFieldClickOptions = {
|
||||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
drawSignatureEnabled?: boolean;
|
||||
qrSignatureEnabled?: boolean;
|
||||
};
|
||||
|
||||
export const handleSignatureFieldClick = async (
|
||||
options: HandleSignatureFieldClickOptions,
|
||||
): Promise<Extract<TSignEnvelopeFieldValue, { type: typeof FieldType.SIGNATURE }> | null> => {
|
||||
const { field, fullName, signature, typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled } = options;
|
||||
const {
|
||||
field,
|
||||
fullName,
|
||||
signature,
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
qrSignatureEnabled,
|
||||
} = options;
|
||||
|
||||
if (field.type !== FieldType.SIGNATURE) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
@@ -40,6 +49,7 @@ export const handleSignatureFieldClick = async (
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
qrSignatureEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Generated
+1
@@ -33965,6 +33965,7 @@
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"uqr": "^0.1.2",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -438,6 +438,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
typedSignatureEnabled: body.meta.typedSignatureEnabled,
|
||||
uploadSignatureEnabled: body.meta.uploadSignatureEnabled,
|
||||
drawSignatureEnabled: body.meta.drawSignatureEnabled,
|
||||
qrSignatureEnabled: body.meta.qrSignatureEnabled,
|
||||
distributionMethod: body.meta.distributionMethod,
|
||||
emailSettings: body.meta.emailSettings,
|
||||
},
|
||||
|
||||
@@ -196,6 +196,7 @@ test.describe('API V2 Envelopes', () => {
|
||||
typedSignatureEnabled: true,
|
||||
uploadSignatureEnabled: false,
|
||||
drawSignatureEnabled: false,
|
||||
qrSignatureEnabled: false,
|
||||
emailReplyTo: userA.email,
|
||||
emailSettings: {
|
||||
recipientSigningRequest: false,
|
||||
@@ -295,6 +296,7 @@ test.describe('API V2 Envelopes', () => {
|
||||
expect(envelope.documentMeta.typedSignatureEnabled).toBe(payload.meta.typedSignatureEnabled);
|
||||
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(payload.meta.uploadSignatureEnabled);
|
||||
expect(envelope.documentMeta.drawSignatureEnabled).toBe(payload.meta.drawSignatureEnabled);
|
||||
expect(envelope.documentMeta.qrSignatureEnabled).toBe(payload.meta.qrSignatureEnabled);
|
||||
expect(envelope.documentMeta.emailReplyTo).toBe(payload.meta.emailReplyTo);
|
||||
expect(envelope.documentMeta.emailSettings).toEqual(payload.meta.emailSettings);
|
||||
|
||||
|
||||
@@ -158,6 +158,7 @@ test.describe('AutoSave Settings Step', () => {
|
||||
expect(retrieved.documentMeta?.drawSignatureEnabled).toBe(false);
|
||||
expect(retrieved.documentMeta?.typedSignatureEnabled).toBe(false);
|
||||
expect(retrieved.documentMeta?.uploadSignatureEnabled).toBe(true);
|
||||
expect(retrieved.documentMeta?.qrSignatureEnabled).toBe(true);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
|
||||
@@ -384,6 +384,7 @@ const assertEnvelopeSettingsPersistedInDatabase = async ({
|
||||
expect(envelope.documentMeta.drawSignatureEnabled).toBe(true);
|
||||
expect(envelope.documentMeta.typedSignatureEnabled).toBe(true);
|
||||
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(false);
|
||||
expect(envelope.documentMeta.qrSignatureEnabled).toBe(true);
|
||||
expect(envelope.documentMeta.emailSettings).toMatchObject(DB_EXPECTED_VALUES.emailSettings);
|
||||
|
||||
const authOptions = parseAuthOptions(envelope.authOptions);
|
||||
|
||||
@@ -56,6 +56,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
||||
expect(teamSettings.typedSignatureEnabled).toEqual(true);
|
||||
expect(teamSettings.uploadSignatureEnabled).toEqual(false);
|
||||
expect(teamSettings.drawSignatureEnabled).toEqual(false);
|
||||
expect(teamSettings.qrSignatureEnabled).toEqual(true);
|
||||
|
||||
// Edit the team settings
|
||||
await page.goto(`/t/${team.url}/settings/document`);
|
||||
@@ -90,6 +91,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
||||
expect(updatedTeamSettings.typedSignatureEnabled).toEqual(true);
|
||||
expect(updatedTeamSettings.uploadSignatureEnabled).toEqual(false);
|
||||
expect(updatedTeamSettings.drawSignatureEnabled).toEqual(false);
|
||||
expect(updatedTeamSettings.qrSignatureEnabled).toEqual(true);
|
||||
|
||||
const document = await seedTeamDocumentWithMeta(team);
|
||||
|
||||
@@ -105,6 +107,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
||||
expect(documentMeta.typedSignatureEnabled).toEqual(true);
|
||||
expect(documentMeta.uploadSignatureEnabled).toEqual(false);
|
||||
expect(documentMeta.drawSignatureEnabled).toEqual(false);
|
||||
expect(documentMeta.qrSignatureEnabled).toEqual(true);
|
||||
expect(documentMeta.language).toEqual('pl');
|
||||
expect(documentMeta.timezone).toEqual('Europe/London');
|
||||
expect(documentMeta.dateFormat).toEqual('MM/dd/yyyy');
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { AnonymousVerificationTokenType, FieldType } from '@documenso/prisma/client';
|
||||
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
/**
|
||||
* Draw a zig-zag onto the drawing canvas so that it passes the minimum
|
||||
* signature coverage threshold.
|
||||
*/
|
||||
const drawOnSignaturePad = async (page: Page) => {
|
||||
const canvas = page.getByTestId('signature-pad-draw');
|
||||
|
||||
await canvas.waitFor({ state: 'visible' });
|
||||
|
||||
let capturedBox: { x: number; y: number; width: number; height: number } | null = null;
|
||||
|
||||
// `boundingBox()` can return null if the canvas is replaced mid-hydration,
|
||||
// so poll until a measurable element is attached, capturing the box inside
|
||||
// the retry closure so it is never re-fetched (and re-raced) afterwards.
|
||||
await expect(async () => {
|
||||
capturedBox = await canvas.boundingBox();
|
||||
|
||||
expect(capturedBox).not.toBeNull();
|
||||
expect(capturedBox?.width ?? 0).toBeGreaterThan(0);
|
||||
}).toPass({ timeout: 5_000 });
|
||||
|
||||
// TS cannot see the closure assignment above, so widen the type back out.
|
||||
const box = capturedBox as { x: number; y: number; width: number; height: number } | null;
|
||||
|
||||
if (!box) {
|
||||
throw new Error('Signature pad canvas not found');
|
||||
}
|
||||
|
||||
await page.mouse.move(box.x + box.width * 0.15, box.y + box.height * 0.5);
|
||||
await page.mouse.down();
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await page.mouse.move(box.x + box.width * (0.15 + i * 0.09), box.y + box.height * (i % 2 === 0 ? 0.25 : 0.75), {
|
||||
steps: 10,
|
||||
});
|
||||
}
|
||||
|
||||
await page.mouse.up();
|
||||
};
|
||||
|
||||
test('[QR_SIGNATURE]: complete signing via mobile qr handoff', async ({ page, browser }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
recipients: ['qr-signer@test.documenso.com'],
|
||||
teamId: team.id,
|
||||
fields: [FieldType.SIGNATURE],
|
||||
});
|
||||
|
||||
const recipient = recipients[0];
|
||||
|
||||
await page.goto(`/sign/${recipient.token}`);
|
||||
|
||||
// Wait for the client-side PDF render so we know the page has hydrated
|
||||
// before interacting with the signature pad.
|
||||
await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR);
|
||||
|
||||
// Open the signature dialog and switch to the Mobile tab.
|
||||
await page.getByTestId('signature-pad-dialog-button').click();
|
||||
await page.getByRole('tab', { name: 'Mobile' }).click();
|
||||
|
||||
// Read the handoff URL rendered beneath the QR code.
|
||||
await expect(page.getByTestId('signature-pad-qr-url')).toBeVisible();
|
||||
const handoffUrl = await page.getByTestId('signature-pad-qr-url').textContent();
|
||||
|
||||
expect(handoffUrl).toContain('/mobile-signature/');
|
||||
|
||||
// Open the mobile page in a fully isolated browser context (no shared
|
||||
// cookies or session) to prove the handoff requires no authentication.
|
||||
const mobileContext = await browser.newContext();
|
||||
const mobilePage = await mobileContext.newPage();
|
||||
|
||||
await mobilePage.goto(handoffUrl ?? '');
|
||||
|
||||
await expect(mobilePage.getByRole('heading', { name: 'Draw your signature' })).toBeVisible();
|
||||
|
||||
await drawOnSignaturePad(mobilePage);
|
||||
|
||||
await mobilePage.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
await expect(mobilePage.getByText('Signature sent')).toBeVisible();
|
||||
|
||||
await mobileContext.close();
|
||||
|
||||
// The desktop pad should receive the signature within a poll interval.
|
||||
await expect(page.getByTestId('signature-pad-qr-preview')).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The session is single-use: the desktop pickup deletes the row on read, and
|
||||
// a missing row is indistinguishable from an expired one by design. So a
|
||||
// revisit must show the expired page (not "Signature already sent"), which
|
||||
// proves the deletion happened.
|
||||
const revisitContext = await browser.newContext();
|
||||
const revisitPage = await revisitContext.newPage();
|
||||
|
||||
await revisitPage.goto(handoffUrl ?? '');
|
||||
|
||||
await expect(revisitPage.getByRole('heading', { name: 'This link has expired' })).toBeVisible();
|
||||
|
||||
await revisitContext.close();
|
||||
|
||||
// Direct proof of consumption: the token row must be gone from the database.
|
||||
const consumedToken = (handoffUrl ?? '').split('/mobile-signature/')[1];
|
||||
|
||||
const consumedRow = await prisma.anonymousVerificationToken.findFirst({
|
||||
where: { token: consumedToken },
|
||||
});
|
||||
|
||||
expect(consumedRow).toBeNull();
|
||||
|
||||
// Confirm and finish signing the document.
|
||||
await page.getByRole('button', { name: 'Next' }).click();
|
||||
|
||||
await page.locator('[data-field-type="SIGNATURE"]:not([data-readonly="true"])').first().click();
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
|
||||
await page.waitForURL(`/sign/${recipient.token}/complete`);
|
||||
await expect(page.getByText('Document Signed')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[QR_SIGNATURE]: mobile tab hidden when qr disabled', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
recipients: ['qr-disabled-signer@test.documenso.com'],
|
||||
teamId: team.id,
|
||||
fields: [FieldType.SIGNATURE],
|
||||
});
|
||||
|
||||
// Seeded documents create their meta row with bare column defaults, which
|
||||
// leave qrSignatureEnabled true, so disable it directly on the meta row.
|
||||
await prisma.documentMeta.update({
|
||||
where: { id: document.documentMetaId },
|
||||
data: { qrSignatureEnabled: false },
|
||||
});
|
||||
|
||||
const recipient = recipients[0];
|
||||
|
||||
await page.goto(`/sign/${recipient.token}`);
|
||||
|
||||
await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR);
|
||||
|
||||
await page.getByTestId('signature-pad-dialog-button').click();
|
||||
|
||||
// Waiting on the Draw tab first guarantees the tab list has rendered before
|
||||
// asserting the Mobile tab is absent.
|
||||
await expect(page.getByRole('tab', { name: 'Draw' })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: 'Mobile' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[QR_SIGNATURE]: mobile tab shown when draw disabled but qr enabled', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
recipients: ['qr-only-signer@test.documenso.com'],
|
||||
teamId: team.id,
|
||||
fields: [FieldType.SIGNATURE],
|
||||
});
|
||||
|
||||
// qrSignatureEnabled already defaults to true on seeded metas, but set it
|
||||
// explicitly so the test still documents the required state if defaults change.
|
||||
await prisma.documentMeta.update({
|
||||
where: { id: document.documentMetaId },
|
||||
data: { drawSignatureEnabled: false, qrSignatureEnabled: true },
|
||||
});
|
||||
|
||||
const recipient = recipients[0];
|
||||
|
||||
await page.goto(`/sign/${recipient.token}`);
|
||||
|
||||
await page.waitForSelector(PDF_VIEWER_PAGE_SELECTOR);
|
||||
|
||||
await page.getByTestId('signature-pad-dialog-button').click();
|
||||
|
||||
await expect(page.getByRole('tab', { name: 'Mobile' })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: 'Draw' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[QR_SIGNATURE]: unknown token shows expired page', async ({ page }) => {
|
||||
await page.goto('/mobile-signature/this-token-does-not-exist');
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'This link has expired' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[QR_SIGNATURE]: expired token shows expired page', async ({ page }) => {
|
||||
const expiredToken = `qr-e2e-expired-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
|
||||
|
||||
await prisma.anonymousVerificationToken.create({
|
||||
data: {
|
||||
type: AnonymousVerificationTokenType.QR_SIGNATURE,
|
||||
token: expiredToken,
|
||||
expiresAt: new Date(Date.now() - 60_000),
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`/mobile-signature/${expiredToken}`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'This link has expired' })).toBeVisible();
|
||||
});
|
||||
@@ -25,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: 'Upload' })).toBeVisible();
|
||||
await expect(page.getByRole('combobox').filter({ hasText: 'Draw' })).toBeVisible();
|
||||
await expect(page.getByRole('combobox').filter({ hasText: 'QR code' })).toBeVisible();
|
||||
|
||||
// Go to document and check that the signatured tabs are correct.
|
||||
await page.goto(`/sign/${document.recipients[0].token}`);
|
||||
@@ -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: 'Upload' })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: 'Draw' })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: 'Mobile' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
|
||||
@@ -45,8 +47,11 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/settings/document`,
|
||||
});
|
||||
|
||||
const allTabs = ['Type', 'Upload', 'Draw'];
|
||||
const tabTest = [['Type', 'Upload', 'Draw'], ['Type', 'Upload'], ['Type']];
|
||||
// The 'QR code' signature type is surfaced as the 'Mobile' tab on the signing dialog.
|
||||
const allSignatureOptions = ['Type', 'Upload', 'Draw', 'QR code'];
|
||||
const tabNameForOption = (option: string) => (option === 'QR code' ? 'Mobile' : option);
|
||||
|
||||
const tabTest = [['Type', 'Upload', 'Draw', 'QR code'], ['Type', 'Upload'], ['Type']];
|
||||
|
||||
for (const tabs of tabTest) {
|
||||
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: 'Upload' })).toBeVisible();
|
||||
await expect(page.getByRole('option', { name: 'Draw' })).toBeVisible();
|
||||
await expect(page.getByRole('option', { name: 'QR code' })).toBeVisible();
|
||||
|
||||
// Clear all selected items.
|
||||
for (const tab of allTabs) {
|
||||
for (const tab of allSignatureOptions) {
|
||||
const item = page.getByRole('option', { name: tab });
|
||||
|
||||
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"]');
|
||||
|
||||
// Check the tab values
|
||||
for (const tab of allTabs) {
|
||||
if (tabs.includes(tab)) {
|
||||
await expect(page.getByRole('tab', { name: tab })).toBeVisible();
|
||||
for (const option of allSignatureOptions) {
|
||||
const tabName = tabNameForOption(option);
|
||||
|
||||
if (tabs.includes(option)) {
|
||||
await expect(page.getByRole('tab', { name: tabName })).toBeVisible();
|
||||
} else {
|
||||
// await expect(page.getByRole('tab', { name: tab })).not.toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: tab })).toHaveCount(0);
|
||||
await expect(page.getByRole('tab', { name: tabName })).toHaveCount(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,8 +117,8 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
|
||||
redirectPath: `/t/${team.url}/settings/document`,
|
||||
});
|
||||
|
||||
const allTabs = ['Type', 'Upload', 'Draw'];
|
||||
const tabTest = [['Type', 'Upload', 'Draw'], ['Type', 'Upload'], ['Type']];
|
||||
const allSignatureOptions = ['Type', 'Upload', 'Draw', 'QR code'];
|
||||
const tabTest = [['Type', 'Upload', 'Draw', 'QR code'], ['Type', 'Upload'], ['Type']];
|
||||
|
||||
for (const tabs of tabTest) {
|
||||
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: 'Upload' })).toBeVisible();
|
||||
await expect(page.getByRole('option', { name: 'Draw' })).toBeVisible();
|
||||
await expect(page.getByRole('option', { name: 'QR code' })).toBeVisible();
|
||||
|
||||
// Clear all selected items.
|
||||
for (const tab of allTabs) {
|
||||
for (const tab of allSignatureOptions) {
|
||||
const item = page.getByRole('option', { name: tab });
|
||||
|
||||
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?.uploadSignatureEnabled).toEqual(tabs.includes('Upload'));
|
||||
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?.typedSignatureEnabled).toBe(false);
|
||||
expect(retrievedTemplate.templateMeta?.uploadSignatureEnabled).toBe(true);
|
||||
expect(retrievedTemplate.templateMeta?.qrSignatureEnabled).toBe(true);
|
||||
}).toPass();
|
||||
});
|
||||
|
||||
|
||||
@@ -77,4 +77,11 @@ export const DOCUMENT_SIGNATURE_TYPES = {
|
||||
}),
|
||||
value: DocumentSignatureType.UPLOAD,
|
||||
},
|
||||
[DocumentSignatureType.QR]: {
|
||||
label: msg({
|
||||
message: `QR code`,
|
||||
context: `Sign using a mobile phone via QR code`,
|
||||
}),
|
||||
value: DocumentSignatureType.QR,
|
||||
},
|
||||
} satisfies Record<DocumentSignatureType, DocumentSignatureTypeData>;
|
||||
|
||||
@@ -2,3 +2,5 @@ export const SIGNATURE_CANVAS_DPI = 2;
|
||||
export const SIGNATURE_MIN_COVERAGE_THRESHOLD = 0.01;
|
||||
|
||||
export const isBase64Image = (value: string) => value.startsWith('data:image/png;base64,');
|
||||
|
||||
export const QR_SIGNATURE_TOKEN_EXPIRY_MINUTES = 10;
|
||||
|
||||
@@ -20,6 +20,7 @@ import { ADMIN_DELETE_ORGANISATION_JOB_DEFINITION } from './definitions/internal
|
||||
import { BACKPORT_SUBSCRIPTION_CLAIM_JOB_DEFINITION } from './definitions/internal/backport-subscription-claims';
|
||||
import { BULK_SEND_TEMPLATE_JOB_DEFINITION } from './definitions/internal/bulk-send-template';
|
||||
import { CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION } from './definitions/internal/cancel-organisation-subscription';
|
||||
import { CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION } from './definitions/internal/cleanup-anonymous-tokens';
|
||||
import { CLEANUP_RATE_LIMITS_JOB_DEFINITION } from './definitions/internal/cleanup-rate-limits';
|
||||
import { EXECUTE_WEBHOOK_JOB_DEFINITION } from './definitions/internal/execute-webhook';
|
||||
import { EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION } from './definitions/internal/expire-recipients-sweep';
|
||||
@@ -62,6 +63,7 @@ export const jobsClient = new JobClient([
|
||||
SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION,
|
||||
PROCESS_SIGNING_REMINDER_JOB_DEFINITION,
|
||||
CLEANUP_RATE_LIMITS_JOB_DEFINITION,
|
||||
CLEANUP_ANONYMOUS_TOKENS_JOB_DEFINITION,
|
||||
SYNC_EMAIL_DOMAINS_JOB_DEFINITION,
|
||||
ADMIN_DELETE_ORGANISATION_JOB_DEFINITION,
|
||||
CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { AnonymousVerificationTokenType } from '@prisma/client';
|
||||
import { generateAuthenticationOptions } from '@simplewebauthn/server';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
@@ -24,12 +25,14 @@ export const createPasskeySigninOptions = async ({ sessionId }: CreatePasskeySig
|
||||
id: sessionId,
|
||||
},
|
||||
update: {
|
||||
type: AnonymousVerificationTokenType.PASSKEY,
|
||||
token: challenge,
|
||||
expiresAt: DateTime.now().plus({ minutes: 2 }).toJSDate(),
|
||||
createdAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
id: sessionId,
|
||||
type: AnonymousVerificationTokenType.PASSKEY,
|
||||
token: challenge,
|
||||
expiresAt: DateTime.now().plus({ minutes: 2 }).toJSDate(),
|
||||
createdAt: new Date(),
|
||||
|
||||
@@ -31,6 +31,7 @@ export type CreateDocumentMetaOptions = {
|
||||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
drawSignatureEnabled?: boolean;
|
||||
qrSignatureEnabled?: boolean;
|
||||
language?: SupportedLanguageCodes;
|
||||
requestMetadata: ApiRequestMetadata;
|
||||
};
|
||||
@@ -53,6 +54,7 @@ export const updateDocumentMeta = async ({
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
qrSignatureEnabled,
|
||||
language,
|
||||
requestMetadata,
|
||||
}: CreateDocumentMetaOptions) => {
|
||||
@@ -132,6 +134,7 @@ export const updateDocumentMeta = async ({
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
qrSignatureEnabled,
|
||||
language,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -47,6 +47,7 @@ export const ZEnvelopeForSigningResponse = z.object({
|
||||
typedSignatureEnabled: true,
|
||||
uploadSignatureEnabled: true,
|
||||
drawSignatureEnabled: true,
|
||||
qrSignatureEnabled: true,
|
||||
allowDictateNextSigner: true,
|
||||
language: true,
|
||||
}),
|
||||
|
||||
@@ -112,6 +112,7 @@ export type CreateDocumentFromTemplateOptions = {
|
||||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
drawSignatureEnabled?: boolean;
|
||||
qrSignatureEnabled?: boolean;
|
||||
envelopeExpirationPeriod?: TEnvelopeExpirationPeriod | null;
|
||||
};
|
||||
|
||||
@@ -540,6 +541,7 @@ export const createDocumentFromTemplate = async ({
|
||||
typedSignatureEnabled: override?.typedSignatureEnabled ?? template.documentMeta?.typedSignatureEnabled,
|
||||
uploadSignatureEnabled: override?.uploadSignatureEnabled ?? template.documentMeta?.uploadSignatureEnabled,
|
||||
drawSignatureEnabled: override?.drawSignatureEnabled ?? template.documentMeta?.drawSignatureEnabled,
|
||||
qrSignatureEnabled: override?.qrSignatureEnabled ?? template.documentMeta?.qrSignatureEnabled,
|
||||
allowDictateNextSigner: override?.allowDictateNextSigner ?? template.documentMeta?.allowDictateNextSigner,
|
||||
envelopeExpirationPeriod: override?.envelopeExpirationPeriod ?? template.documentMeta?.envelopeExpirationPeriod,
|
||||
},
|
||||
|
||||
@@ -46,6 +46,7 @@ export const generateSampleWebhookPayload = (event: WebhookTriggerEvents, webhoo
|
||||
typedSignatureEnabled: true,
|
||||
uploadSignatureEnabled: true,
|
||||
drawSignatureEnabled: true,
|
||||
qrSignatureEnabled: true,
|
||||
language: 'en',
|
||||
distributionMethod: DocumentDistributionMethod.EMAIL,
|
||||
emailSettings: null,
|
||||
|
||||
@@ -28,6 +28,7 @@ export const ZDocumentMetaSchema = DocumentMetaSchema.pick({
|
||||
typedSignatureEnabled: true,
|
||||
uploadSignatureEnabled: true,
|
||||
drawSignatureEnabled: true,
|
||||
qrSignatureEnabled: true,
|
||||
language: true,
|
||||
emailSettings: true,
|
||||
});
|
||||
@@ -105,6 +106,10 @@ export const ZDocumentMetaUploadSignatureEnabledSchema = z
|
||||
.boolean()
|
||||
.describe('Whether to allow recipients to sign using an uploaded signature.');
|
||||
|
||||
export const ZDocumentMetaQrSignatureEnabledSchema = z
|
||||
.boolean()
|
||||
.describe('Whether to allow recipients to sign using a QR code handoff to a mobile device.');
|
||||
|
||||
/**
|
||||
* Note: Any updates to this will cause public API changes. You will need to update
|
||||
* all corresponding areas where this is used (some places that use this needs to pass
|
||||
@@ -123,6 +128,7 @@ export const ZDocumentMetaCreateSchema = z.object({
|
||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
|
||||
emailId: z.string().nullish(),
|
||||
emailReplyTo: zEmail().nullish(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema.nullish(),
|
||||
|
||||
@@ -62,6 +62,7 @@ export const ZDocumentSchema = LegacyDocumentSchema.pick({
|
||||
typedSignatureEnabled: true,
|
||||
uploadSignatureEnabled: true,
|
||||
drawSignatureEnabled: true,
|
||||
qrSignatureEnabled: true,
|
||||
allowDictateNextSigner: true,
|
||||
language: true,
|
||||
emailSettings: true,
|
||||
|
||||
@@ -279,6 +279,7 @@ export const ZEditorEnvelopeSchema = EnvelopeSchema.pick({
|
||||
typedSignatureEnabled: true,
|
||||
uploadSignatureEnabled: true,
|
||||
drawSignatureEnabled: true,
|
||||
qrSignatureEnabled: true,
|
||||
allowDictateNextSigner: true,
|
||||
language: true,
|
||||
emailSettings: true,
|
||||
|
||||
@@ -49,6 +49,7 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({
|
||||
typedSignatureEnabled: true,
|
||||
uploadSignatureEnabled: true,
|
||||
drawSignatureEnabled: true,
|
||||
qrSignatureEnabled: true,
|
||||
allowDictateNextSigner: true,
|
||||
language: true,
|
||||
emailSettings: true,
|
||||
|
||||
@@ -54,6 +54,7 @@ export const ZTemplateSchema = TemplateSchema.pick({
|
||||
typedSignatureEnabled: true,
|
||||
uploadSignatureEnabled: true,
|
||||
drawSignatureEnabled: true,
|
||||
qrSignatureEnabled: true,
|
||||
allowDictateNextSigner: true,
|
||||
distributionMethod: true,
|
||||
redirectUrl: true,
|
||||
|
||||
@@ -55,6 +55,7 @@ export const ZWebhookDocumentMetaSchema = z.object({
|
||||
typedSignatureEnabled: z.boolean(),
|
||||
uploadSignatureEnabled: z.boolean(),
|
||||
drawSignatureEnabled: z.boolean(),
|
||||
qrSignatureEnabled: z.boolean(),
|
||||
language: z.string(),
|
||||
distributionMethod: z.nativeEnum(DocumentDistributionMethod),
|
||||
emailSettings: z.any().nullable(),
|
||||
|
||||
@@ -59,6 +59,7 @@ export const extractDerivedDocumentMeta = (
|
||||
typedSignatureEnabled: meta.typedSignatureEnabled ?? settings.typedSignatureEnabled,
|
||||
uploadSignatureEnabled: meta.uploadSignatureEnabled ?? settings.uploadSignatureEnabled,
|
||||
drawSignatureEnabled: meta.drawSignatureEnabled ?? settings.drawSignatureEnabled,
|
||||
qrSignatureEnabled: meta.qrSignatureEnabled ?? settings.qrSignatureEnabled,
|
||||
|
||||
// Email settings.
|
||||
emailId: meta.emailId ?? settings.emailId,
|
||||
|
||||
@@ -119,6 +119,7 @@ export const generateDefaultOrganisationSettings = (): Omit<OrganisationGlobalSe
|
||||
typedSignatureEnabled: true,
|
||||
uploadSignatureEnabled: true,
|
||||
drawSignatureEnabled: true,
|
||||
qrSignatureEnabled: true,
|
||||
|
||||
brandingEnabled: false,
|
||||
brandingLogo: '',
|
||||
|
||||
@@ -17,6 +17,7 @@ export enum DocumentSignatureType {
|
||||
DRAW = 'draw',
|
||||
TYPE = 'type',
|
||||
UPLOAD = 'upload',
|
||||
QR = 'qr',
|
||||
}
|
||||
|
||||
export const formatTeamUrl = (teamUrl: string, baseUrl?: string) => {
|
||||
@@ -93,10 +94,16 @@ export const extractTeamSignatureSettings = (
|
||||
typedSignatureEnabled: boolean | null;
|
||||
drawSignatureEnabled: boolean | null;
|
||||
uploadSignatureEnabled: boolean | null;
|
||||
qrSignatureEnabled: boolean | null;
|
||||
} | null,
|
||||
) => {
|
||||
if (!settings) {
|
||||
return [DocumentSignatureType.TYPE, DocumentSignatureType.UPLOAD, DocumentSignatureType.DRAW];
|
||||
return [
|
||||
DocumentSignatureType.TYPE,
|
||||
DocumentSignatureType.UPLOAD,
|
||||
DocumentSignatureType.DRAW,
|
||||
DocumentSignatureType.QR,
|
||||
];
|
||||
}
|
||||
|
||||
const signatureTypes: DocumentSignatureType[] = [];
|
||||
@@ -113,6 +120,10 @@ export const extractTeamSignatureSettings = (
|
||||
signatureTypes.push(DocumentSignatureType.UPLOAD);
|
||||
}
|
||||
|
||||
if (settings.qrSignatureEnabled) {
|
||||
signatureTypes.push(DocumentSignatureType.QR);
|
||||
}
|
||||
|
||||
return signatureTypes;
|
||||
};
|
||||
|
||||
@@ -186,6 +197,7 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
|
||||
typedSignatureEnabled: null,
|
||||
uploadSignatureEnabled: null,
|
||||
drawSignatureEnabled: null,
|
||||
qrSignatureEnabled: null,
|
||||
|
||||
brandingEnabled: null,
|
||||
brandingLogo: null,
|
||||
|
||||
@@ -144,9 +144,18 @@ model Passkey {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
enum AnonymousVerificationTokenType {
|
||||
PASSKEY
|
||||
QR_SIGNATURE
|
||||
}
|
||||
|
||||
model AnonymousVerificationToken {
|
||||
id String @id @unique @default(cuid())
|
||||
token String @unique
|
||||
id String @id @unique @default(cuid())
|
||||
type AnonymousVerificationTokenType
|
||||
token String @unique
|
||||
value String?
|
||||
metadata Json?
|
||||
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
@@ -570,6 +579,7 @@ model DocumentMeta {
|
||||
typedSignatureEnabled Boolean @default(true)
|
||||
uploadSignatureEnabled Boolean @default(true)
|
||||
drawSignatureEnabled Boolean @default(true)
|
||||
qrSignatureEnabled Boolean @default(true)
|
||||
|
||||
language String @default("en")
|
||||
distributionMethod DocumentDistributionMethod @default(EMAIL)
|
||||
@@ -969,6 +979,7 @@ model OrganisationGlobalSettings {
|
||||
typedSignatureEnabled Boolean @default(true)
|
||||
uploadSignatureEnabled Boolean @default(true)
|
||||
drawSignatureEnabled Boolean @default(true)
|
||||
qrSignatureEnabled Boolean @default(true)
|
||||
|
||||
defaultRecipients Json? /// [DefaultRecipient[]] @zod.custom.use(ZDefaultRecipientsSchema)
|
||||
|
||||
@@ -1012,6 +1023,7 @@ model TeamGlobalSettings {
|
||||
typedSignatureEnabled Boolean?
|
||||
uploadSignatureEnabled Boolean?
|
||||
drawSignatureEnabled Boolean?
|
||||
qrSignatureEnabled Boolean?
|
||||
|
||||
defaultRecipients Json? /// [DefaultRecipient[]] @zod.custom.use(ZDefaultRecipientsSchema)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
ZDocumentMetaMessageSchema,
|
||||
ZDocumentMetaQrSignatureEnabledSchema,
|
||||
ZDocumentMetaRedirectUrlSchema,
|
||||
ZDocumentMetaSubjectSchema,
|
||||
ZDocumentMetaTimezoneSchema,
|
||||
@@ -65,6 +66,7 @@ export const ZCreateEmbeddingDocumentRequestSchema = z.object({
|
||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
ZDocumentMetaMessageSchema,
|
||||
ZDocumentMetaQrSignatureEnabledSchema,
|
||||
ZDocumentMetaRedirectUrlSchema,
|
||||
ZDocumentMetaSubjectSchema,
|
||||
ZDocumentMetaTimezoneSchema,
|
||||
@@ -62,6 +63,7 @@ export const ZCreateEmbeddingTemplateRequestSchema = z.object({
|
||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
|
||||
@@ -30,6 +30,7 @@ export const ZGetMultiSignDocumentResponseSchema = ZDocumentLiteSchema.extend({
|
||||
typedSignatureEnabled: true,
|
||||
uploadSignatureEnabled: true,
|
||||
drawSignatureEnabled: true,
|
||||
qrSignatureEnabled: true,
|
||||
allowDictateNextSigner: true,
|
||||
language: true,
|
||||
emailSettings: true,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
ZDocumentMetaMessageSchema,
|
||||
ZDocumentMetaQrSignatureEnabledSchema,
|
||||
ZDocumentMetaRedirectUrlSchema,
|
||||
ZDocumentMetaSubjectSchema,
|
||||
ZDocumentMetaTimezoneSchema,
|
||||
@@ -66,6 +67,7 @@ export const ZUpdateEmbeddingDocumentRequestSchema = z.object({
|
||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
ZDocumentMetaMessageSchema,
|
||||
ZDocumentMetaQrSignatureEnabledSchema,
|
||||
ZDocumentMetaRedirectUrlSchema,
|
||||
ZDocumentMetaSubjectSchema,
|
||||
ZDocumentMetaTimezoneSchema,
|
||||
@@ -66,6 +67,7 @@ export const ZUpdateEmbeddingTemplateRequestSchema = z.object({
|
||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
|
||||
emailSettings: ZDocumentEmailSettingsSchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
ZDocumentMetaMessageSchema,
|
||||
ZDocumentMetaQrSignatureEnabledSchema,
|
||||
ZDocumentMetaRedirectUrlSchema,
|
||||
ZDocumentMetaSubjectSchema,
|
||||
ZDocumentMetaTimezoneSchema,
|
||||
@@ -94,6 +95,7 @@ export const ZUseEnvelopePayloadSchema = z.object({
|
||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
|
||||
allowDictateNextSigner: z.boolean().optional(),
|
||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
|
||||
})
|
||||
|
||||
@@ -37,6 +37,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
qrSignatureEnabled,
|
||||
defaultRecipients,
|
||||
delegateDocumentOwnership,
|
||||
envelopeExpirationPeriod,
|
||||
@@ -104,6 +105,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
||||
uploadSignatureEnabled ?? organisation.organisationGlobalSettings.uploadSignatureEnabled;
|
||||
const derivedDrawSignatureEnabled =
|
||||
drawSignatureEnabled ?? organisation.organisationGlobalSettings.drawSignatureEnabled;
|
||||
const derivedQrSignatureEnabled = qrSignatureEnabled ?? organisation.organisationGlobalSettings.qrSignatureEnabled;
|
||||
|
||||
const derivedDelegateDocumentOwnership =
|
||||
delegateDocumentOwnership ?? organisation.organisationGlobalSettings.delegateDocumentOwnership;
|
||||
@@ -111,7 +113,8 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
||||
if (
|
||||
derivedTypedSignatureEnabled === false &&
|
||||
derivedUploadSignatureEnabled === false &&
|
||||
derivedDrawSignatureEnabled === false
|
||||
derivedDrawSignatureEnabled === false &&
|
||||
derivedQrSignatureEnabled === false
|
||||
) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'At least one signature type must be enabled',
|
||||
@@ -166,6 +169,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
qrSignatureEnabled,
|
||||
defaultRecipients: defaultRecipients === null ? Prisma.DbNull : defaultRecipients,
|
||||
delegateDocumentOwnership: derivedDelegateDocumentOwnership,
|
||||
envelopeExpirationPeriod: envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod,
|
||||
|
||||
@@ -25,6 +25,7 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
|
||||
typedSignatureEnabled: z.boolean().optional(),
|
||||
uploadSignatureEnabled: z.boolean().optional(),
|
||||
drawSignatureEnabled: z.boolean().optional(),
|
||||
qrSignatureEnabled: z.boolean().optional(),
|
||||
defaultRecipients: ZDefaultRecipientsSchema.nullish(),
|
||||
delegateDocumentOwnership: z.boolean().nullish(),
|
||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.optional(),
|
||||
|
||||
@@ -10,6 +10,7 @@ import { folderRouter } from './folder-router/router';
|
||||
import { organisationRouter } from './organisation-router/router';
|
||||
import { profileRouter } from './profile-router/router';
|
||||
import { recipientRouter } from './recipient-router/router';
|
||||
import { signatureRouter } from './signature-router/router';
|
||||
import { teamRouter } from './team-router/router';
|
||||
import { templateRouter } from './template-router/router';
|
||||
import { router } from './trpc';
|
||||
@@ -24,6 +25,7 @@ export const appRouter = router({
|
||||
field: fieldRouter,
|
||||
folder: folderRouter,
|
||||
recipient: recipientRouter,
|
||||
signature: signatureRouter,
|
||||
admin: adminRouter,
|
||||
organisation: organisationRouter,
|
||||
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>;
|
||||
@@ -36,6 +36,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
qrSignatureEnabled,
|
||||
delegateDocumentOwnership,
|
||||
envelopeExpirationPeriod,
|
||||
reminderSettings,
|
||||
@@ -66,7 +67,12 @@ export const updateTeamSettingsRoute = authenticatedProcedure
|
||||
}
|
||||
|
||||
// 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, {
|
||||
message: 'At least one signature type must be enabled',
|
||||
});
|
||||
@@ -169,6 +175,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
qrSignatureEnabled,
|
||||
delegateDocumentOwnership,
|
||||
envelopeExpirationPeriod: envelopeExpirationPeriod === null ? Prisma.DbNull : envelopeExpirationPeriod,
|
||||
reminderSettings: reminderSettings === null ? Prisma.DbNull : reminderSettings,
|
||||
|
||||
@@ -29,6 +29,7 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
|
||||
typedSignatureEnabled: z.boolean().nullish(),
|
||||
uploadSignatureEnabled: z.boolean().nullish(),
|
||||
drawSignatureEnabled: z.boolean().nullish(),
|
||||
qrSignatureEnabled: z.boolean().nullish(),
|
||||
delegateDocumentOwnership: z.boolean().nullish(),
|
||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
|
||||
reminderSettings: ZEnvelopeReminderSettings.nullish(),
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ZDocumentMetaDrawSignatureEnabledSchema,
|
||||
ZDocumentMetaLanguageSchema,
|
||||
ZDocumentMetaMessageSchema,
|
||||
ZDocumentMetaQrSignatureEnabledSchema,
|
||||
ZDocumentMetaRedirectUrlSchema,
|
||||
ZDocumentMetaSubjectSchema,
|
||||
ZDocumentMetaTimezoneSchema,
|
||||
@@ -66,6 +67,7 @@ export const ZTemplateMetaUpsertSchema = z.object({
|
||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
|
||||
signingOrder: z.nativeEnum(DocumentSigningOrder).optional(),
|
||||
allowDictateNextSigner: z.boolean().optional(),
|
||||
});
|
||||
@@ -147,6 +149,7 @@ export const ZCreateDocumentFromTemplateRequestSchema = z.object({
|
||||
typedSignatureEnabled: ZDocumentMetaTypedSignatureEnabledSchema.optional(),
|
||||
uploadSignatureEnabled: ZDocumentMetaUploadSignatureEnabledSchema.optional(),
|
||||
drawSignatureEnabled: ZDocumentMetaDrawSignatureEnabledSchema.optional(),
|
||||
qrSignatureEnabled: ZDocumentMetaQrSignatureEnabledSchema.optional(),
|
||||
allowDictateNextSigner: z.boolean().optional(),
|
||||
envelopeExpirationPeriod: ZEnvelopeExpirationPeriod.nullish(),
|
||||
})
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"uqr": "^0.1.2",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export type SignaturePadDialogProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'o
|
||||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
drawSignatureEnabled?: boolean;
|
||||
qrSignatureEnabled?: boolean;
|
||||
};
|
||||
|
||||
export const SignaturePadDialog = ({
|
||||
@@ -34,6 +35,7 @@ export const SignaturePadDialog = ({
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
qrSignatureEnabled,
|
||||
dialogConfirmText,
|
||||
}: SignaturePadDialogProps) => {
|
||||
const { i18n } = useLingui();
|
||||
@@ -121,6 +123,7 @@ export const SignaturePadDialog = ({
|
||||
typedSignatureEnabled={typedSignatureEnabled}
|
||||
uploadSignatureEnabled={uploadSignatureEnabled}
|
||||
drawSignatureEnabled={drawSignatureEnabled}
|
||||
qrSignatureEnabled={qrSignatureEnabled}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -2,14 +2,16 @@ import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
||||
|
||||
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 { useState } from 'react';
|
||||
import { match } from 'ts-pattern';
|
||||
import { match, P } from 'ts-pattern';
|
||||
|
||||
import { SignatureIcon } from '../../icons/signature';
|
||||
import { cn } from '../../lib/utils';
|
||||
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 { SignaturePadUpload } from './signature-pad-upload';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './signature-tabs';
|
||||
@@ -29,6 +31,7 @@ export type SignaturePadProps = Omit<HTMLAttributes<HTMLCanvasElement>, 'onChang
|
||||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
drawSignatureEnabled?: boolean;
|
||||
qrSignatureEnabled?: boolean;
|
||||
|
||||
onValidityChange?: (isValid: boolean) => void;
|
||||
};
|
||||
@@ -41,11 +44,14 @@ export const SignaturePad = ({
|
||||
typedSignatureEnabled = true,
|
||||
uploadSignatureEnabled = true,
|
||||
drawSignatureEnabled = true,
|
||||
qrSignatureEnabled = true,
|
||||
}: SignaturePadProps) => {
|
||||
const [imageSignature, setImageSignature] = useState(isBase64Image(value) ? value : '');
|
||||
const [drawSignature, setDrawSignature] = useState(isBase64Image(value) ? value : '');
|
||||
const [typedSignature, setTypedSignature] = useState(isBase64Image(value) ? '' : value);
|
||||
|
||||
const [qrSession, setQrSession] = useState<QrSignatureSession | null>(null);
|
||||
|
||||
/**
|
||||
* This is cooked.
|
||||
*
|
||||
@@ -53,7 +59,7 @@ export const SignaturePad = ({
|
||||
* the first enabled tab.
|
||||
*/
|
||||
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.
|
||||
if (drawSignatureEnabled && drawSignature) {
|
||||
return 'draw';
|
||||
@@ -80,6 +86,10 @@ export const SignaturePad = ({
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if (qrSignatureEnabled) {
|
||||
return 'qr';
|
||||
}
|
||||
|
||||
throw new Error('No signature enabled');
|
||||
})(),
|
||||
);
|
||||
@@ -111,7 +121,7 @@ export const SignaturePad = ({
|
||||
});
|
||||
};
|
||||
|
||||
const onTabChange = (value: 'draw' | 'text' | 'image') => {
|
||||
const onTabChange = (value: 'draw' | 'text' | 'image' | 'qr') => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
@@ -119,7 +129,7 @@ export const SignaturePad = ({
|
||||
setTab(value);
|
||||
|
||||
match(value)
|
||||
.with('draw', () => {
|
||||
.with(P.union('draw', 'qr'), () => {
|
||||
onDrawSignatureChange(drawSignature);
|
||||
})
|
||||
.with('text', () => {
|
||||
@@ -131,7 +141,7 @@ export const SignaturePad = ({
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
if (!drawSignatureEnabled && !typedSignatureEnabled && !uploadSignatureEnabled) {
|
||||
if (!drawSignatureEnabled && !typedSignatureEnabled && !uploadSignatureEnabled && !qrSignatureEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -142,7 +152,7 @@ export const SignaturePad = ({
|
||||
'pointer-events-none': disabled,
|
||||
})}
|
||||
// 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>
|
||||
{drawSignatureEnabled && (
|
||||
@@ -152,6 +162,13 @@ export const SignaturePad = ({
|
||||
</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 && (
|
||||
<TabsTrigger value="text">
|
||||
<KeyboardIcon className="mr-2 size-4" />
|
||||
@@ -174,6 +191,18 @@ export const SignaturePad = ({
|
||||
<SignaturePadDraw className="h-full w-full" onChange={onDrawSignatureChange} value={drawSignature} />
|
||||
</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}
|
||||
onSessionChange={setQrSession}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="text"
|
||||
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