diff --git a/README.md b/README.md index 2d41327..de5b93d 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ It works with any DocType, standard or custom, that has a Signature field and a - **Draw, type or upload a signature.** The app replaces Frappe's Signature control, in the desk and on the signing page, with a dialog that offers a drawing pad, a typed signature in one of four bundled script fonts (Pacifico, Dancing Script, Great Vibes, Caveat), or an uploaded image. - **Send for eSign from the document.** Forms whose DocType has a published eSign web form get a **Send for eSign** button. Its dialog picks the sending email account, recipients, CC and BCC, an eSign email template, the web form and the print format, and previews the link. If the message does not already contain the link, a **Sign Document** button is added to the email. When **To** holds a single address, the link carries it as its `email` parameter. - **Signed PDFs.** After signing, a background job renders the document in the web form's print format, attaches the PDF to the document as a private file and records its SHA-256 hash. -- **Audit trail in the timeline.** Each signing creates an eSign Communication with the signer's email, IP address and browser, the time, the web form, the print format, the fields signed and the PDF hash. It shows as a card in the document timeline with links to open and download the signed PDF. +- **Audit trail in the timeline.** Each signing creates an eSign Communication with the signer's name and email, IP address and browser, the time, the web form, the print format, the fields signed and the PDF hash. It shows as a card in the document timeline, "Document signed by" the signer, with links to open and download the signed PDF. +- **Guest signers by name.** A web form can name the fields its signer types their name and email into, and the audit trail records what the guest entered there, so a signer without an account is named in the timeline card and the notification email, not only by email. - **Actions after signing.** A web form can set a field to a value (for example, **Status** to "Signed"), submit the document, and send a Notification with the signed PDF attached and the audit trail added to the email. - **Completed documents.** Once every signature field on the form is filled, the signing page shows a **Document Signed** card instead of the form, with the signed PDF and a download button once the PDF exists. - **Signing on a phone.** **Only allow Signing on Mobile** hides the form on large screens and shows a QR code that opens the same page on a phone. @@ -73,12 +74,18 @@ Until the first release creates the `version-16` branch, install from `develop` ## Configuration 1. **The DocType.** Give the DocType you want signed a **Signature** field. To sign documents that are already submitted, tick **Allow on Submit** on the Signature field and on every other field the web form changes. -2. **The web form.** Create a **Web Form** for the DocType and tick **Enable eSign**. Add the Signature field, and any fields the signer should fill in, such as their name. To record the signer's email in the audit trail, add a Data field whose options are `Email`. Then set: +2. **The web form.** Create a **Web Form** for the DocType and tick **Enable eSign**. Add the Signature field, and any fields the signer should fill in, such as their name and email (Data fields; give the email field the options `Email`). Then set: - **Print Format**, the web form's own field, which shows once eSign is enabled: the format of the preview and of the signed PDF. - In the **eSign Settings** section: - **Submit on Response**: submit the document when it is signed (submittable DocTypes only). - **Set Property After Response** and **Value To Be Set**: set a Select, Link, Data or Text field to a value when the document is signed. Only those fields are offered, and saving the web form with any other field is refused. - **Only allow Signing on Mobile**: show a QR code instead of the form on large screens. + - In the **Signer** section, which names the signer in the audit trail: + - **Signer Name Field**: the form field the signer types their name into. Its value is recorded as the signer's name. + - **Signer Email Field**: the form field the signer types their email into. A valid address in it is recorded as the signer's email, and the link's `email` parameter pre-fills it. + + Both offer only the web form's own Data fields, and saving the web form with any other field is refused. Make the fields mandatory on the web form if every signer must give them. When they are not set, the signer is identified as before: by the link's `email` parameter or a Data field whose options are `Email`, and named after a matching User. + - In the **Notification Settings** section, **Send Notification on Response** and **Notification**: send an enabled Notification of this DocType whose event is **Custom** and whose channel is **Email**, with the signed PDF attached. Enabling eSign also turns on **Allow Editing After Submit** for the web form, which the signing page needs. @@ -105,14 +112,14 @@ The signed PDF and the timeline entry are written by background jobs on the `sho - A signing link is `///edit?key=`, with optional `format` (print format) and `email` parameters. The web form's `get_context` checks the key, or without one whether the logged-in user may open the document through the web form or print it, and renders the app's signing page. The page loads the document preview from `get_print_html` into a shadow root, so the print format's styles do not touch the page. - Signing posts the form to the app's own `accept` endpoint rather than Frappe's. It checks the share key or the user's permission, writes the form's fields, applies **Set Property After Response** and **Submit on Response**, and saves the document in one go. - After the save commits, `attach_print_to_document` renders and attaches the PDF. It then queues `record_signature`, which creates the eSign Communication with the audit data as JSON, attaches a copy of the PDF to it (or, with cloud_storage, an association to the same file), and sends the configured Notification. The `additional_timeline_content` hook turns each eSign Communication into the timeline card. -- The signer's email comes from the link's `email` parameter, then from a Data field with options `Email` on the form, then from the logged-in user. Their name is looked up from a matching User. The IP address is read from `CF-Connecting-IP`, `X-Real-IP` or `X-Forwarded-For`, then the connection. +- The signer's email comes from the web form's **Signer Email Field** when one is set (the link's `email` parameter only pre-fills it and is not recorded on its own), otherwise from the link's `email` parameter, then from a Data field with options `Email` on the form; failing those, from the logged-in user. Their name comes from the **Signer Name Field** when one is set and filled, otherwise from the User with the signer's email. Both are recorded in the eSign Communication (as its sender and sender name too), shown on the timeline card and added to the notification email. The IP address is read from `CF-Connecting-IP`, `X-Real-IP` or `X-Forwarded-For`, then the connection. - `download_signed_pdf` serves the latest signed PDF to a guest with the share key, and logs the download in the Access Log. ## Limitations - **Not a digital signature.** The PDF is not signed with a certificate, and it carries no visual audit page. Its SHA-256 hash and the audit data are stored in Frappe, in the eSign Communication and the timeline, and in the notification email when one is sent. - **A Send for eSign email to several recipients has no `email` parameter.** One link goes to everyone in **To**, so it names nobody, and the signer's email is only recorded when the form has an email field or the signer is logged in. Send to one recipient at a time, or use `get_esign_link` with `recipient_email` (for example in a Notification), to pre-fill it. -- **Guest signers have no name in the audit trail.** The signer's name is only looked up from a User with the signer's email, so the timeline card names a guest by email. A name field on the form is saved to the document but not to the audit record. +- **The signer's name and email are what they typed.** The Signer Name and Signer Email fields record what the signer entered on the form, not a verified identity: the app does not confirm the email address or check the name against anything. The share key in the link is what limits who can sign. - **A form with no Signature field never shows as completed,** and a document whose signature fields are already filled opens as completed even if it was not signed through the app. - **Only allow Signing on Mobile** goes by screen width, not by touch support: the form is hidden on large screens and shown on small ones. - **Notifications** are sent only for an enabled Notification of the web form's DocType, with the **Custom** event and the **Email** channel. Others are skipped and an error is logged. diff --git a/docs/README.md b/docs/README.md index 9b11e4c..d4f7f8a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,13 +10,13 @@ This app turns a Frappe web form into a signing page. You send the signer a link - A **Send for eSign** button on the document, with a dialog that sends the link using an eSign email template. - A signature dialog with three ways to sign: draw, type in one of four script fonts, or upload an image. - A signed PDF in the web form's print format, attached to the document with its SHA-256 hash. -- An audit trail in the document timeline: the signer's email, IP address and browser, the time, the fields signed and the PDF hash. +- An audit trail in the document timeline: the signer's name and email, IP address and browser, the time, the fields signed and the PDF hash. Guest signers are named by what they type into the form's signer name and email fields. - Optional actions after signing: set a field, submit the document, and send a notification with the signed PDF attached. ## Setting it up 1. Give the DocType a **Signature** field. -2. Create a **Web Form** for it, tick **Enable eSign**, and add the Signature field and anything else the signer fills in. +2. Create a **Web Form** for it, tick **Enable eSign**, and add the Signature field and anything else the signer fills in, such as their name and email. In the **Signer** section, pick those fields as the **Signer Name Field** and **Signer Email Field** so the audit trail names the signer. 3. Create an **Email Template** with **eSign Request** ticked. 4. Open a document and click **Send for eSign**. diff --git a/docs/screenshots/audit-trail-dark.webp b/docs/screenshots/audit-trail-dark.webp index db9f985..05a36f8 100644 Binary files a/docs/screenshots/audit-trail-dark.webp and b/docs/screenshots/audit-trail-dark.webp differ diff --git a/docs/screenshots/audit-trail-light.webp b/docs/screenshots/audit-trail-light.webp index a667e9e..332c5da 100644 Binary files a/docs/screenshots/audit-trail-light.webp and b/docs/screenshots/audit-trail-light.webp differ diff --git a/docs/screenshots/manifest.json b/docs/screenshots/manifest.json index 67fbd8f..da3b69c 100644 --- a/docs/screenshots/manifest.json +++ b/docs/screenshots/manifest.json @@ -2,22 +2,22 @@ { "name": "audit-trail", "theme": "dark", - "alt": "A signed agreement's timeline: the eSign card with the PDF's SHA-256 hash, the signer's email, IP address, web form, signed fields and browser", + "alt": "A signed agreement's timeline: the eSign card naming the signer, with the PDF's SHA-256 hash, the signer's email, IP address, web form, signed fields and browser", "featured": true, "readme": "feature", "width": 2880, "height": 1800, - "sha256": "bd6a91fb9e16bac8bd6edecda59d01a635af43a9ca798777c459856bb4a6e7de" + "sha256": "98f589f2a6959029e303ac189efc2a188adab504c42521b4708ff032366ad624" }, { "name": "audit-trail", "theme": "light", - "alt": "A signed agreement's timeline: the eSign card with the PDF's SHA-256 hash, the signer's email, IP address, web form, signed fields and browser", + "alt": "A signed agreement's timeline: the eSign card naming the signer, with the PDF's SHA-256 hash, the signer's email, IP address, web form, signed fields and browser", "featured": true, "readme": "feature", "width": 2880, "height": 1800, - "sha256": "dad94cc41f84272dd348684a0fad913c2d3f6331ba6a7a0e9e703084504eae16" + "sha256": "5efc1116a089533a39a51dbb8fa5ce0ba5765c8d8f2d26c42e023dd1d12e5eb8" }, { "name": "send-for-esign", @@ -49,6 +49,26 @@ "height": 1800, "sha256": "b61d5ba5433a8b3db74386a804ec5cb504a0a99aff61262176e6ac10248f8fdc" }, + { + "name": "signer-settings", + "theme": "dark", + "alt": "The eSign web form's settings: Submit on Response, Set Property After Response, and the Signer Name Field and Signer Email Field that name the form fields the audit trail records", + "featured": false, + "readme": "feature", + "width": 2880, + "height": 1800, + "sha256": "73c89ccda81ba499cc663a196e2207d29c82bef378399ea5867be9d269c04445" + }, + { + "name": "signer-settings", + "theme": "light", + "alt": "The eSign web form's settings: Submit on Response, Set Property After Response, and the Signer Name Field and Signer Email Field that name the form fields the audit trail records", + "featured": false, + "readme": "feature", + "width": 2880, + "height": 1800, + "sha256": "c65e2399ef5c33db0982a7501a25966c6666530bb38dadc5949bd53b8706e3b0" + }, { "name": "signing-page", "theme": "light", diff --git a/docs/screenshots/signer-settings-dark.webp b/docs/screenshots/signer-settings-dark.webp new file mode 100644 index 0000000..79cd665 Binary files /dev/null and b/docs/screenshots/signer-settings-dark.webp differ diff --git a/docs/screenshots/signer-settings-light.webp b/docs/screenshots/signer-settings-light.webp new file mode 100644 index 0000000..bbf6055 Binary files /dev/null and b/docs/screenshots/signer-settings-light.webp differ diff --git a/esign_webforms/demo.py b/esign_webforms/demo.py index ece1807..9035f7b 100644 --- a/esign_webforms/demo.py +++ b/esign_webforms/demo.py @@ -81,7 +81,8 @@ starting {{ frappe.utils.formatdate(start_date, "MMMM d, yyyy") }}, is ready to sign.

Use the button below to read it and sign it on any device.

""" -# The signing request and signer the signed agreement's audit record describes. The values +# The signing request and signer the signed agreement's audit record describes: a guest, +# named by what they typed into the web form's Signer Name and Signer Email fields. The values # are the ones the signing page records (_collect_audit_data); 203.0.113.0/24 is a # documentation range. SIGNER_EMAIL = "dana.whitfield@example.com" @@ -343,6 +344,8 @@ def _ensure_web_form() -> None: "esign_submit_on_response": 1, "esign_update_field": "status", "esign_update_value": "Signed", + "esign_signer_name_field": "signer_name", + "esign_signer_email_field": "signer_email", "introduction_text": "Read the agreement, then sign it below.", "button_label": "Sign & Submit", "success_title": "Agreement signed", @@ -416,7 +419,7 @@ def _sign(name: str, today: datetime.date) -> None: "ip_address": "203.0.113.42", "user_agent": SIGNER_AGENT, "signer_email": SIGNER_EMAIL, - "signer_name": None, + "signer_name": "Dana Whitfield", "web_form": "Service Agreement", "print_format": PRINT_FORMAT, "signed_fields": ["Signature"], diff --git a/esign_webforms/esign/custom/web_form.json b/esign_webforms/esign/custom/web_form.json index 627a9e5..e497c7a 100644 --- a/esign_webforms/esign/custom/web_form.json +++ b/esign_webforms/esign/custom/web_form.json @@ -74,10 +74,52 @@ { "depends_on": "eval:doc.esign_enabled==1", "dt": "Web Form", - "fieldname": "esign_notification_section", + "fieldname": "esign_signer_section", "fieldtype": "Section Break", "insert_after": "esign_update_value", "is_system_generated": 0, + "label": "Signer", + "module": "eSign", + "name": "Web Form-esign_signer_section" + }, + { + "description": "The form field the signer types their name into. Its value is recorded as the signer's name in the audit trail.", + "dt": "Web Form", + "fieldname": "esign_signer_name_field", + "fieldtype": "Select", + "insert_after": "esign_signer_section", + "is_system_generated": 0, + "label": "Signer Name Field", + "module": "eSign", + "name": "Web Form-esign_signer_name_field" + }, + { + "dt": "Web Form", + "fieldname": "esign_signer_column_break", + "fieldtype": "Column Break", + "insert_after": "esign_signer_name_field", + "is_system_generated": 0, + "module": "eSign", + "name": "Web Form-esign_signer_column_break" + }, + { + "description": "The form field the signer types their email into. A valid address in it is recorded as the signer's email in the audit trail.", + "dt": "Web Form", + "fieldname": "esign_signer_email_field", + "fieldtype": "Select", + "insert_after": "esign_signer_column_break", + "is_system_generated": 0, + "label": "Signer Email Field", + "module": "eSign", + "name": "Web Form-esign_signer_email_field" + }, + { + "depends_on": "eval:doc.esign_enabled==1", + "dt": "Web Form", + "fieldname": "esign_notification_section", + "fieldtype": "Section Break", + "insert_after": "esign_signer_email_field", + "is_system_generated": 0, "label": "Notification Settings", "module": "eSign", "name": "Web Form-esign_notification_section" @@ -122,4 +164,4 @@ } ], "sync_on_migrate": 1 -} \ No newline at end of file +} diff --git a/esign_webforms/esign/custom/web_form.py b/esign_webforms/esign/custom/web_form.py index d5db6e8..3134c40 100644 --- a/esign_webforms/esign/custom/web_form.py +++ b/esign_webforms/esign/custom/web_form.py @@ -29,6 +29,14 @@ # offers only these. UPDATE_FIELD_TYPES = ("Select", "Link", "Data", "Text") +# The field types the Signer Name Field and Signer Email Field (esign_signer_name_field, +# esign_signer_email_field) can name; web_form.js offers only the form's fields of these types. +SIGNER_FIELD_TYPES = ("Data",) + +# The longest signer name recorded, as frappe's User full_name and Communication +# sender_full_name hold it. +SIGNER_NAME_LENGTH = 140 + class PrintHtmlResponse(TypedDict): """Response from get_print_html endpoint.""" @@ -138,6 +146,18 @@ def _find_email_fieldname(wf: EsignWebForm) -> str | None: return None +def _signer_fieldname(wf: EsignWebForm, setting: str) -> str | None: + """The fieldname a Signer Name or Signer Email Field setting names, if it is still one of + the web form's Data fields (the form may have changed since the setting was saved).""" + fieldname = wf.get(setting) + if not fieldname: + return None + for field in wf.web_form_fields: + if _fieldname(field) == fieldname and field.fieldtype in SIGNER_FIELD_TYPES: + return fieldname + return None + + class EsignWebForm(BaseWebForm): """eSign behaviour for Web Form, registered through `extend_doctype_class`. @@ -153,6 +173,9 @@ class EsignWebForm(BaseWebForm): # A Select whose options are the target doctype's fields, set at runtime (web_form.js). esign_update_field: DF.Data | None esign_update_value: DF.Data | None + # Selects whose options are the web form's own Data fields, set at runtime (web_form.js). + esign_signer_name_field: DF.Data | None + esign_signer_email_field: DF.Data | None esign_submit_on_response: DF.Check | None esign_send_notification: DF.Check | None esign_notification: DF.Link | None @@ -273,10 +296,13 @@ def get_context(self, context): form_fields.add("doctype") context.reference_doc = {k: v for k, v in doc.as_dict().items() if k in form_fields} - # Pre-fill email field from URL param (e.g. when link was sent via email) + # Pre-fill email field from URL param (e.g. when link was sent via email). This only + # fills the form: the signer can change it, and what is saved is what they submit. email_param = frappe.form_dict.get("email", "") if email_param: - email_fieldname = _find_email_fieldname(self) + email_fieldname = _signer_fieldname(self, "esign_signer_email_field") or _find_email_fieldname( + self + ) if email_fieldname and email_fieldname in form_fields: context.reference_doc[email_fieldname] = email_param @@ -340,6 +366,7 @@ def validate(self): if self.esign_enabled: self.allow_edit = True self.validate_update_field() + self.validate_signer_fields() def validate_update_field(self): """Refuse a Set Property After Response field that accept would not set.""" @@ -355,6 +382,36 @@ def validate_update_field(self): ) ) + def validate_signer_fields(self): + """Refuse a Signer Name or Signer Email Field that is not a Data field of this form. + + The audit trail only records what the signer typed into the form, so the field has to + be one of the web form's own fields. + """ + form_fieldtypes = { + fieldname: field.fieldtype for field in self.web_form_fields if (fieldname := _fieldname(field)) + } + for setting, label in ( + ("esign_signer_name_field", _("Signer Name Field")), + ("esign_signer_email_field", _("Signer Email Field")), + ): + fieldname = self.get(setting) + if not fieldname: + continue + if fieldname not in form_fieldtypes: + frappe.throw( + _("{0} must be one of this web form's fields, not {1}.").format(label, fieldname) + ) + if form_fieldtypes[fieldname] not in SIGNER_FIELD_TYPES: + frappe.throw( + _("{0} can only be a {1} field, not {2} ({3}).").format( + label, + ", ".join(_(fieldtype) for fieldtype in SIGNER_FIELD_TYPES), + fieldname, + _(form_fieldtypes[fieldname]), + ) + ) + def has_web_form_permission(self, doctype, name, ptype="read"): """Override to allow eSign access for Guest users with valid keys""" # For eSign forms, check key-based access first @@ -378,6 +435,17 @@ def has_web_form_permission(self, doctype, name, ptype="read"): return super().has_web_form_permission(doctype, name, ptype) +def _signer_field_value(doc: Document, wf: EsignWebForm, setting: str) -> str | None: + """The value the signer submitted in the field a Signer Name or Signer Email Field setting + names, stripped, or None when the setting is unset or the field is empty.""" + fieldname = _signer_fieldname(wf, setting) + if not fieldname: + return None + value = doc.get(fieldname) + value = " ".join(str(value).split()) if value else "" + return value or None + + def _get_esign_web_form(web_form: EsignWebForm | str, doctype: str) -> EsignWebForm | None: """The eSign-enabled web form for doctype, or None if web_form is not one.""" if isinstance(web_form, str): @@ -1094,13 +1162,27 @@ def _get_client_ip() -> str | None: return getattr(frappe.request, "remote_addr", None) +def _is_single_email(value: str | None) -> bool: + """Whether value is one valid email address. validate_email_address accepts a list of + them, separated by commas, and returns the valid ones joined the same way.""" + if not value or "," in value: + return False + return validate_email_address(value) == value + + def _collect_audit_data(doc: Document, wf: EsignWebForm, print_format: str) -> AuditData: """Collect audit trail data at the moment of signing. Signer email resolution order: - 1. ``email`` URL search param (set when the eSign link was emailed) - 2. A Data field with Options="Email" on the web form (filled by the signer) - 3. The logged-in Frappe user (if not Guest) + 1. With a Signer Email Field set on the web form, the valid address the signer + submitted in it; the link's ``email`` parameter is then not used + 2. Without one, the ``email`` URL search param (set when the eSign link was emailed) + 3. Without one, a Data field with Options="Email" on the web form (filled by the signer) + 4. The logged-in Frappe user (if not Guest) + + Signer name resolution order: + 1. The web form's Signer Name Field (what the signer typed) + 2. The full name of the User with the signer's email """ # Get IP address from various possible headers (proxy-aware) ip_address = _get_client_ip() @@ -1112,27 +1194,38 @@ def _collect_audit_data(doc: Document, wf: EsignWebForm, print_format: str) -> A # --- Resolve signer email --- signer_email = None - signer_name = None - - # 1. From the email URL search param (link was sent via email) - # The parameter is the signer's own input, so only a valid address is recorded. - email_param = extract_param_from_referrer("email") - if email_param and validate_email_address(email_param) == email_param: - signer_email = email_param - - # 2. From a Data(Email) field on the web form - if not signer_email: - email_fieldname = _find_email_fieldname(wf) - if email_fieldname: - field_value = doc.get(email_fieldname) - signer_email = str(field_value) if field_value else None - - # 3. Fall back to the logged-in user + + if _signer_fieldname(wf, "esign_signer_email_field"): + # 1. The web form names the field the signer's email is in: record only what the + # signer submitted there. It is their own input, so only a valid address is recorded. + field_email = _signer_field_value(doc, wf, "esign_signer_email_field") + if _is_single_email(field_email): + signer_email = field_email + else: + # 2. From the email URL search param (link was sent via email) + # The parameter is the signer's own input, so only a valid address is recorded. + email_param = extract_param_from_referrer("email") + if _is_single_email(email_param): + signer_email = email_param + + # 3. From a Data(Email) field on the web form + if not signer_email: + email_fieldname = _find_email_fieldname(wf) + if email_fieldname: + field_value = doc.get(email_fieldname) + signer_email = str(field_value) if field_value else None + + # 4. Fall back to the logged-in user if not signer_email and frappe.session.user and frappe.session.user != "Guest": signer_email = frappe.session.user - # Resolve signer name from user record when we have an email that matches a User - if signer_email: + # The name the signer typed into the web form's Signer Name Field + signer_name = _signer_field_value(doc, wf, "esign_signer_name_field") + if signer_name: + signer_name = signer_name[:SIGNER_NAME_LENGTH] + + # Otherwise the full name of the User the signer's email belongs to + if not signer_name and signer_email: try: user_doc = cast(User, frappe.get_cached_doc("User", signer_email)) signer_name = user_doc.full_name if user_doc else None diff --git a/esign_webforms/public/js/web_form.js b/esign_webforms/public/js/web_form.js index d58478a..37e2624 100644 --- a/esign_webforms/public/js/web_form.js +++ b/esign_webforms/public/js/web_form.js @@ -26,6 +26,65 @@ function update_field_options(frm) { }); } +/** + * The field types the "Signer Name Field" and "Signer Email Field" can name (SIGNER_FIELD_TYPES + * in esign_webforms/esign/custom/web_form.py). + */ +const SIGNER_FIELD_TYPES = ["Data"]; + +/** The Web Form settings that name the signer's fields. */ +const SIGNER_SETTINGS = /** @type {const} */ (["esign_signer_name_field", "esign_signer_email_field"]); + +/** + * The options last set on a form's signer selects, one per line. + * @type {WeakMap, string>} + */ +const signer_field_options = new WeakMap(); + +/** + * Fill the "Signer Name Field" and "Signer Email Field" selects with the web form's own fields + * that they can name: the audit trail records only what the signer typed into the form. + * @param {frappe.ui.form.Form<"Web Form">} frm + */ +function update_signer_field_options(frm) { + /** @type {string[]} */ + const options = [""]; + for (const field of frm.doc.web_form_fields ?? []) { + if (field.fieldname && field.fieldtype && SIGNER_FIELD_TYPES.includes(field.fieldtype)) { + options.push(field.fieldname); + } + } + // Re-render the selects only when the list changed, so refreshing them as one is opened + // (bind_signer_field_options) leaves an unchanged select alone. + const key = options.join("\n"); + if (signer_field_options.get(frm) === key) { + return; + } + signer_field_options.set(frm, key); + for (const setting of SIGNER_SETTINGS) { + frm.set_df_property(setting, "options", options); + } +} + +/** + * Refresh the signer selects' options as one is focused or pressed. "Get Fields" and other + * bulk changes add rows to the Web Form Fields table without any row or field event, so the + * options could otherwise miss fields added since the form was loaded. + * @param {frappe.ui.form.Form<"Web Form">} frm + */ +function bind_signer_field_options(frm) { + for (const setting of SIGNER_SETTINGS) { + const wrapper = frm.fields_dict[setting]?.wrapper; + if (wrapper) { + $(wrapper) + .off(".esign_signer_options") + .on("mousedown.esign_signer_options focusin.esign_signer_options", "select", () => + update_signer_field_options(frm), + ); + } + } +} + /** * Offer only enabled Custom-event notifications for the web form's doctype. * @param {frappe.ui.form.Form<"Web Form">} frm @@ -49,6 +108,13 @@ const web_form_events = { update_field_options(frm); } set_notification_query(frm); + // A refresh re-renders the form's fields, so the options are set again. + signer_field_options.delete(frm); + update_signer_field_options(frm); + bind_signer_field_options(frm); + }, + web_form_fields_remove(frm) { + update_signer_field_options(frm); }, doc_type(frm) { update_field_options(frm); @@ -57,3 +123,15 @@ const web_form_events = { }; frappe.ui.form.on("Web Form", web_form_events); + +/** @type {frappe.ui.form.FormEvents<"Web Form Field">} */ +const web_form_field_events = { + fieldname(frm) { + update_signer_field_options(frm); + }, + fieldtype(frm) { + update_signer_field_options(frm); + }, +}; + +frappe.ui.form.on("Web Form Field", web_form_field_events); diff --git a/esign_webforms/tests/test_demo.py b/esign_webforms/tests/test_demo.py index f3adb8f..119b6b4 100644 --- a/esign_webforms/tests/test_demo.py +++ b/esign_webforms/tests/test_demo.py @@ -27,7 +27,14 @@ def test_setup_creates_the_esign_web_form_and_template(self): web_form = frappe.db.get_value( "Web Form", demo.WEB_FORM, - ["doc_type", "esign_enabled", "print_format", "esign_update_field"], + [ + "doc_type", + "esign_enabled", + "print_format", + "esign_update_field", + "esign_signer_name_field", + "esign_signer_email_field", + ], as_dict=True, ) assert web_form is not None @@ -35,6 +42,8 @@ def test_setup_creates_the_esign_web_form_and_template(self): self.assertEqual(web_form.esign_enabled, 1) self.assertEqual(web_form.print_format, demo.PRINT_FORMAT) self.assertEqual(web_form.esign_update_field, "status") + self.assertEqual(web_form.esign_signer_name_field, "signer_name") + self.assertEqual(web_form.esign_signer_email_field, "signer_email") self.assertEqual(frappe.db.get_value("Email Template", demo.EMAIL_TEMPLATE, "esign_request"), 1) def test_unsigned_agreement_waits_for_a_signature(self): @@ -73,6 +82,7 @@ def test_signed_agreement_has_its_pdf_and_audit_record(self): assert isinstance(content, str) audit = json.loads(content) self.assertEqual(audit["signer_email"], demo.SIGNER_EMAIL) + self.assertEqual(audit["signer_name"], "Dana Whitfield") self.assertEqual(audit["timestamp"], "2026-01-15 10:42:15") self.assertEqual(len(audit["pdf_hash"]), 64) diff --git a/esign_webforms/tests/test_web_form.py b/esign_webforms/tests/test_web_form.py index eeb6c3c..4b9f6a3 100644 --- a/esign_webforms/tests/test_web_form.py +++ b/esign_webforms/tests/test_web_form.py @@ -63,6 +63,17 @@ def fake_attach_print(**kwargs: Any) -> dict: return {"fname": f"{kwargs['file_name']}.pdf", "fcontent": PDF} +def add_signer_fields( + wf: EsignWebForm, name: str | None = "signer_name", email: str | None = "signer_email" +) -> None: + """Add a Full Name field to the test web form and name the form's signer fields.""" + if not any(row.get("fieldname") == "signer_name" for row in wf.web_form_fields): + wf.append("web_form_fields", {"fieldname": "signer_name", "fieldtype": "Data", "label": "Full Name"}) + wf.esign_signer_name_field = name + wf.esign_signer_email_field = email + wf.save(ignore_permissions=True) + + class EsignTestCase(IntegrationTestCase): def setUp(self) -> None: # Idempotent: the doctype and users are created once per site and kept. @@ -110,6 +121,30 @@ def test_update_field_must_be_a_type_accept_sets(self): wf.save(ignore_permissions=True) self.assertEqual(self.reload_web_form().esign_update_field, "status") + def test_signer_fields_must_be_data_fields_of_the_form(self): + wf = self.reload_web_form() + add_signer_fields(wf) + wf = self.reload_web_form() + self.assertEqual(wf.esign_signer_name_field, "signer_name") + self.assertEqual(wf.esign_signer_email_field, "signer_email") + + # A field of the doctype that is not on the form: its value is not the signer's input. + wf.esign_signer_name_field = "title" + with self.assertRaisesRegex(frappe.ValidationError, "must be one of this web form's fields"): + wf.save(ignore_permissions=True) + + # A field on the form that is not a Data field. + wf = self.reload_web_form() + wf.esign_signer_email_field = "signature" + with self.assertRaisesRegex(frappe.ValidationError, "can only be a Data field"): + wf.save(ignore_permissions=True) + + # Unset is allowed. + wf = self.reload_web_form() + wf.esign_signer_name_field = None + wf.esign_signer_email_field = "" + wf.save(ignore_permissions=True) + def test_signature_exists_only_when_every_signature_field_is_signed(self): wf = self.reload_web_form() self.assertFalse(wf._check_signature_exists(self.doc)) @@ -234,6 +269,19 @@ def test_guest_with_key_gets_the_signing_page(self): self.assertIn("frappe.utils.print_format.download_pdf", content) self.assertNotIn("esign-completed", content) + def test_email_parameter_prefills_the_signer_email_field(self): + wf = self.reload_web_form() + wf.append("web_form_fields", {"fieldname": "title", "fieldtype": "Data", "label": "Your Email"}) + add_signer_fields(wf, name=None, email="title") + frappe.set_user("Guest") + with patch( + "frappe.website.doctype.web_form.web_form.WebForm.add_custom_context_and_script" + ) as custom: + self.render(key=self.key, email="prefill@example.com") + context = custom.call_args.args[0] + self.assertEqual(context.reference_doc["title"], "prefill@example.com") + self.assertNotEqual(context.reference_doc.get("signer_email"), "prefill@example.com") + def test_guest_without_key_is_refused(self): frappe.set_user("Guest") content = self.render() @@ -739,6 +787,80 @@ def test_signer_email_resolution(self): self.assertEqual(audit["ip_address"], "127.0.0.1") self.assertIsNone(audit["user_agent"]) + def test_guest_signer_name_and_email_come_from_the_signer_fields(self): + add_signer_fields(self.reload_web_form()) + frappe.set_user("Guest") + result, enqueue = self.accept( + form_data( + self.doc, + signature=SIGNATURE, + signer_name=" Ana Torres ", + signer_email="ana.torres@example.com", + ), + key=self.key, + ) + self.assertEqual(result.signer_name, " Ana Torres ") + audit = enqueue.call_args.kwargs["audit_data"] + self.assertEqual(audit["signer_name"], "Ana Torres") + self.assertEqual(audit["signer_email"], "ana.torres@example.com") + + def test_signer_fields_are_not_overridden_by_the_link(self): + add_signer_fields(self.reload_web_form()) + wf = self.reload_web_form() + self.doc.signer_name = "Ana Torres" + self.doc.signer_email = "ana.torres@example.com" + frappe.set_user("Guest") + # The link's email parameter does not replace what the signer submitted. + with signing_request(referrer="https://example.com/form?email=param%40example.com"): + audit = esign_web_form._collect_audit_data(self.doc, wf, "Standard") + self.assertEqual(audit["signer_email"], "ana.torres@example.com") + self.assertEqual(audit["signer_name"], "Ana Torres") + + # Nor does it fill in for an invalid or empty address in the signer email field. + for value in ("not an email", "a@example.com, b@example.com", None): + with self.subTest(value=value): + self.doc.signer_email = value + with signing_request(referrer="https://example.com/form?email=param%40example.com"): + audit = esign_web_form._collect_audit_data(self.doc, wf, "Standard") + self.assertIsNone(audit["signer_email"]) + self.assertEqual(audit["signer_name"], "Ana Torres") + + def test_signer_fields_fall_back_when_empty_or_unset(self): + add_signer_fields(self.reload_web_form()) + wf = self.reload_web_form() + # An empty name field falls back to the User the signer's email belongs to. + self.doc.signer_name = " " + self.doc.signer_email = SIGNER + with signing_request(): + audit = esign_web_form._collect_audit_data(self.doc, wf, "Standard") + self.assertEqual(audit["signer_email"], SIGNER) + self.assertEqual(audit["signer_name"], "esign-signer") + + # An empty email field falls back to the logged-in user. + self.doc.signer_email = None + frappe.set_user(SIGNER) + with signing_request(): + audit = esign_web_form._collect_audit_data(self.doc, wf, "Standard") + self.assertEqual(audit["signer_email"], SIGNER) + + # A long name is cut to what the Communication's sender name holds. + self.doc.signer_name = "A" * 300 + with signing_request(): + audit = esign_web_form._collect_audit_data(self.doc, wf, "Standard") + self.assertEqual(audit["signer_name"], "A" * esign_web_form.SIGNER_NAME_LENGTH) + + # A setting naming a field the form no longer has is ignored, as if unset. + frappe.set_user("Guest") + wf.web_form_fields = [row for row in wf.web_form_fields if row.get("fieldname") != "signer_name"] + wf.esign_signer_email_field = "title" + self.doc.signer_name = "Ana Torres" + self.doc.title = "ana.torres@example.com" + self.doc.signer_email = "field@example.com" + with signing_request(referrer="https://example.com/form?email=param%40example.com"): + audit = esign_web_form._collect_audit_data(self.doc, wf, "Standard") + self.assertEqual(audit["signer_email"], "param@example.com") + self.assertIsNone(audit["signer_name"]) + def test_client_ip_headers_in_order(self): with signing_request(headers={"CF-Connecting-IP": "198.51.100.1", "X-Real-IP": "198.51.100.2"}): self.assertEqual(esign_web_form._get_client_ip(), "198.51.100.1") @@ -821,6 +943,8 @@ def test_signed_pdf_is_attached_and_recorded(self): self.assertEqual(audit["file_name"], files[0].file_name) self.assertEqual(len(audit["pdf_hash"]), 64) self.assertEqual(comm.sender, "signer@example.com") + self.assertEqual(audit["signer_name"], "Sam Signer") + self.assertEqual(comm.sender_full_name, "Sam Signer") copy = frappe.get_all( "File", @@ -847,6 +971,7 @@ def test_notification_is_sent_with_the_signed_pdf_and_audit_trail(self): self.assertEqual(mail["subject"], "Signed: Service agreement") self.assertIn(f"{self.doc.name} was signed", mail["message"]) self.assertIn("eSign Audit Trail", mail["message"]) + self.assertIn("Sam Signer", mail["message"]) self.assertIn("<b>TestBrowser</b>", mail["message"]) self.assertNotIn("TestBrowser", mail["message"]) self.assertIn("PDF SHA-256", mail["message"]) diff --git a/esign_webforms/tests/utils.py b/esign_webforms/tests/utils.py index 02abf52..5c3709e 100644 --- a/esign_webforms/tests/utils.py +++ b/esign_webforms/tests/utils.py @@ -22,6 +22,7 @@ class Agreement(Document): documents are plain Documents at runtime and this class is never instantiated.""" title: str | None + signer_name: str | None signer_email: str | None signature: str | None status: str | None @@ -64,6 +65,12 @@ def ensure_test_doctype() -> None: "autoname": "hash", "fields": [ {"fieldname": "title", "fieldtype": "Data", "label": "Title"}, + { + "fieldname": "signer_name", + "fieldtype": "Data", + "label": "Signer Name", + "allow_on_submit": 1, + }, { "fieldname": "signer_email", "fieldtype": "Data", diff --git a/marketplace/screenshots.ts b/marketplace/screenshots.ts index 18d8357..d1962b1 100644 --- a/marketplace/screenshots.ts +++ b/marketplace/screenshots.ts @@ -8,6 +8,7 @@ import type { ShotSpec } from "./shots.d.ts"; const SIGNING_PAGE = "/service-agreement/DEMO-AGR-001/edit"; const UNSIGNED = "/app/service-agreement/DEMO-AGR-001"; const SIGNED = "/app/service-agreement/DEMO-AGR-002"; +const WEB_FORMS = "/app/web-form"; // The signing page is ready once the agreement's preview is rendered in the shadow root and // the web form has built its fields. @@ -202,7 +203,7 @@ export default { { name: "audit-trail", route: SIGNED, - alt: "A signed agreement's timeline: the eSign card with the PDF's SHA-256 hash, the signer's email, IP address, web form, signed fields and browser", + alt: "A signed agreement's timeline: the eSign card naming the signer, with the PDF's SHA-256 hash, the signer's email, IP address, web form, signed fields and browser", waitForFn: `${DESK_READY("DEMO-AGR-002")} && !!document.querySelector('.timeline-message-box[data-communication-type="eSign"]')`, actions: [ { @@ -214,5 +215,29 @@ export default { readme: "feature", featured: true, }, + { + name: "signer-settings", + // The Web Form form's code editors start ace's syntax-check workers from blob: URLs, + // which the network wait never sees finish, so the shot opens the form from the Web + // Form list with a stand-in Worker: the editors work without their linting. + route: WEB_FORMS, + alt: "The eSign web form's settings: Submit on Response, Set Property After Response, and the Signer Name Field and Signer Email Field that name the form fields the audit trail records", + waitForFn: "!!(window.cur_list && cur_list.doctype === 'Web Form')", + actions: [ + { + eval: "window.Worker = class { postMessage() {} terminate() {} addEventListener() {} removeEventListener() {} }", + }, + { eval: 'frappe.set_route("Form", "Web Form", "service-agreement")' }, + { waitFor: '[data-fieldname="esign_signer_name_field"] select' }, + { wait: 800 }, + // Opens the Settings tab; the highlight it puts on the field is taken off again. + { eval: 'cur_frm.scroll_to_field("esign_submit_on_response", false)' }, + { wait: 800 }, + { eval: "document.querySelectorAll('.highlight').forEach((el) => el.classList.remove('highlight'))" }, + { eval: "document.activeElement && document.activeElement.blur()" }, + ], + mask: [".frappe-timestamp"], + readme: "feature", + }, ], } satisfies ShotSpec; diff --git a/types/doctypes.d.ts b/types/doctypes.d.ts index d0dd607..5772148 100644 --- a/types/doctypes.d.ts +++ b/types/doctypes.d.ts @@ -147,6 +147,10 @@ declare global { esign_update_field?: string | null; /** Value To Be Set · Data · Custom Field, esign_webforms/esign_webforms/esign/custom/web_form.json */ esign_update_value?: string | null; + /** Signer Name Field · Select · Custom Field, esign_webforms/esign_webforms/esign/custom/web_form.json */ + esign_signer_name_field?: string | null; + /** Signer Email Field · Select · Custom Field, esign_webforms/esign_webforms/esign/custom/web_form.json */ + esign_signer_email_field?: string | null; /** Send Notification on Response · Check · Custom Field, esign_webforms/esign_webforms/esign/custom/web_form.json */ esign_send_notification?: 0 | 1; /** Notification · Link → Notification · Custom Field, esign_webforms/esign_webforms/esign/custom/web_form.json */