mirror of
https://github.com/documenso/documenso.git
synced 2026-08-22 14:22:21 +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,
|
||||
uploadSignatureEnabled: false,
|
||||
drawSignatureEnabled: true,
|
||||
qrSignatureEnabled: true,
|
||||
},
|
||||
distributeDocument: true,
|
||||
}),
|
||||
@@ -485,6 +486,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 |
|
||||
@@ -141,6 +142,7 @@ Triggered when a new document is created.
|
||||
"typedSignatureEnabled": true,
|
||||
"uploadSignatureEnabled": true,
|
||||
"drawSignatureEnabled": true,
|
||||
"qrSignatureEnabled": true,
|
||||
"language": "en",
|
||||
"distributionMethod": "EMAIL",
|
||||
"emailSettings": null
|
||||
@@ -235,6 +237,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
|
||||
@@ -435,6 +438,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
|
||||
@@ -618,6 +622,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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { TQrSignatureContext } from '@documenso/lib/types/qr-signature';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
|
||||
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
||||
@@ -13,10 +14,21 @@ export type SignFieldSignatureDialogProps = {
|
||||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
drawSignatureEnabled?: boolean;
|
||||
qrSignatureEnabled?: boolean;
|
||||
qrSignatureContext?: TQrSignatureContext;
|
||||
};
|
||||
|
||||
export const SignFieldSignatureDialog = createCallable<SignFieldSignatureDialogProps, string | null>(
|
||||
({ call, fullName, typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled, initialSignature }) => {
|
||||
({
|
||||
call,
|
||||
fullName,
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
qrSignatureEnabled,
|
||||
qrSignatureContext,
|
||||
initialSignature,
|
||||
}) => {
|
||||
const [localSignature, setLocalSignature] = useState(initialSignature);
|
||||
|
||||
return (
|
||||
@@ -36,6 +48,8 @@ export const SignFieldSignatureDialog = createCallable<SignFieldSignatureDialogP
|
||||
typedSignatureEnabled={typedSignatureEnabled}
|
||||
uploadSignatureEnabled={uploadSignatureEnabled}
|
||||
drawSignatureEnabled={drawSignatureEnabled}
|
||||
qrSignatureEnabled={qrSignatureEnabled}
|
||||
qrSignatureContext={qrSignatureContext}
|
||||
/>
|
||||
</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,8 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
||||
qrSignatureEnabled={metadata?.qrSignatureEnabled}
|
||||
qrSignatureContext={{ type: 'DOCUMENT_SIGNATURE', recipientToken: recipient.token }}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -55,6 +55,7 @@ type SettingsSubset = Pick<
|
||||
| 'typedSignatureEnabled'
|
||||
| 'uploadSignatureEnabled'
|
||||
| 'drawSignatureEnabled'
|
||||
| 'qrSignatureEnabled'
|
||||
| 'defaultRecipients'
|
||||
| 'delegateDocumentOwnership'
|
||||
| 'aiFeaturesEnabled'
|
||||
|
||||
@@ -111,6 +111,7 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
|
||||
<FormControl>
|
||||
<SignaturePadDialog
|
||||
disabled={isSubmitting}
|
||||
qrSignatureContext={{ type: 'PROFILE_SIGNATURE' }}
|
||||
fullName={user.name ?? ''}
|
||||
value={value}
|
||||
onChange={(v) => onChange(v ?? '')}
|
||||
|
||||
@@ -314,6 +314,7 @@ export const SignUpForm = ({
|
||||
<FormControl>
|
||||
<SignaturePadDialog
|
||||
disabled={isSubmitting}
|
||||
qrSignatureContext={{ type: 'PROFILE_SIGNATURE' }}
|
||||
value={value}
|
||||
onChange={(v) => onChange(v ?? '')}
|
||||
/>
|
||||
|
||||
@@ -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,8 @@ export const DocumentSigningForm = ({
|
||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
||||
qrSignatureEnabled={document.documentMeta?.qrSignatureEnabled}
|
||||
qrSignatureContext={{ type: 'DOCUMENT_SIGNATURE', recipientToken: recipient.token }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -408,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;
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -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,8 @@ export const DocumentSigningSignatureField = ({
|
||||
typedSignatureEnabled={typedSignatureEnabled}
|
||||
uploadSignatureEnabled={uploadSignatureEnabled}
|
||||
drawSignatureEnabled={drawSignatureEnabled}
|
||||
qrSignatureEnabled={qrSignatureEnabled}
|
||||
qrSignatureContext={{ type: 'DOCUMENT_SIGNATURE', recipientToken: recipient.token }}
|
||||
/>
|
||||
|
||||
<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,8 @@ export default function EnvelopeSignerForm() {
|
||||
typedSignatureEnabled={envelope.documentMeta.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={envelope.documentMeta.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={envelope.documentMeta.drawSignatureEnabled}
|
||||
qrSignatureEnabled={envelope.documentMeta.qrSignatureEnabled}
|
||||
qrSignatureContext={{ type: 'DOCUMENT_SIGNATURE', recipientToken: recipient.token }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -384,6 +384,8 @@ export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderD
|
||||
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled,
|
||||
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled,
|
||||
drawSignatureEnabled: envelope.documentMeta.drawSignatureEnabled,
|
||||
qrSignatureEnabled: envelope.documentMeta.qrSignatureEnabled,
|
||||
recipientToken: envelopeData.recipient.token,
|
||||
})
|
||||
.then(async (payload) => {
|
||||
if (!payload) {
|
||||
|
||||
@@ -137,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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -62,6 +62,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,
|
||||
},
|
||||
|
||||
@@ -50,11 +50,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 />}
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
import backgroundPattern from '@documenso/assets/images/background-pattern.png';
|
||||
import { Outlet } from 'react-router';
|
||||
import { Outlet, useLocation } from 'react-router';
|
||||
|
||||
export default function Layout() {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
// Todo: Use the layout params to hide instead of hardcoding the pathname.
|
||||
const hideBackground = pathname.includes('mobile-signature');
|
||||
|
||||
return (
|
||||
<main className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">
|
||||
<div>
|
||||
<div className="absolute -inset-[min(600px,max(400px,60vw))] -z-[1] flex items-center justify-center opacity-70">
|
||||
<img
|
||||
src={backgroundPattern}
|
||||
alt="background pattern"
|
||||
className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
|
||||
style={{
|
||||
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
||||
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!hideBackground && (
|
||||
<div className="absolute -inset-[min(600px,max(400px,60vw))] -z-[1] flex items-center justify-center opacity-70">
|
||||
<img
|
||||
src={backgroundPattern}
|
||||
alt="background pattern"
|
||||
className="dark:brightness-95 dark:contrast-[70%] dark:invert dark:sepia"
|
||||
style={{
|
||||
mask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
||||
WebkitMask: 'radial-gradient(rgba(255, 255, 255, 1) 0%, transparent 80%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative w-full">
|
||||
<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}
|
||||
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,23 @@ type HandleSignatureFieldClickOptions = {
|
||||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
drawSignatureEnabled?: boolean;
|
||||
qrSignatureEnabled?: boolean;
|
||||
recipientToken?: string;
|
||||
};
|
||||
|
||||
export const handleSignatureFieldClick = async (
|
||||
options: HandleSignatureFieldClickOptions,
|
||||
): Promise<Extract<TSignEnvelopeFieldValue, { type: typeof FieldType.SIGNATURE }> | null> => {
|
||||
const { field, fullName, signature, typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled } = options;
|
||||
const {
|
||||
field,
|
||||
fullName,
|
||||
signature,
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
qrSignatureEnabled,
|
||||
recipientToken,
|
||||
} = options;
|
||||
|
||||
if (field.type !== FieldType.SIGNATURE) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
@@ -40,6 +51,8 @@ export const handleSignatureFieldClick = async (
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
qrSignatureEnabled,
|
||||
qrSignatureContext: recipientToken ? { type: 'DOCUMENT_SIGNATURE', recipientToken } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user