diff --git a/.vscode/settings.json b/.vscode/settings.json index f5542fbb5..e6ff5d1a0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,12 +5,7 @@ "editor.codeActionsOnSave": { "source.fixAll": "explicit" }, - "eslint.validate": [ - "typescript", - "typescriptreact", - "javascript", - "javascriptreact" - ], + "eslint.validate": ["typescript", "typescriptreact", "javascript", "javascriptreact"], "javascript.preferences.importModuleSpecifier": "non-relative", "javascript.preferences.useAliasesForRenames": false, "typescript.enablePromptUseWorkspaceTsdk": true, @@ -20,4 +15,7 @@ "[prisma]": { "editor.defaultFormatter": "Prisma.prisma" }, -} \ No newline at end of file + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } +} diff --git a/apps/marketing/content/blog/announcing-vial-21-cfr-part-11.mdx b/apps/marketing/content/blog/announcing-vial-21-cfr-part-11.mdx index 540f7a0a5..b64953c8c 100644 --- a/apps/marketing/content/blog/announcing-vial-21-cfr-part-11.mdx +++ b/apps/marketing/content/blog/announcing-vial-21-cfr-part-11.mdx @@ -11,14 +11,7 @@ tags: - Compliance --- - +
Vial.com uses Documenso for 21 CFR Part 11 compliant signing.
@@ -26,42 +19,40 @@ tags: > TLDR; We launched Vial.com on Documenso and are open for 21 CFR Part 11 business. # What is 21 CFR -You have never heard of 21 CFR Part 11? You are in good company since most people haven't. If you have, you probably work in an industry regulated by the U.S. Food and Drug Administration (FDA). Title 21 of the Code of Federal Regulations (CFR) is dedicated to detailing FDA-regulated business, and sub-part 11 sets out guidelines for using electronic signatures in this highly regulated field. Hence, 21 CFR Part 11 is highly relevant for regulated industries that aim to employ digital signatures. The guidelines set out in 21 CFR Part 11 aim to provide trustworthy, reliable, and equivalent to paper records and handwritten signatures. All Industries that fall under the FDA's regulation, e.g. pharmaceuticals, biotechnology, medical devices, and biologics, must comply with these rules when choosing or creating systems for electronic signatures. + +You have never heard of 21 CFR Part 11? You are in good company since most people haven't. If you have, you probably work in an industry regulated by the U.S. Food and Drug Administration (FDA). Title 21 of the Code of Federal Regulations (CFR) is dedicated to detailing FDA-regulated business, and sub-part 11 sets out guidelines for using electronic signatures in this highly regulated field. Hence, 21 CFR Part 11 is highly relevant for regulated industries that aim to employ digital signatures. The guidelines set out in 21 CFR Part 11 aim to provide trustworthy, reliable, and equivalent to paper records and handwritten signatures. All Industries that fall under the FDA's regulation, e.g. pharmaceuticals, biotechnology, medical devices, and biologics, must comply with these rules when choosing or creating systems for electronic signatures. Compliance with 21 CFR Part 11 is crucial for companies to use electronic records and signatures in their operations legally. It affects how companies manage documentation, conduct audits, and maintain regulatory submissions. Non-compliance can result in legal penalties, rejected submissions, and delays in product approvals, emphasizing the importance of adhering to these guidelines in FDA-regulated activities. # Vial.com + Vial is a technology company on a mission to advance programs to market through computationally designed therapeutics and cost-effective clinical trials. It is imperative that Vial manages this process securely, effectively, and highly compliant. By leveraging it's modern platform, Vial aims to accelerate drug development and, ultimately, time to market for new therapies. You can learn more about them [here](https://vial.com/about-us). [Together](https://documen.so/vial-documenso), Documenso and Vial set out to create the first open-source, 21 CFR Part 11 compliant signing solution. After iterating over the product together, Vial moved their operation from DocuSign, a known legacy signing provider, to a Documenso Enterprise plan. We are very happy to be able to support Vial’s mission by fulfilling our own: bringing open signing and all its innovation to where it's needed. # 21 CFR Part 11 on Documenso Highlights + 21 CFR Part 11 is a highly complex statute, and going into the all design rationales and the following implementation details, deserves its own article later. For now, I want to share a few notable highlights. ## The Full Experience + We implemented 21 CFR Part 11, keeping the main user experience of Documenso intact. Our 21 CFR module is not separate but natively integrated into all Documenso flows, thus not sacrificing usability for compliance. This also means most (if not all) advanced features we offer are usable in a compliant way. This prevents customers from being trapped in an anti-innovation bubble, not allowing access to new features for fear of non-compliance. ## Action Reauth Using Passkeys - + +
Using passkeys (used here via fingerprint scanner) is the smoothest way to re-authenticate.
- One of the requirements affecting day-to-day life the most is the requirement to actually reauthenticate every signature placed on a document. While we can't change that, we can help make the reauthentication as painless as possible. To this end, we opted for passkeys. While Documenso supports passkeys to log in, they are also supported to authenticate signing on a per-signature level as part of the Documenso Enterprise Plan. The user still has to authenticate every signature but can now do so from the comfort of their passkey provider, be that 1Password, their browser, or any other provider. ## Direct Links + We recently launched [Direct Template Links](https://documen.so/direct-links), a new way to let people sign and fill out forms. Links can be completed anytime, creating a new document in the process. Direct Links are also 21 CFR part 11 compliant, using action reauthentication, audit log, and all other compliance requirements. # Documenso Enterprise Plan + With the successful launch of Vial, we are now open for business. 21 CFR Part 11 compliance is part of the Documenso Enterprise plan, which includes all regulations we currently support and upcoming additions. While the pricing depends heavily on your needs and scale, we offer fixed-price plans for better predictability for both sides. In our experience, volume-based pricing is a legacy headache we want to avoid. If you are FDA-regulated and looking for a modern signing solution, we are happy to discuss your requirements in detail. You can write us (hi@documenso.com) or contact [our enterprise team](https://documen.so/21cfr) at any time or stage. @@ -70,4 +61,3 @@ If you have any questions or comments, please reach out on [Twitter / X](https:/ Best from Hamburg\ Timur - diff --git a/apps/web/src/app/(signing)/sign/[token]/number-field.tsx b/apps/web/src/app/(signing)/sign/[token]/number-field.tsx index 79a91d6b5..adf04015f 100644 --- a/apps/web/src/app/(signing)/sign/[token]/number-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/number-field.tsx @@ -216,7 +216,10 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu let fieldDisplayName = 'Number'; if (parsedFieldMeta?.label) { - fieldDisplayName = parsedFieldMeta.label.length > 10 ? parsedFieldMeta.label.substring(0, 10) + '...' : parsedFieldMeta.label; + fieldDisplayName = + parsedFieldMeta.label.length > 10 + ? parsedFieldMeta.label.substring(0, 10) + '...' + : parsedFieldMeta.label; } const userInputHasErrors = Object.values(errors).some((error) => error.length > 0); @@ -246,7 +249,7 @@ export const NumberField = ({ field, recipient, onSignField, onUnsignField }: Nu )} > - {fieldDisplayName} + {fieldDisplayName}

)} diff --git a/apps/web/src/app/(signing)/sign/[token]/radio-field.tsx b/apps/web/src/app/(signing)/sign/[token]/radio-field.tsx index d09205ca0..cdae9c9af 100644 --- a/apps/web/src/app/(signing)/sign/[token]/radio-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/radio-field.tsx @@ -172,15 +172,15 @@ export const RadioField = ({ field, recipient, onSignField, onUnsignField }: Rad {values?.map((item, index) => (
- - + +
))}
diff --git a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx index 6c1ef2bd2..fc973f77e 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx @@ -190,7 +190,7 @@ export const SignatureField = ({ )} {state === 'empty' && ( -

+

Signature

)} diff --git a/apps/web/src/app/(signing)/sign/[token]/text-field.tsx b/apps/web/src/app/(signing)/sign/[token]/text-field.tsx index 6e2a7b405..d9f9edef3 100644 --- a/apps/web/src/app/(signing)/sign/[token]/text-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/text-field.tsx @@ -279,11 +279,13 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text /> - {parsedFieldMeta?.characterLimit !== undefined && parsedFieldMeta?.characterLimit > 0 && !userInputHasErrors && ( -
- {charactersRemaining} characters remaining -
- )} + {parsedFieldMeta?.characterLimit !== undefined && + parsedFieldMeta?.characterLimit > 0 && + !userInputHasErrors && ( +
+ {charactersRemaining} characters remaining +
+ )} {userInputHasErrors && (
diff --git a/packages/ui/components/field/field.tsx b/packages/ui/components/field/field.tsx index d6553947c..ac67171de 100644 --- a/packages/ui/components/field/field.tsx +++ b/packages/ui/components/field/field.tsx @@ -84,7 +84,7 @@ export function FieldContainerPortal({ left: `${coords.x}px`, // height: `${coords.height}px`, // width: `${coords.width}px`, - ...((!isCheckboxOrRadioField) && { + ...(!isCheckboxOrRadioField && { height: `${coords.height}px`, width: `${coords.width}px`, }), diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx index 597948fab..148cadc3c 100644 --- a/packages/ui/primitives/document-flow/add-fields.tsx +++ b/packages/ui/primitives/document-flow/add-fields.tsx @@ -19,6 +19,7 @@ import { User, } from 'lucide-react'; import { useFieldArray, useForm } from 'react-hook-form'; +import { useHotkeys } from 'react-hotkeys-hook'; import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect'; import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element'; @@ -40,6 +41,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from ' import { Popover, PopoverContent, PopoverTrigger } from '../popover'; import { useStep } from '../stepper'; import { Tooltip, TooltipContent, TooltipTrigger } from '../tooltip'; +import { useToast } from '../use-toast'; import type { TAddFieldsFormSchema } from './add-fields.types'; import { DocumentFlowFormContainerActions, @@ -103,6 +105,8 @@ export const AddFieldsFormPartial = ({ isDocumentPdfLoaded, teamId, }: AddFieldsFormProps) => { + const { toast } = useToast(); + const [isMissingSignatureDialogVisible, setIsMissingSignatureDialogVisible] = useState(false); const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement(); @@ -136,7 +140,12 @@ export const AddFieldsFormPartial = ({ }, }); + useHotkeys(['ctrl+c', 'meta+c'], (evt) => onFieldCopy(evt)); + useHotkeys(['ctrl+v', 'meta+v'], (evt) => onFieldPaste(evt)); + useHotkeys(['ctrl+d', 'meta+d'], (evt) => onFieldCopy(evt, { duplicate: true })); + const onFormSubmit = handleSubmit(onSubmit); + const handleSavedFieldSettings = (fieldState: FieldMeta) => { const initialValues = getValues(); @@ -169,6 +178,12 @@ export const AddFieldsFormPartial = ({ const [selectedField, setSelectedField] = useState(null); const [selectedSigner, setSelectedSigner] = useState(null); const [showRecipientsSelector, setShowRecipientsSelector] = useState(false); + const [lastActiveField, setLastActiveField] = useState( + null, + ); + const [fieldClipboard, setFieldClipboard] = useState( + null, + ); const selectedSignerIndex = recipients.findIndex((r) => r.id === selectedSigner?.id); const selectedSignerStyles = useSignerColors( selectedSignerIndex === -1 ? 0 : selectedSignerIndex, @@ -281,7 +296,7 @@ export const AddFieldsFormPartial = ({ pageX -= fieldPageWidth / 2; pageY -= fieldPageHeight / 2; - append({ + const field = { formId: nanoid(12), type: selectedField, pageNumber, @@ -291,7 +306,9 @@ export const AddFieldsFormPartial = ({ pageHeight: fieldPageHeight, signerEmail: selectedSigner.email, fieldMeta: undefined, - }); + }; + + append(field); setIsFieldWithinBounds(false); setSelectedField(null); @@ -352,6 +369,57 @@ export const AddFieldsFormPartial = ({ [getFieldPosition, localFields, update], ); + const onFieldCopy = useCallback( + (event?: KeyboardEvent | null, options?: { duplicate?: boolean }) => { + const { duplicate = false } = options ?? {}; + + if (lastActiveField) { + event?.preventDefault(); + + if (!duplicate) { + setFieldClipboard(lastActiveField); + + toast({ + title: 'Copied field', + description: 'Copied field to clipboard', + }); + + return; + } + + const newField: TAddFieldsFormSchema['fields'][0] = { + ...structuredClone(lastActiveField), + formId: nanoid(12), + signerEmail: selectedSigner?.email ?? lastActiveField.signerEmail, + pageX: lastActiveField.pageX + 3, + pageY: lastActiveField.pageY + 3, + }; + + append(newField); + } + }, + [append, lastActiveField, selectedSigner?.email, toast], + ); + + const onFieldPaste = useCallback( + (event: KeyboardEvent) => { + if (fieldClipboard) { + event.preventDefault(); + + const copiedField = structuredClone(fieldClipboard); + + append({ + ...copiedField, + formId: nanoid(12), + signerEmail: selectedSigner?.email ?? copiedField.signerEmail, + pageX: copiedField.pageX + 3, + pageY: copiedField.pageY + 3, + }); + } + }, + [append, fieldClipboard, selectedSigner?.email], + ); + useEffect(() => { if (selectedField) { window.addEventListener('mousemove', onMouseMove); @@ -464,6 +532,7 @@ export const AddFieldsFormPartial = ({ '-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds, 'dark:text-black/60': isFieldWithinBounds, }, + selectedField === FieldType.SIGNATURE && fontCaveat.className, )} style={{ top: coords.y, @@ -491,9 +560,12 @@ export const AddFieldsFormPartial = ({ minHeight={fieldBounds.current.height} minWidth={fieldBounds.current.width} passive={isFieldWithinBounds && !!selectedField} + onFocus={() => setLastActiveField(field)} + onBlur={() => setLastActiveField(null)} onResize={(options) => onFieldResize(options, index)} onMove={(options) => onFieldMove(options, index)} onRemove={() => remove(index)} + onDuplicate={() => onFieldCopy(null, { duplicate: true })} onAdvancedSettings={() => { setCurrentField(field); handleAdvancedSettings(); diff --git a/packages/ui/primitives/document-flow/advanced-fields/checkbox.tsx b/packages/ui/primitives/document-flow/advanced-fields/checkbox.tsx index 695cd1821..bd7e62c75 100644 --- a/packages/ui/primitives/document-flow/advanced-fields/checkbox.tsx +++ b/packages/ui/primitives/document-flow/advanced-fields/checkbox.tsx @@ -26,12 +26,12 @@ export const CheckboxField = ({ field }: CheckboxFieldProps) => { } return ( -
+
{!parsedFieldMeta?.values ? ( ) : ( parsedFieldMeta.values.map((item: { value: string; checked: boolean }, index: number) => ( -
+
{ } return ( -
+
{!parsedFieldMeta?.values ? ( ) : ( diff --git a/packages/ui/primitives/document-flow/field-item.tsx b/packages/ui/primitives/document-flow/field-item.tsx index 6e322d868..44788cabd 100644 --- a/packages/ui/primitives/document-flow/field-item.tsx +++ b/packages/ui/primitives/document-flow/field-item.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Caveat } from 'next/font/google'; -import { Settings2, Trash } from 'lucide-react'; +import { CopyPlus, Settings2, Trash } from 'lucide-react'; import { createPortal } from 'react-dom'; import { Rnd } from 'react-rnd'; import { match } from 'ts-pattern'; @@ -38,7 +38,10 @@ export type FieldItemProps = { onResize?: (_node: HTMLElement) => void; onMove?: (_node: HTMLElement) => void; onRemove?: () => void; + onDuplicate?: () => void; onAdvancedSettings?: () => void; + onFocus?: () => void; + onBlur?: () => void; recipientIndex?: number; hideRecipients?: boolean; }; @@ -52,6 +55,9 @@ export const FieldItem = ({ onResize, onMove, onRemove, + onDuplicate, + onFocus, + onBlur, onAdvancedSettings, recipientIndex = 0, hideRecipients = false, @@ -115,18 +121,29 @@ export const FieldItem = ({ }; }, [calculateCoords]); - const handleClickOutsideField = (event: MouseEvent) => { - if (settingsActive && $el.current && !event.composedPath().includes($el.current)) { - setSettingsActive(false); - } - }; - useEffect(() => { - document.body.addEventListener('click', handleClickOutsideField); - return () => { - document.body.removeEventListener('click', handleClickOutsideField); + const onClickOutsideOfField = (event: MouseEvent) => { + const isOutsideOfField = $el.current && !event.composedPath().includes($el.current); + + setSettingsActive((active) => { + if (active && isOutsideOfField) { + return false; + } + + return active; + }); + + if (isOutsideOfField) { + onBlur?.(); + } }; - }, [settingsActive]); + + document.body.addEventListener('click', onClickOutsideOfField); + + return () => { + document.body.removeEventListener('click', onClickOutsideOfField); + }; + }, [onBlur]); const hasFieldMetaValues = ( fieldType: string, @@ -189,6 +206,7 @@ export const FieldItem = ({ )} onClick={() => { setSettingsActive((prev) => !prev); + onFocus?.(); }} ref={$el} > @@ -224,7 +242,7 @@ export const FieldItem = ({ {!disabled && settingsActive && (
-
+
{advancedField && ( )} + + +