mirror of
https://github.com/documenso/documenso.git
synced 2025-11-16 17:51:49 +10:00
Add ability to enable or disable allowed signature types: - Drawn - Typed - Uploaded **Tabbed style signature dialog**  **Document settings**  **Team preferences**  - Add multiselect to select allowed signatures in document and templates settings tab - Add multiselect to select allowed signatures in teams preferences - Removed "Enable typed signatures" from document/template edit page - Refactored signature pad to use tabs instead of an all in one signature pad Added E2E tests to check settings are applied correctly for documents and templates
318 lines
9.7 KiB
TypeScript
318 lines
9.7 KiB
TypeScript
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
import { msg } from '@lingui/core/macro';
|
|
import { useLingui } from '@lingui/react';
|
|
import { Trans } from '@lingui/react/macro';
|
|
import { Loader } from 'lucide-react';
|
|
import { useRevalidator } from 'react-router';
|
|
|
|
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
|
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
|
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
|
import { trpc } from '@documenso/trpc/react';
|
|
import type {
|
|
TRemovedSignedFieldWithTokenMutationSchema,
|
|
TSignFieldWithTokenMutationSchema,
|
|
} from '@documenso/trpc/server/field-router/schema';
|
|
import { Button } from '@documenso/ui/primitives/button';
|
|
import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog';
|
|
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
|
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
|
|
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
|
|
|
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
|
import { DocumentSigningFieldContainer } from './document-signing-field-container';
|
|
import { useRequiredDocumentSigningContext } from './document-signing-provider';
|
|
import { useDocumentSigningRecipientContext } from './document-signing-recipient-provider';
|
|
|
|
type SignatureFieldState = 'empty' | 'signed-image' | 'signed-text';
|
|
|
|
export type DocumentSigningSignatureFieldProps = {
|
|
field: FieldWithSignature;
|
|
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
|
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
|
typedSignatureEnabled?: boolean;
|
|
uploadSignatureEnabled?: boolean;
|
|
drawSignatureEnabled?: boolean;
|
|
};
|
|
|
|
export const DocumentSigningSignatureField = ({
|
|
field,
|
|
onSignField,
|
|
onUnsignField,
|
|
typedSignatureEnabled,
|
|
uploadSignatureEnabled,
|
|
drawSignatureEnabled,
|
|
}: DocumentSigningSignatureFieldProps) => {
|
|
const { _ } = useLingui();
|
|
const { toast } = useToast();
|
|
const { revalidate } = useRevalidator();
|
|
|
|
const { recipient } = useDocumentSigningRecipientContext();
|
|
|
|
const signatureRef = useRef<HTMLParagraphElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [fontSize, setFontSize] = useState(2);
|
|
|
|
const { signature: providedSignature, setSignature: setProvidedSignature } =
|
|
useRequiredDocumentSigningContext();
|
|
|
|
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
|
|
|
|
const { mutateAsync: signFieldWithToken, isPending: isSignFieldWithTokenLoading } =
|
|
trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
|
|
|
const {
|
|
mutateAsync: removeSignedFieldWithToken,
|
|
isPending: isRemoveSignedFieldWithTokenLoading,
|
|
} = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION);
|
|
|
|
const { signature } = field;
|
|
|
|
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading;
|
|
|
|
const [showSignatureModal, setShowSignatureModal] = useState(false);
|
|
const [localSignature, setLocalSignature] = useState<string | null>(null);
|
|
|
|
const state = useMemo<SignatureFieldState>(() => {
|
|
if (!field.inserted) {
|
|
return 'empty';
|
|
}
|
|
|
|
if (signature?.signatureImageAsBase64) {
|
|
return 'signed-image';
|
|
}
|
|
|
|
return 'signed-text';
|
|
}, [field.inserted, signature?.signatureImageAsBase64]);
|
|
|
|
const onPreSign = () => {
|
|
if (!providedSignature) {
|
|
setShowSignatureModal(true);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
/**
|
|
* When the user clicks the sign button in the dialog where they enter their signature.
|
|
*/
|
|
const onDialogSignClick = () => {
|
|
setShowSignatureModal(false);
|
|
setProvidedSignature(localSignature);
|
|
|
|
if (!localSignature) {
|
|
return;
|
|
}
|
|
|
|
void executeActionAuthProcedure({
|
|
onReauthFormSubmit: async (authOptions) => await onSign(authOptions, localSignature),
|
|
actionTarget: field.type,
|
|
});
|
|
};
|
|
|
|
const onSign = async (authOptions?: TRecipientActionAuth, signature?: string) => {
|
|
try {
|
|
const value = signature || providedSignature;
|
|
|
|
if (!value) {
|
|
setShowSignatureModal(true);
|
|
return;
|
|
}
|
|
|
|
const isTypedSignature = !value.startsWith('data:image');
|
|
|
|
if (isTypedSignature && typedSignatureEnabled === false) {
|
|
toast({
|
|
title: _(msg`Error`),
|
|
description: _(msg`Typed signatures are not allowed. Please draw your signature.`),
|
|
variant: 'destructive',
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
const payload: TSignFieldWithTokenMutationSchema = {
|
|
token: recipient.token,
|
|
fieldId: field.id,
|
|
value,
|
|
isBase64: !isTypedSignature,
|
|
authOptions,
|
|
};
|
|
|
|
if (onSignField) {
|
|
await onSignField(payload);
|
|
} else {
|
|
await signFieldWithToken(payload);
|
|
}
|
|
|
|
await revalidate();
|
|
} catch (err) {
|
|
const error = AppError.parseError(err);
|
|
|
|
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
|
throw error;
|
|
}
|
|
|
|
console.error(err);
|
|
|
|
toast({
|
|
title: _(msg`Error`),
|
|
description: _(msg`An error occurred while signing the document.`),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
const onRemove = async () => {
|
|
try {
|
|
const payload: TRemovedSignedFieldWithTokenMutationSchema = {
|
|
token: recipient.token,
|
|
fieldId: field.id,
|
|
};
|
|
|
|
if (onUnsignField) {
|
|
await onUnsignField(payload);
|
|
return;
|
|
} else {
|
|
await removeSignedFieldWithToken(payload);
|
|
}
|
|
|
|
await revalidate();
|
|
} catch (err) {
|
|
console.error(err);
|
|
|
|
toast({
|
|
title: _(msg`Error`),
|
|
description: _(msg`An error occurred while removing the signature.`),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
useLayoutEffect(() => {
|
|
if (!signatureRef.current || !containerRef.current || !signature?.typedSignature) {
|
|
return;
|
|
}
|
|
|
|
const adjustTextSize = () => {
|
|
const container = containerRef.current;
|
|
const text = signatureRef.current;
|
|
|
|
if (!container || !text) {
|
|
return;
|
|
}
|
|
|
|
let size = 2;
|
|
text.style.fontSize = `${size}rem`;
|
|
|
|
while (
|
|
(text.scrollWidth > container.clientWidth || text.scrollHeight > container.clientHeight) &&
|
|
size > 0.8
|
|
) {
|
|
size -= 0.1;
|
|
text.style.fontSize = `${size}rem`;
|
|
}
|
|
|
|
setFontSize(size);
|
|
};
|
|
|
|
const resizeObserver = new ResizeObserver(adjustTextSize);
|
|
resizeObserver.observe(containerRef.current);
|
|
|
|
adjustTextSize();
|
|
|
|
return () => resizeObserver.disconnect();
|
|
}, [signature?.typedSignature]);
|
|
|
|
return (
|
|
<DocumentSigningFieldContainer
|
|
field={field}
|
|
onPreSign={onPreSign}
|
|
onSign={onSign}
|
|
onRemove={onRemove}
|
|
type="Signature"
|
|
>
|
|
{isLoading && (
|
|
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
|
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
|
</div>
|
|
)}
|
|
|
|
{state === 'empty' && (
|
|
<p className="group-hover:text-primary font-signature text-muted-foreground text-[clamp(0.575rem,25cqw,1.2rem)] text-xl duration-200 group-hover:text-yellow-300">
|
|
<Trans>Signature</Trans>
|
|
</p>
|
|
)}
|
|
|
|
{state === 'signed-image' && signature?.signatureImageAsBase64 && (
|
|
<img
|
|
src={signature.signatureImageAsBase64}
|
|
alt={`Signature for ${recipient.name}`}
|
|
className="h-full w-full object-contain"
|
|
/>
|
|
)}
|
|
|
|
{state === 'signed-text' && (
|
|
<div ref={containerRef} className="flex h-full w-full items-center justify-center p-2">
|
|
<p
|
|
ref={signatureRef}
|
|
className="font-signature text-muted-foreground dark:text-background w-full overflow-hidden break-all text-center leading-tight duration-200"
|
|
style={{ fontSize: `${fontSize}rem` }}
|
|
>
|
|
{signature?.typedSignature}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<Dialog open={showSignatureModal} onOpenChange={setShowSignatureModal}>
|
|
<DialogContent>
|
|
<DialogTitle>
|
|
<Trans>
|
|
Sign as {recipient.name}{' '}
|
|
<div className="text-muted-foreground h-5">({recipient.email})</div>
|
|
</Trans>
|
|
</DialogTitle>
|
|
|
|
<SignaturePad
|
|
className="mt-2"
|
|
value={localSignature ?? ''}
|
|
onChange={({ value }) => setLocalSignature(value)}
|
|
typedSignatureEnabled={typedSignatureEnabled}
|
|
uploadSignatureEnabled={uploadSignatureEnabled}
|
|
drawSignatureEnabled={drawSignatureEnabled}
|
|
/>
|
|
|
|
<DocumentSigningDisclosure />
|
|
|
|
<DialogFooter>
|
|
<div className="flex w-full flex-1 flex-nowrap gap-4">
|
|
<Button
|
|
type="button"
|
|
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
|
|
variant="secondary"
|
|
onClick={() => {
|
|
setShowSignatureModal(false);
|
|
setLocalSignature(null);
|
|
}}
|
|
>
|
|
<Trans>Cancel</Trans>
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
className="flex-1"
|
|
disabled={!localSignature}
|
|
onClick={() => onDialogSignClick()}
|
|
>
|
|
<Trans>Sign</Trans>
|
|
</Button>
|
|
</div>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</DocumentSigningFieldContainer>
|
|
);
|
|
};
|