From 610d9858af73e3f0a32d542fb4464bdd4b20cc20 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 30 Jan 2026 17:20:36 -0300 Subject: [PATCH 1/2] feat: add esign package to monorepo Move @superdoc-dev/esign from standalone repository to monorepo. - Add packages/esign with source, demo app, and proxy server - Add CI workflow for esign PR validation - Add esign to release.yml for automated npm publishing - Add esign demo deployment to deploy-demos.yml - Add deploy-esign-proxy.yml for GCP Cloud Run deployment - Update ci-superdoc.yml to exclude esign paths (has own CI) - Pin React versions in catalog to fix version mismatch --- .github/workflows/ci-esign.yml | 45 ++ .github/workflows/ci-superdoc.yml | 1 + .github/workflows/deploy-demos.yml | 28 +- .github/workflows/deploy-esign-proxy.yml | 68 ++ .github/workflows/release.yml | 8 + packages/esign/.gitignore | 29 + packages/esign/.releaserc.cjs | 34 + packages/esign/CHANGELOG.md | 74 ++ packages/esign/LICENSE | 661 ++++++++++++++++++ packages/esign/README.md | 143 ++++ packages/esign/demo/README.md | 51 ++ packages/esign/demo/index.html | 17 + packages/esign/demo/package.json | 24 + packages/esign/demo/public/CNAME | 1 + packages/esign/demo/public/favicon.png | Bin 0 -> 31161 bytes packages/esign/demo/server/.dockerignore | 6 + packages/esign/demo/server/.env.example | 3 + packages/esign/demo/server/Dockerfile | 13 + packages/esign/demo/server/package.json | 15 + packages/esign/demo/server/server.js | 217 ++++++ packages/esign/demo/src/App.css | 543 ++++++++++++++ packages/esign/demo/src/App.tsx | 417 +++++++++++ packages/esign/demo/src/CustomSignature.tsx | 259 +++++++ packages/esign/demo/src/index.css | 40 ++ packages/esign/demo/src/main.tsx | 10 + packages/esign/demo/src/vite-env.d.ts | 1 + packages/esign/demo/vite.config.ts | 25 + packages/esign/eslint.config.js | 47 ++ packages/esign/package.json | 82 +++ .../src/__tests__/SuperDocESign.test.tsx | 500 +++++++++++++ .../esign/src/__tests__/signature.test.ts | 11 + packages/esign/src/defaults/CheckboxInput.tsx | 21 + .../esign/src/defaults/DownloadButton.tsx | 43 ++ .../esign/src/defaults/SignatureInput.tsx | 29 + packages/esign/src/defaults/SubmitButton.tsx | 42 ++ packages/esign/src/defaults/index.ts | 4 + packages/esign/src/index.tsx | 477 +++++++++++++ packages/esign/src/styles.css | 77 ++ packages/esign/src/test/setup.ts | 89 +++ packages/esign/src/types.ts | 159 +++++ packages/esign/src/utils/signature.ts | 27 + packages/esign/tsconfig.json | 30 + packages/esign/vite.config.ts | 24 + pnpm-lock.yaml | 148 +++- pnpm-workspace.yaml | 4 +- 45 files changed, 4528 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/ci-esign.yml create mode 100644 .github/workflows/deploy-esign-proxy.yml create mode 100644 packages/esign/.gitignore create mode 100644 packages/esign/.releaserc.cjs create mode 100644 packages/esign/CHANGELOG.md create mode 100644 packages/esign/LICENSE create mode 100644 packages/esign/README.md create mode 100644 packages/esign/demo/README.md create mode 100644 packages/esign/demo/index.html create mode 100644 packages/esign/demo/package.json create mode 100644 packages/esign/demo/public/CNAME create mode 100644 packages/esign/demo/public/favicon.png create mode 100644 packages/esign/demo/server/.dockerignore create mode 100644 packages/esign/demo/server/.env.example create mode 100644 packages/esign/demo/server/Dockerfile create mode 100644 packages/esign/demo/server/package.json create mode 100644 packages/esign/demo/server/server.js create mode 100644 packages/esign/demo/src/App.css create mode 100644 packages/esign/demo/src/App.tsx create mode 100644 packages/esign/demo/src/CustomSignature.tsx create mode 100644 packages/esign/demo/src/index.css create mode 100644 packages/esign/demo/src/main.tsx create mode 100644 packages/esign/demo/src/vite-env.d.ts create mode 100644 packages/esign/demo/vite.config.ts create mode 100644 packages/esign/eslint.config.js create mode 100644 packages/esign/package.json create mode 100644 packages/esign/src/__tests__/SuperDocESign.test.tsx create mode 100644 packages/esign/src/__tests__/signature.test.ts create mode 100644 packages/esign/src/defaults/CheckboxInput.tsx create mode 100644 packages/esign/src/defaults/DownloadButton.tsx create mode 100644 packages/esign/src/defaults/SignatureInput.tsx create mode 100644 packages/esign/src/defaults/SubmitButton.tsx create mode 100644 packages/esign/src/defaults/index.ts create mode 100644 packages/esign/src/index.tsx create mode 100644 packages/esign/src/styles.css create mode 100644 packages/esign/src/test/setup.ts create mode 100644 packages/esign/src/types.ts create mode 100644 packages/esign/src/utils/signature.ts create mode 100644 packages/esign/tsconfig.json create mode 100644 packages/esign/vite.config.ts diff --git a/.github/workflows/ci-esign.yml b/.github/workflows/ci-esign.yml new file mode 100644 index 0000000000..0d8e7af1fb --- /dev/null +++ b/.github/workflows/ci-esign.yml @@ -0,0 +1,45 @@ +name: CI eSign + +permissions: + contents: read + +on: + pull_request: + paths: + - 'packages/esign/**' + workflow_dispatch: + +concurrency: + group: ci-esign-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + + - name: Install dependencies + run: pnpm install + + - name: Build superdoc (dependency) + run: pnpm run build:superdoc + + - name: Lint + run: pnpm --filter @superdoc-dev/esign lint + + - name: Type check + run: pnpm --filter @superdoc-dev/esign type-check + + - name: Build + run: pnpm --filter @superdoc-dev/esign build + + - name: Test + run: pnpm --filter @superdoc-dev/esign test diff --git a/.github/workflows/ci-superdoc.yml b/.github/workflows/ci-superdoc.yml index c1fc397542..d104d1c681 100644 --- a/.github/workflows/ci-superdoc.yml +++ b/.github/workflows/ci-superdoc.yml @@ -9,6 +9,7 @@ on: paths-ignore: - 'apps/docs/**' - 'packages/template-builder/**' + - 'packages/esign/**' - '**/*.md' concurrency: diff --git a/.github/workflows/deploy-demos.yml b/.github/workflows/deploy-demos.yml index 6e5f7a7188..d97195674e 100644 --- a/.github/workflows/deploy-demos.yml +++ b/.github/workflows/deploy-demos.yml @@ -5,7 +5,7 @@ on: branches: [main] paths: - 'packages/template-builder/**' - # Add more demo paths here as needed + - 'packages/esign/**' workflow_dispatch: jobs: @@ -30,7 +30,25 @@ jobs: publish_dir: ./packages/template-builder/demo/dist cname: template-builder.superdoc.dev - # Add more demo deploy jobs here as needed - # deploy-another-demo: - # runs-on: ubuntu-latest - # steps: ... + deploy-esign-demo: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + - run: pnpm install + - name: Build superdoc (dependency) + run: pnpm run build:superdoc + - name: Build esign + run: pnpm --filter @superdoc-dev/esign build + - name: Build demo + run: pnpm --filter superdoc-esign-demo build + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./packages/esign/demo/dist + cname: esign.superdoc.dev diff --git a/.github/workflows/deploy-esign-proxy.yml b/.github/workflows/deploy-esign-proxy.yml new file mode 100644 index 0000000000..8319c09435 --- /dev/null +++ b/.github/workflows/deploy-esign-proxy.yml @@ -0,0 +1,68 @@ +name: Deploy eSign Proxy Server + +on: + workflow_dispatch: + +env: + PROJECT_ID: ${{ vars.GCP_PROJECT_ID }} + REGION: ${{ vars.GCP_REGION }} + SERVICE_NAME: esign-demo-proxy-server + SUPERDOC_SERVICES_BASE_URL: ${{ vars.SUPERDOC_SERVICES_BASE_URL }} + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Auth to Google Cloud + uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ secrets.GCP_SA_KEY }} + + - name: Setup gcloud + uses: google-github-actions/setup-gcloud@v2 + with: + project_id: ${{ env.PROJECT_ID }} + + - name: Build and push container with Cloud Build + run: | + REGION="${REGION:-us-central1}" + SERVICE_NAME="${SERVICE_NAME:-esign-demo-proxy-server}" + IMAGE="gcr.io/${PROJECT_ID}/${SERVICE_NAME}:${GITHUB_SHA}" + + echo "REGION=${REGION}" >> $GITHUB_ENV + echo "SERVICE_NAME=${SERVICE_NAME}" >> $GITHUB_ENV + echo "IMAGE=${IMAGE}" >> $GITHUB_ENV + + gcloud builds submit packages/esign/demo/server --tag "${IMAGE}" + env: + PROJECT_ID: ${{ env.PROJECT_ID }} + REGION: ${{ env.REGION }} + SERVICE_NAME: ${{ env.SERVICE_NAME }} + + - name: Deploy container to Cloud Run + run: | + REGION="${REGION:-us-central1}" + SERVICE_NAME="${SERVICE_NAME:-esign-demo-proxy-server}" + IMAGE="${IMAGE}" + SUPERDOC_SERVICES_BASE_URL="${SUPERDOC_SERVICES_BASE_URL:-https://api.superdoc.dev}" + + gcloud run deploy "${SERVICE_NAME}" \ + --image "${IMAGE}" \ + --region "${REGION}" \ + --memory=1Gi \ + --cpu=1 \ + --allow-unauthenticated \ + --set-env-vars SUPERDOC_SERVICES_BASE_URL="${SUPERDOC_SERVICES_BASE_URL}" \ + --set-secrets="SUPERDOC_SERVICES_API_KEY=esign-demo-sd-services-api-key:latest" + env: + IMAGE: ${{ env.IMAGE }} + REGION: ${{ env.REGION }} + SERVICE_NAME: ${{ env.SERVICE_NAME }} + SUPERDOC_SERVICES_BASE_URL: ${{ env.SUPERDOC_SERVICES_BASE_URL }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f793faf1e4..0c963d5d6d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -82,6 +82,14 @@ jobs: working-directory: packages/template-builder run: pnpx semantic-release + - name: Release esign + env: + GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + working-directory: packages/esign + run: pnpx semantic-release + # Sync stable back to main after stable release # - name: Sync stable to main # if: github.ref == 'refs/heads/stable' diff --git a/packages/esign/.gitignore b/packages/esign/.gitignore new file mode 100644 index 0000000000..fef042bad3 --- /dev/null +++ b/packages/esign/.gitignore @@ -0,0 +1,29 @@ +# Dependencies +node_modules/ + +# Build +dist/ + +# Logs +*.log +npm-debug.log* + +# Editor +.vscode/ +.DS_Store + +# Environment +.env + +# Coverage +coverage/ + +# Examples +examples/**/package-lock.json +examples/**/pnpm-lock.yaml +examples/**/yarn.lock + +# Dev +dev/** +CLAUDE.md +/demo-crc \ No newline at end of file diff --git a/packages/esign/.releaserc.cjs b/packages/esign/.releaserc.cjs new file mode 100644 index 0000000000..a499a9109b --- /dev/null +++ b/packages/esign/.releaserc.cjs @@ -0,0 +1,34 @@ +/* eslint-env node */ +const branch = process.env.GITHUB_REF_NAME || process.env.CI_COMMIT_BRANCH; + +const config = { + branches: [ + { name: 'stable', channel: 'latest' }, + { name: 'main', prerelease: 'next', channel: 'next' }, + ], + tagFormat: 'esign-v${version}', + plugins: [ + '@semantic-release/commit-analyzer', + '@semantic-release/release-notes-generator', + ['@semantic-release/npm', { npmPublish: true }], + ], +}; + +const isPrerelease = config.branches.some( + (b) => typeof b === 'object' && b.name === branch && b.prerelease +); + +if (!isPrerelease) { + config.plugins.push([ + '@semantic-release/git', + { + assets: ['package.json'], + message: + 'chore(esign): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}', + }, + ]); +} + +config.plugins.push('@semantic-release/github'); + +module.exports = config; diff --git a/packages/esign/CHANGELOG.md b/packages/esign/CHANGELOG.md new file mode 100644 index 0000000000..91be6d297a --- /dev/null +++ b/packages/esign/CHANGELOG.md @@ -0,0 +1,74 @@ +# [1.3.0](https://github.com/superdoc-dev/esign/compare/v1.2.0...v1.3.0) (2025-10-08) + + +### Features + +* enhance document download and UI components in app ([#1](https://github.com/superdoc-dev/esign/issues/1)) ([cab8848](https://github.com/superdoc-dev/esign/commit/cab8848b70a86453ed323413086d05dcb614d32b)) +* integrate testing framework and add test cases ([#2](https://github.com/superdoc-dev/esign/issues/2)) ([e92c73e](https://github.com/superdoc-dev/esign/commit/e92c73eab838be19c624c72289446caf38264885)) + +# [1.2.0](https://github.com/superdoc-dev/esign/compare/v1.1.2...v1.2.0) (2025-10-07) + + +### Features + +* add download functionality for document conversion in App component ([69a9403](https://github.com/superdoc-dev/esign/commit/69a94039d52f1f1249f66f4a8881be3a83ba86c1)) + +## [1.1.2](https://github.com/superdoc-dev/esign/compare/v1.1.1...v1.1.2) (2025-10-07) + + +### Bug Fixes + +* modify README to reflect changes in document source and field values ([7b904ee](https://github.com/superdoc-dev/esign/commit/7b904ee4cd642bbf847980fede5e0ac8a9260adf)) +* remove unused superdoc CSS import from index.tsx ([d17f61f](https://github.com/superdoc-dev/esign/commit/d17f61f9437b85e90b68b218c796d7b97466bb6f)) +* simplify rollup external dependencies and add superdoc styles ([0c0be96](https://github.com/superdoc-dev/esign/commit/0c0be96693afc2d10df9ffff8fcbc5c9838c9ad5)) + +## [1.1.1](https://github.com/superdoc-dev/esign/compare/v1.1.0...v1.1.1) (2025-10-07) + + +### Bug Fixes + +* update package.json to include dependencies and fix README installation command ([722321b](https://github.com/superdoc-dev/esign/commit/722321bcbee9ebdaf2c481328c59cc16e105a96d)) + +# [1.1.0](https://github.com/superdoc-dev/esign/compare/v1.0.1...v1.1.0) (2025-10-07) + + +### Bug Fixes + +* update @types/react and @types/react-dom to latest versions ([68cf44c](https://github.com/superdoc-dev/esign/commit/68cf44cdfdb5cc21365304b688c6abb01a830837)) +* update license to AGPL-3.0 and fix workflow for syncing stable releases to main ([b0a60d0](https://github.com/superdoc-dev/esign/commit/b0a60d028730a974c3b708192aec2ac38c5fa0fc)) + + +### Features + +* add GitHub Actions workflow to sync patches to main branch ([af02010](https://github.com/superdoc-dev/esign/commit/af02010619ba804efdf7ae4a2c4f5a63de96f720)) + +## [1.0.1](https://github.com/superdoc-dev/esign/compare/v1.0.0...v1.0.1) (2025-10-07) + + +### Bug Fixes + +* update superdoc version and refactor consent handling ([afe3e18](https://github.com/superdoc-dev/esign/commit/afe3e18a5b101b2fae8bb4e93752c63a10f332ad)) + +# 1.0.0 (2025-10-07) + + +### Bug Fixes + +* add superdoc class and data attribute to eSign component ([a9ce117](https://github.com/superdoc-dev/esign/commit/a9ce1171d3f4fb457b2d5f53cb0e3fc82c8806f4)) +* trigger release ([c02a107](https://github.com/superdoc-dev/esign/commit/c02a107749b83b7731bcd19634c5d72ca23b7f92)) +* trigger release ([73e3580](https://github.com/superdoc-dev/esign/commit/73e3580cea43d02eb0fd327c7b1930725d26f209)) +* trigger release ([841ec15](https://github.com/superdoc-dev/esign/commit/841ec1502286c0cc454e17309fca55f2b39cb79b)) + + +### Features + +* add additional fields for user information in App component ([038a5dd](https://github.com/superdoc-dev/esign/commit/038a5dd97f3761a5713ec621553d7cf67568edbb)) +* add GitHub Actions workflow for demo deployment and Vite configuration for demo and examples ([1b52830](https://github.com/superdoc-dev/esign/commit/1b52830c00253e0a444c34e73c15ba7917b5cfe2)) +* add interactive demo for SuperDoc eSign with initial setup and styles ([4734e0f](https://github.com/superdoc-dev/esign/commit/4734e0f1daf7ea4d818f3334ec1eeea6803d3bd6)) +* add README and examples for SuperDoc eSign integration ([49c4477](https://github.com/superdoc-dev/esign/commit/49c4477f9c3f78b72ffcb280c544b2dadd69489c)) +* enhance ESLint configuration for React support and update package dependencies ([babacde](https://github.com/superdoc-dev/esign/commit/babacde252f6a55d2b3d0aed8ead8ec060a33a7a)) +* first commit ([d84a550](https://github.com/superdoc-dev/esign/commit/d84a5502c533615b2f619aed7b355876aec0ea00)) +* implement download functionality for document export in SuperDoc eSign ([1124775](https://github.com/superdoc-dev/esign/commit/11247759aa7050ba10e0ae9b869d68d180975ca0)) +* implement signature input handling and update document with signature image ([d31d5cc](https://github.com/superdoc-dev/esign/commit/d31d5cc60b4bd23d46fbd51d7e6b3cc8cb8f5df2)) +* implement signature input handling and update payload structure ([5c6ab23](https://github.com/superdoc-dev/esign/commit/5c6ab23885a052788cf23b61f540cabb2681c8a6)) +* trigger release ([f7f04ef](https://github.com/superdoc-dev/esign/commit/f7f04efffeccb9bf7240fcca9b6f8aaa2c482239)) diff --git a/packages/esign/LICENSE b/packages/esign/LICENSE new file mode 100644 index 0000000000..0ad25db4bd --- /dev/null +++ b/packages/esign/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/esign/README.md b/packages/esign/README.md new file mode 100644 index 0000000000..660fcedfe2 --- /dev/null +++ b/packages/esign/README.md @@ -0,0 +1,143 @@ +# @superdoc-dev/esign + +React component that wraps SuperDoc for document signing workflows with audit trails and compliance tracking. + +## Installation + +```bash +npm install @superdoc-dev/esign +``` + +## Quick Start + +```jsx +import React from 'react'; +import SuperDocESign from '@superdoc-dev/esign'; +import 'superdoc/dist/style.css'; + +function App() { + const handleSubmit = async (data) => { + // Send to your backend + await fetch('/api/sign', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + alert('Document signed!'); + }; + + return ( + + ); +} +``` + +## Backend - Create Signed PDF + +Use the SuperDoc API to create the final signed document: + +```javascript +// Node.js/Express +app.post('/api/sign', async (req, res) => { + const { eventId, auditTrail, documentFields, signerFields } = req.body; + + // 1. Fill document fields + const annotated = await fetch('https://api.superdoc.dev/v1/annotate', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${API_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + document: 'template.docx', + fields: [...documentFields, ...signerFields] + }) + }); + + // 2. Add digital signature + const signed = await fetch('https://api.superdoc.dev/v1/sign', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${API_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + document: await annotated.blob(), + auditTrail: auditTrail + }) + }); + + // 3. Save PDF + await saveToStorage(await signed.blob(), `signed_${eventId}.pdf`); + + res.json({ success: true }); +}); +``` + +See [Python, Node.js, and more examples](https://docs.superdoc.dev/solutions/esign/backend). + +## What You Receive + +```javascript +{ + eventId: "session-123", + timestamp: "2024-01-15T10:30:00Z", + duration: 45000, + documentFields: [ + { id: "user_name", value: "John Doe" } + ], + signerFields: [ + { id: "signature", value: "John Doe" }, + { id: "accept_terms", value: true } + ], + auditTrail: [ + { type: "ready", timestamp: "..." }, + { type: "scroll", timestamp: "..." }, + { type: "field_change", timestamp: "..." }, + { type: "submit", timestamp: "..." } + ], + isFullyCompleted: true +} +``` + +## Documentation + +Full docs at [docs.superdoc.dev/solutions/esign](https://docs.superdoc.dev/solutions/esign) + +## License + +AGPLv3 \ No newline at end of file diff --git a/packages/esign/demo/README.md b/packages/esign/demo/README.md new file mode 100644 index 0000000000..bf1d474c1f --- /dev/null +++ b/packages/esign/demo/README.md @@ -0,0 +1,51 @@ +# Demo + +This demo shows how to integrate `@superdoc-dev/esign` into a React application. The frontend sends signing and download requests to a proxy server, which securely communicates with the SuperDoc Services API. + +## Prerequisites + +You'll need a SuperDoc Services API key. [Get your API key here](https://docs.superdoc.dev/api-reference/authentication/register). + +## Setup + +1. Build the main package (from repo root): + ```bash + pnpm build + ``` + +2. Install dependencies: + ```bash + cd demo + pnpm install + cd server + pnpm install + ``` + +3. Create `.env` file in `demo/server/`: + ``` + SUPERDOC_SERVICES_API_KEY=your_key_here + ``` + +4. Update `demo/vite.config.ts` to proxy to localhost: + ```ts + proxy: { + '/v1': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + }, + ``` + +## Running + +Start the proxy server: +```bash +cd demo/server +pnpm start +``` + +In a separate terminal, start the frontend: +```bash +cd demo +pnpm dev +``` diff --git a/packages/esign/demo/index.html b/packages/esign/demo/index.html new file mode 100644 index 0000000000..85449ec968 --- /dev/null +++ b/packages/esign/demo/index.html @@ -0,0 +1,17 @@ + + + + + + + @superdoc-dev/esign - Interactive Demo + + + + + +
+ + + + \ No newline at end of file diff --git a/packages/esign/demo/package.json b/packages/esign/demo/package.json new file mode 100644 index 0000000000..6121ae1181 --- /dev/null +++ b/packages/esign/demo/package.json @@ -0,0 +1,24 @@ +{ + "name": "superdoc-esign-demo", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build" + }, + "dependencies": { + "@superdoc-dev/esign": "workspace:*", + "react": "catalog:", + "react-dom": "catalog:", + "signature_pad": "^5.1.1", + "superdoc": "workspace:*" + }, + "devDependencies": { + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" + } +} diff --git a/packages/esign/demo/public/CNAME b/packages/esign/demo/public/CNAME new file mode 100644 index 0000000000..356ad27140 --- /dev/null +++ b/packages/esign/demo/public/CNAME @@ -0,0 +1 @@ +esign.superdoc.dev \ No newline at end of file diff --git a/packages/esign/demo/public/favicon.png b/packages/esign/demo/public/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..a88bb4fc66161ef24154eb1197d07a4b45997242 GIT binary patch literal 31161 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�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 literal 0 HcmV?d00001 diff --git a/packages/esign/demo/server/.dockerignore b/packages/esign/demo/server/.dockerignore new file mode 100644 index 0000000000..dbb23e6410 --- /dev/null +++ b/packages/esign/demo/server/.dockerignore @@ -0,0 +1,6 @@ +node_modules +npm-debug.log +.env +.env.* +Dockerfile +.dockerignore diff --git a/packages/esign/demo/server/.env.example b/packages/esign/demo/server/.env.example new file mode 100644 index 0000000000..a65e3b79c4 --- /dev/null +++ b/packages/esign/demo/server/.env.example @@ -0,0 +1,3 @@ +PORT=3003 +SUPERDOC_SERVICES_API_KEY=replace-with-your-superdoc-api-key +SUPERDOC_SERVICES_BASE_URL=https://api.superdoc.dev diff --git a/packages/esign/demo/server/Dockerfile b/packages/esign/demo/server/Dockerfile new file mode 100644 index 0000000000..aa7d575b1a --- /dev/null +++ b/packages/esign/demo/server/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package.json ./ +RUN corepack enable && pnpm install --prod --no-lockfile + +COPY . . + +# Cloud Run/Functions set PORT; default to 8080 +ENV PORT=8080 + +CMD ["pnpm", "start"] diff --git a/packages/esign/demo/server/package.json b/packages/esign/demo/server/package.json new file mode 100644 index 0000000000..962a67341b --- /dev/null +++ b/packages/esign/demo/server/package.json @@ -0,0 +1,15 @@ +{ + "name": "esign-proxy-server", + "private": true, + "type": "module", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node server.js" + }, + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.2" + } +} diff --git a/packages/esign/demo/server/server.js b/packages/esign/demo/server/server.js new file mode 100644 index 0000000000..f639d64cbf --- /dev/null +++ b/packages/esign/demo/server/server.js @@ -0,0 +1,217 @@ +import express from 'express'; +import cors from 'cors'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const app = express(); +const PORT = process.env.PORT || 3001; +const SUPERDOC_SERVICES_API_KEY = process.env.SUPERDOC_SERVICES_API_KEY; +const SUPERDOC_SERVICES_BASE_URL = + process.env.SUPERDOC_SERVICES_BASE_URL || 'https://api.superdoc.dev'; +const CONSENT_FIELD_IDS = new Set(['consent_agreement', 'terms', 'email', '406948812']); +const SIGNATURE_FIELD_ID = '789012'; +const IP_ADDRESS = '127.0.0.1'; // Replace with real client IP once available +const DEMO_USER = { + name: 'Demo User', + email: 'demo@superdoc.dev', + userAgent: 'demo-user-agent', +}; + +app.use( + cors({ + origin: 'https://esign.superdoc.dev', + }), +); +app.use(express.json({ limit: '50mb' })); + +app.get('/health', (req, res) => { + res.json({ status: 'ok' }); +}); + +const normalizeFields = (fieldsPayload = {}, signatureMode = 'annotate') => { + const documentFields = Array.isArray(fieldsPayload.document) ? fieldsPayload.document : []; + const signerFields = Array.isArray(fieldsPayload.signer) ? fieldsPayload.signer : []; + + return [...documentFields, ...signerFields] + .filter((field) => field?.id && !CONSENT_FIELD_IDS.has(field.id)) + .map((field) => { + const isSignatureField = field.id === SIGNATURE_FIELD_ID; + const value = field.value ?? ''; + const signatureType = signatureMode === 'sign' ? 'signature' : 'image'; + const type = isSignatureField ? signatureType : 'text'; + + const normalized = { id: field.id, value, type }; + if (type === 'signature') { + normalized.options = { + bottomLabel: { text: `ip: ${IP_ADDRESS}`, color: '#666' }, + }; + } + return normalized; + }); +}; + +const annotateDocument = async ({ documentUrl, fields }) => { + const response = await fetch(`${SUPERDOC_SERVICES_BASE_URL}/v1/annotate?to=pdf`, { + method: 'POST', + headers: { + Authorization: `Bearer ${SUPERDOC_SERVICES_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + document: { url: documentUrl }, + fields: fields || [], + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(error || 'Failed to annotate document'); + } + + const data = await response.json(); + return { + base64: data?.document?.base64, + contentType: data?.document?.contentType || 'application/pdf', + }; +}; + +const sendPdfBuffer = (res, base64, fileName, contentType = 'application/pdf') => { + const buffer = Buffer.from(base64, 'base64'); + res.setHeader('Content-Type', contentType); + res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); + res.send(buffer); +}; + +app.post('/v1/download', async (req, res) => { + try { + const { document, fields = {}, fileName = 'document.pdf', signatureMode = 'annotate' } = + req.body || {}; + + if (!SUPERDOC_SERVICES_API_KEY) { + return res.status(500).json({ error: 'Missing SUPERDOC_SERVICES_API_KEY on the server' }); + } + + if (!document?.url) { + return res.status(400).json({ error: 'document.url is required' }); + } + + const annotatedFields = normalizeFields(fields, signatureMode); + + const { base64, contentType } = await annotateDocument({ + documentUrl: document.url, + fields: annotatedFields, + }); + + if (!base64) { + return res.status(502).json({ + error: 'Annotate response missing PDF content', + }); + } + + sendPdfBuffer(res, base64, fileName || 'document.pdf', contentType); + } catch (error) { + console.error('Error processing download:', error); + res.status(500).json({ + error: 'Internal server error', + message: error.message, + }); + } +}); + +app.post('/v1/sign', async (req, res) => { + try { + const { + document, + documentFields = [], + signerFields = [], + auditTrail = [], + eventId, + certificate, + metadata, + fileName = 'signed-document.pdf', + signatureMode = 'sign', + } = req.body || {}; + + if (!SUPERDOC_SERVICES_API_KEY) { + return res.status(500).json({ error: 'Missing SUPERDOC_SERVICES_API_KEY on the server' }); + } + + if (!document?.url) { + return res.status(400).json({ error: 'document.url is required' }); + } + + const annotatedFields = normalizeFields( + { + document: documentFields, + signer: signerFields, + }, + signatureMode, + ); + + const { base64: annotatedBase64 } = await annotateDocument({ + documentUrl: document.url, + fields: annotatedFields, + }); + + if (!annotatedBase64) { + return res.status(502).json({ + error: 'Annotate response missing document content', + }); + } + + const signPayload = { + eventId, + document: { base64: annotatedBase64 }, + auditTrail, + signer: { + name: DEMO_USER.name, + email: DEMO_USER.email, + ip: IP_ADDRESS, + userAgent: DEMO_USER.userAgent, + }, + certificate, + metadata, + }; + + const signResponse = await fetch(`${SUPERDOC_SERVICES_BASE_URL}/v1/sign`, { + method: 'POST', + headers: { + Authorization: `Bearer ${SUPERDOC_SERVICES_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(signPayload), + }); + + if (!signResponse.ok) { + const error = await signResponse.text(); + console.error('SuperDoc sign error:', error); + return res.status(signResponse.status).json({ + error: 'Failed to sign document', + details: error, + }); + } + + const signData = await signResponse.json(); + const signedBase64 = signData?.document?.base64; + const contentType = signData?.document?.contentType || 'application/pdf'; + + if (!signedBase64) { + return res.status(502).json({ + error: 'Sign response missing document content', + }); + } + + sendPdfBuffer(res, signedBase64, fileName, contentType); + } catch (error) { + console.error('Error signing document:', error); + res.status(500).json({ + error: 'Internal server error', + message: error.message, + }); + } +}); + +app.listen(PORT, () => { + console.log(`Proxy server running on http://localhost:${PORT}`); +}); diff --git a/packages/esign/demo/src/App.css b/packages/esign/demo/src/App.css new file mode 100644 index 0000000000..6f47d6c9f8 --- /dev/null +++ b/packages/esign/demo/src/App.css @@ -0,0 +1,543 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: system-ui, -apple-system, sans-serif; + background: #f5f5f7; +} + +.demo { + min-height: 100vh; +} + +header { + background: white; + padding: 1.5rem 2rem; + border-bottom: 1px solid #e5e5e7; +} + +header h1 { + font-size: 1.5rem; + margin: 0; +} + +header p { + color: #86868b; + margin: 0.25rem 0 0 0; +} + +.layout { + display: grid; + grid-template-columns: 1fr 400px; + gap: 2rem; + max-width: 1400px; + margin: 2rem auto; + padding: 0 2rem; +} + +/* Application Side */ +.app-side { + background: white; + border-radius: 12px; + padding: 2rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} + +.app-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 2rem; +} + +.app-header h2 { + font-size: 1.25rem; +} + +.badge { + background: #f0f0f2; + padding: 0.25rem 0.75rem; + border-radius: 12px; + font-size: 0.75rem; + color: #86868b; +} + +.document-section label { + display: block; + font-weight: 600; + margin-bottom: 0.5rem; + color: #1d1d1f; +} + +.document-container { + height: 400px; + border: 2px solid #007aff; + border-radius: 8px; + overflow: auto; + background: white; + margin-bottom: 2rem; +} + +.form-section { + margin-bottom: 1.5rem; +} + +.form-section label { + display: block; + font-weight: 600; + margin-bottom: 0.5rem; + color: #1d1d1f; +} + +.signature-input { + width: 100%; + padding: 0.75rem; + font-size: 1.5rem; + font-family: cursive; + border: none; + border-bottom: 2px solid #d2d2d7; + transition: border-color 0.2s; +} + +.signature-input:focus { + outline: none; + border-color: #007aff; +} + +.checkbox-input { + padding: 1rem; + background: #f5f5f7; + border-radius: 8px; +} + +.checkbox-input label { + display: flex; + align-items: center; + font-weight: normal; + cursor: pointer; +} + +.checkbox-input input[type="checkbox"] { + width: 20px; + height: 20px; + margin-right: 0.75rem; +} + +small { + display: block; + color: #86868b; + font-size: 0.875rem; + margin-top: 0.5rem; +} + +.check { + color: #34c759; + margin-left: 0.5rem; +} + +.success-message { + padding: 1.5rem; + background: #34c759; + color: white; + border-radius: 8px; + text-align: center; +} + +.success-message button { + margin-top: 1rem; + padding: 0.75rem 1.5rem; + background: white; + color: #34c759; + border: none; + border-radius: 6px; + font-weight: 600; + cursor: pointer; +} + +/* Control Side */ +.control-side { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.panel { + background: white; + border-radius: 12px; + padding: 1.5rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} + +.panel h3 { + font-size: 1rem; + margin-bottom: 1rem; + color: #1d1d1f; +} + +.panel label { + display: flex; + align-items: center; + padding: 0.5rem 0; + cursor: pointer; +} + +.panel input[type="checkbox"] { + margin-right: 0.75rem; +} + +.status-list>div { + padding: 0.5rem; + margin-bottom: 0.25rem; + border-radius: 6px; + background: #f5f5f7; + color: #86868b; + transition: all 0.2s; +} + +.status-list>div.active { + background: #e8f5e9; + color: #2e7d32; +} + +.event-log { + max-height: 200px; + overflow-y: auto; +} + +.event-item { + padding: 0.5rem; + font-size: 0.875rem; + border-bottom: 1px solid #f0f0f2; + font-family: monospace; +} + +.empty { + color: #86868b; + font-style: italic; + padding: 1rem; + text-align: center; +} + +/* Responsive */ +@media (max-width: 1024px) { + .layout { + grid-template-columns: 1fr; + } + + .control-side { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + } +} + +/* Updated demo/src/App.css - key changes */ + +.success-state { + text-align: center; + padding: 3rem 2rem; +} + +.success-icon { + font-size: 4rem; + margin-bottom: 1rem; +} + +.success-state h3 { + font-size: 1.5rem; + margin-bottom: 0.5rem; + color: #1d1d1f; +} + +.success-state p { + color: #86868b; + margin-bottom: 2rem; +} + +.audit-info { + background: #f5f5f7; + padding: 1.5rem; + border-radius: 8px; + margin-bottom: 2rem; + text-align: left; + max-width: 400px; + margin-left: auto; + margin-right: auto; +} + +.audit-info p { + margin: 0.5rem 0; + font-size: 0.875rem; +} + +.primary-button { + padding: 1rem 2rem; + background: #007aff; + color: white; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; +} + +.hint { + background: #fff3cd; + color: #856404; + padding: 0.5rem; + text-align: center; + font-size: 0.875rem; + margin-top: -2px; + border-radius: 0 0 8px 8px; +} + +.checkbox-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.checkbox-item { + display: flex; + align-items: center; + padding: 1rem; + background: #f5f5f7; + border-radius: 8px; + cursor: pointer; +} + +.checkbox-item input { + width: 20px; + height: 20px; + margin-right: 0.75rem; +} + +.accept-button { + width: 100%; + padding: 1rem; + background: #34c759; + color: white; + border: none; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.accept-button:disabled { + background: #d2d2d7; + cursor: not-allowed; +} + +.config-options label { + display: flex; + align-items: center; + padding: 0.5rem 0; +} + +.config-options code { + background: #f5f5f7; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.875rem; + margin-left: 0.5rem; +} + +.fields-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.field-item { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.field-item label { + font-size: 0.75rem; + color: #86868b; +} + +.field-item input { + padding: 0.5rem; + border: 1px solid #e5e5e7; + border-radius: 6px; + font-size: 0.875rem; + font-family: inherit; +} + +.field-item input:focus { + outline: none; + border-color: #007aff; +} + +.indicator { + display: inline-block; + width: 12px; + margin-right: 0.5rem; + color: #86868b; +} + +.status-list .active .indicator { + color: #34c759; +} + +header { + background: white; + padding: 1.5rem 2rem; + border-bottom: 1px solid #e5e5e7; +} + +.header-content { + max-width: 1400px; + margin: 0 auto; + display: flex; + justify-content: space-between; + align-items: center; +} + +.header-left h1 { + font-size: 1.5rem; + margin: 0; +} + +.header-left p { + color: #86868b; + margin: 0.25rem 0 0 0; +} + +.header-nav { + display: flex; + gap: 2rem; +} + +.header-nav a { + color: #1d1d1f; + text-decoration: none; + font-weight: 500; + font-size: 0.95rem; + transition: color 0.2s; +} + +.header-nav a:hover { + color: #007aff; +} + +/* Mobile responsive */ +@media (max-width: 640px) { + .header-content { + flex-direction: column; + gap: 1rem; + text-align: center; + } + + .header-nav { + gap: 1.5rem; + } +} + +/* =========================================== + eSign Component Customization Example + =========================================== */ + +/* + * The eSign component exposes CSS classes that you can target directly. + * No special CSS variables needed - just write standard CSS! + * + * Available classes: + * .superdoc-esign-container - Root container (also accepts className prop) + * .superdoc-esign-document - Document section wrapper + * .superdoc-esign-document-toolbar - Toolbar with download button + * .superdoc-esign-document-controls - Control buttons container + * .superdoc-esign-document-viewer - Scroll container (SuperDoc mounts inside) + * .superdoc-esign-controls - Bottom section with fields + submit + * .superdoc-esign-fields - Signer fields container + * .superdoc-esign-actions - Action buttons container + * .superdoc-esign-form-actions - Form submit button container + */ + +/* Example: Card-like styling */ +.superdoc-esign-container { + border: 1px solid #e5e7eb; + border-radius: 12px; + overflow: hidden; +} + +.superdoc-esign-document-viewer { + background: #f9fafb; +} + +.superdoc-esign-controls { + margin-top: 0; /* Remove gap between viewer and controls */ + padding: 16px 20px; + background: #f9fafb; + border-top: 1px solid #e5e7eb; +} + +.superdoc-esign-fields { + margin-bottom: 16px; +} + +.superdoc-esign-actions { + gap: 12px; +} + +/* Main layout container */ +.main-layout-container { + display: flex; + gap: 24px; +} + +/* Main content area */ +.main-content-area { + flex: 1; + min-width: 0; +} + +/* Right sidebar */ +.document-fields-sidebar { + width: 280px; + flex-shrink: 0; + padding: 16px; + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 8px; + align-self: flex-start; +} + +.document-fields-sidebar h3 { + margin: 0 0 16px; + font-size: 14px; + font-weight: 600; + color: #374151; +} + +.document-fields-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.document-field { + /* Individual field styles handled inline for now */ +} + +/* Responsive layout - stack vertically on small screens */ +@media (max-width: 768px) { + .main-layout-container { + flex-direction: column; + } + + .document-fields-sidebar { + width: 100%; + order: 2; /* Move sidebar below the main content */ + } + + .main-content-area { + order: 1; + } +} \ No newline at end of file diff --git a/packages/esign/demo/src/App.tsx b/packages/esign/demo/src/App.tsx new file mode 100644 index 0000000000..b78c8e75e0 --- /dev/null +++ b/packages/esign/demo/src/App.tsx @@ -0,0 +1,417 @@ +import React, { useState, useRef } from 'react'; +import SuperDocESign, { textToImageDataUrl } from '@superdoc-dev/esign'; +import type { + SubmitData, + SigningState, + FieldChange, + DownloadData, + SuperDocESignHandle, +} from '@superdoc-dev/esign'; +import CustomSignature from './CustomSignature'; +import 'superdoc/style.css'; +import './App.css'; + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''; + +const documentSource = + 'https://storage.googleapis.com/public_static_hosting/public_demo_docs/service_agreement_updated.docx'; + +const signerFieldsConfig = [ + { + id: '789012', + type: 'signature' as const, + label: 'Your Signature', + validation: { required: true }, + component: CustomSignature, + }, + { + id: 'terms', + type: 'checkbox' as const, + label: 'I accept the terms and conditions', + validation: { required: true }, + }, + { + id: 'email', + type: 'checkbox' as const, + label: 'Send me a copy of the agreement', + validation: { required: false }, + }, +]; + +const signatureFieldIds = new Set( + signerFieldsConfig.filter((field) => field.type === 'signature').map((field) => field.id), +); + +const toSignatureImageValue = (value: SubmitData['signerFields'][number]['value']) => { + if (value === null || value === undefined) return null; + if (typeof value === 'string' && value.startsWith('data:image/')) return value; + return textToImageDataUrl(String(value)); +}; + +const mapSignerFieldsWithType = ( + fields: Array<{ id: string; value: SubmitData['signerFields'][number]['value'] }>, + signatureType: 'signature' | 'image', +) => + fields.map((field) => { + if (!signatureFieldIds.has(field.id)) { + return field; + } + + return { + ...field, + type: signatureType, + value: toSignatureImageValue(field.value), + }; + }); + +// Helper to download a response blob as a file +const downloadBlob = async (response: Response, fileName: string) => { + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + a.click(); + URL.revokeObjectURL(url); +}; + +// Document field definitions with labels +const documentFieldsConfig = [ + { + id: '123456', + label: 'Date', + defaultValue: new Date().toLocaleDateString(), + readOnly: true, + type: 'text' as const, + }, + { + id: '234567', + label: 'Full Name', + defaultValue: 'John Doe', + readOnly: false, + type: 'text' as const, + }, + { + id: '345678', + label: 'Company', + defaultValue: 'SuperDoc', + readOnly: false, + type: 'text' as const, + }, + { id: '456789', label: 'Plan', defaultValue: 'Premium', readOnly: false, type: 'text' as const }, + { id: '567890', label: 'State', defaultValue: 'CA', readOnly: false, type: 'text' as const }, + { + id: '678901', + label: 'Address', + defaultValue: '123 Main St, Anytown, USA', + readOnly: false, + type: 'text' as const, + }, +]; + +export function App() { + const [submitted, setSubmitted] = useState(false); + const [submitData, setSubmitData] = useState(null); + const [events, setEvents] = useState([]); + + // Stable eventId that persists across renders + const [eventId] = useState(() => `demo-${Date.now()}`); + + // Ref to the esign component + const esignRef = useRef(null); + + // State for document field values + const [documentFields, setDocumentFields] = useState>(() => + Object.fromEntries(documentFieldsConfig.map((f) => [f.id, f.defaultValue])), + ); + + const updateDocumentField = (id: string, value: string) => { + setDocumentFields((prev) => ({ ...prev, [id]: value })); + esignRef.current?.updateFieldInDocument({ id, value }); + }; + + const log = (msg: string) => { + const time = new Date().toLocaleTimeString(); + console.log(`[${time}] ${msg}`); + setEvents((prev) => [...prev.slice(-4), `${time} - ${msg}`]); + }; + + const handleSubmit = async (data: SubmitData) => { + log('⏳ Signing document...'); + console.log('Submit data:', data); + + try { + const signerFields = mapSignerFieldsWithType(data.signerFields, 'signature'); + + const response = await fetch(`${API_BASE_URL}/v1/sign`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + document: { url: documentSource }, + documentFields: data.documentFields, + signerFields, + auditTrail: data.auditTrail, + eventId: data.eventId, + certificate: { enable: true }, + metadata: { + company: documentFields['345678'], + plan: documentFields['456789'], + }, + fileName: `signed_agreement_${data.eventId}.pdf`, + signatureMode: 'sign', + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(error || 'Failed to sign document'); + } + + await downloadBlob(response, `signed_agreement_${data.eventId}.pdf`); + + log('✓ Document signed and downloaded!'); + setSubmitted(true); + setSubmitData(data); + } catch (error) { + console.error('Error signing document:', error); + log(`✗ Signing failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + }; + + const handleDownload = async (data: DownloadData) => { + try { + if (typeof data.documentSource !== 'string') { + log('Download requires a document URL.'); + return; + } + + const signerFields = mapSignerFieldsWithType(data.fields.signer, 'image'); + + const response = await fetch(`${API_BASE_URL}/v1/download`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + document: { url: data.documentSource }, + fields: { + ...data.fields, + signer: signerFields, + }, + fileName: data.fileName, + signatureMode: 'annotate', + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(error || 'Failed to annotate document'); + } + + await downloadBlob(response, data.fileName || 'document.pdf'); + log('✓ Downloaded PDF'); + } catch (error) { + console.error('Error processing document:', error); + log('Download failed'); + } + }; + + const handleStateChange = (state: SigningState) => { + if (state.scrolled && !events.some((e) => e.includes('Scrolled'))) { + log('↓ Scrolled to bottom'); + } + if (state.isValid && !events.some((e) => e.includes('Ready'))) { + log('✓ Ready to submit'); + } + console.log('State:', state); + }; + + const handleFieldChange = (field: FieldChange) => { + const displayValue = + typeof field.value === 'string' && field.value.startsWith('data:image/') + ? `${field.value.slice(0, 30)}... (base64 image)` + : field.value; + log(`Field "${field.id}": ${displayValue}`); + console.log('Field change:', field); + }; + + return ( +
+
+

+ + @superdoc-dev/esign + +

+

+ React eSign component from{' '} + + SuperDoc + +

+
+ + {submitted ? ( +
+
+

Agreement Signed!

+

Event ID: {submitData?.eventId}

+ {submitData?.signerFields.find((f) => f.id === 'signature') && ( +
+

Signature:

+
+ {submitData.signerFields.find((f) => f.id === 'signature')?.value} +
+
+ )} + +
+ ) : ( + <> +

Employment Agreement

+

+ Use the document toolbar to download the current agreement at any time. +

+ +
+ {/* Main content */} +
+ ({ + id: f.id, + value: documentFields[f.id], + type: f.type, + })), + signer: signerFieldsConfig, + }} + download={{ label: 'Download PDF' }} + onSubmit={handleSubmit} + onDownload={handleDownload} + onStateChange={handleStateChange} + onFieldChange={handleFieldChange} + documentHeight="500px" + /> + + {/* Event Log */} + {events.length > 0 && ( +
+
+ EVENT LOG +
+ {events.map((evt, i) => ( +
+ {evt} +
+ ))} +
+ )} +
+ + {/* Right Sidebar - Document Fields */} +
+

Document Fields

+
+ {documentFieldsConfig.map((field) => ( +
+ + updateDocumentField(field.id, e.target.value)} + readOnly={field.readOnly} + style={{ + width: '100%', + padding: '8px 10px', + fontSize: '14px', + border: '1px solid #d1d5db', + borderRadius: '6px', + background: field.readOnly ? '#f3f4f6' : 'white', + color: field.readOnly ? '#6b7280' : '#111827', + cursor: field.readOnly ? 'not-allowed' : 'text', + boxSizing: 'border-box', + }} + /> +
+ ))} +
+
+
+ + )} +
+ ); +} diff --git a/packages/esign/demo/src/CustomSignature.tsx b/packages/esign/demo/src/CustomSignature.tsx new file mode 100644 index 0000000000..507b055986 --- /dev/null +++ b/packages/esign/demo/src/CustomSignature.tsx @@ -0,0 +1,259 @@ +import React, { useEffect, useRef, useState } from 'react'; +import SignaturePad from 'signature_pad'; +import type { FieldComponentProps } from '@superdoc-dev/esign'; + +// Trim whitespace around strokes by tightening the SVG viewBox. +const cropSVG = (svgText: string): string => { + const container = document.createElement('div'); + container.setAttribute('style', 'visibility: hidden; position: absolute; left: -9999px;'); + document.body.appendChild(container); + + try { + container.innerHTML = svgText; + const svgElement = container.getElementsByTagName('svg')[0]; + if (!svgElement) return svgText; + + const bbox = svgElement.getBBox(); + if (bbox.width === 0 || bbox.height === 0) return svgText; + + const padding = 5; + const viewBox = [ + bbox.x - padding, + bbox.y - padding, + bbox.width + padding * 2, + bbox.height + padding * 2, + ].join(' '); + svgElement.setAttribute('viewBox', viewBox); + svgElement.setAttribute('width', String(Math.ceil(bbox.width + padding * 2))); + svgElement.setAttribute('height', String(Math.ceil(bbox.height + padding * 2))); + + return svgElement.outerHTML; + } finally { + container.remove(); + } +}; + +// Rasterize a cropped SVG into a PNG data URL. +const svgToPngDataUrl = (svgText: string): Promise => + new Promise((resolve, reject) => { + const svgDataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgText)}`; + const img = new Image(); + img.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext('2d'); + if (!ctx) { + reject(new Error('Canvas context not available')); + return; + } + ctx.drawImage(img, 0, 0); + resolve(canvas.toDataURL('image/png')); + }; + img.onerror = () => reject(new Error('Failed to load SVG for rasterization')); + img.src = svgDataUrl; + }); + +const CustomSignature: React.FC = ({ value, onChange, isDisabled, label }) => { + const [mode, setMode] = useState<'type' | 'draw'>('type'); + const canvasRef = useRef(null); + const signaturePadRef = useRef(null); + const commitTimerRef = useRef | null>(null); + const latestDataUrlRef = useRef(null); + const conversionIdRef = useRef(0); + + const clearCommitTimer = () => { + if (commitTimerRef.current) { + clearTimeout(commitTimerRef.current); + commitTimerRef.current = null; + } + }; + + // Debounced export to avoid re-rendering during active drawing. + const commitSignature = () => { + if (!signaturePadRef.current) return; + if (signaturePadRef.current.isEmpty()) { + latestDataUrlRef.current = null; + onChange(''); + return; + } + + const svgText = signaturePadRef.current.toSVG(); + const croppedSvg = cropSVG(svgText); + const conversionId = ++conversionIdRef.current; + + svgToPngDataUrl(croppedSvg) + .then((dataUrl) => { + if (conversionIdRef.current !== conversionId) return; + latestDataUrlRef.current = dataUrl; + onChange(dataUrl); + }) + .catch((error) => { + console.error('Failed to convert signature to PNG:', error); + }); + }; + + const switchMode = (newMode: 'type' | 'draw') => { + clearCommitTimer(); + latestDataUrlRef.current = null; + conversionIdRef.current += 1; + setMode(newMode); + onChange(''); + if (newMode === 'draw' && signaturePadRef.current) { + signaturePadRef.current.clear(); + } + }; + + const clearCanvas = () => { + if (signaturePadRef.current) { + signaturePadRef.current.clear(); + clearCommitTimer(); + latestDataUrlRef.current = null; + conversionIdRef.current += 1; + onChange(''); + } + }; + + useEffect(() => { + if (!canvasRef.current || mode !== 'draw') return; + + const canvas = canvasRef.current; + // Match canvas pixels to display size for correct pointer mapping. + const resizeCanvas = () => { + const rect = canvas.getBoundingClientRect(); + const ratio = Math.max(window.devicePixelRatio || 1, 1); + canvas.width = Math.floor(rect.width * ratio); + canvas.height = Math.floor(rect.height * ratio); + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.scale(ratio, ratio); + } + signaturePadRef.current?.clear(); + }; + + resizeCanvas(); + + signaturePadRef.current = new SignaturePad(canvasRef.current, { + backgroundColor: 'rgb(255, 255, 255)', + penColor: 'rgb(0, 0, 0)', + }); + + if (isDisabled) { + signaturePadRef.current.off(); + } + + signaturePadRef.current.addEventListener('endStroke', () => { + if (signaturePadRef.current) { + clearCommitTimer(); + commitTimerRef.current = setTimeout(() => { + commitSignature(); + }, 1000); + } + }); + + window.addEventListener('resize', resizeCanvas); + + return () => { + if (signaturePadRef.current) { + signaturePadRef.current.off(); + } + clearCommitTimer(); + window.removeEventListener('resize', resizeCanvas); + }; + }, [mode, isDisabled, onChange]); + + return ( +
+ {label && ( + + )} +
+ + +
+ {mode === 'type' ? ( + onChange(e.target.value)} + disabled={isDisabled} + placeholder="Type your full name" + style={{ + fontFamily: 'cursive', + fontSize: '20px', + padding: '14px', + border: '1px solid #d1d5db', + borderRadius: '8px', + outline: 'none', + transition: 'border-color 0.2s', + }} + onFocus={(e) => (e.target.style.borderColor = '#14b8a6')} + onBlur={(e) => (e.target.style.borderColor = '#d1d5db')} + /> + ) : ( +
+ + +
+ )} +
+ ); +}; + +export default CustomSignature; diff --git a/packages/esign/demo/src/index.css b/packages/esign/demo/src/index.css new file mode 100644 index 0000000000..9b579babb0 --- /dev/null +++ b/packages/esign/demo/src/index.css @@ -0,0 +1,40 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: system-ui, -apple-system, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + margin: 0; + font-size: 14px; + line-height: 1.6; + -webkit-text-size-adjust: 100%; + -webkit-tap-highlight-color: transparent; +} + +.superdoc-esign-document { + border: 1px solid #e5e7eb; + border-radius: 12px; + overflow: hidden; + background: #ffffff; +} + +.superdoc-esign-document-toolbar { + background: #f9fafb; + border-bottom: 1px solid #e5e7eb; +} + +.superdoc-esign-document-controls { + justify-content: flex-end; +} + +.superdoc-esign-document-viewer { + background: #ffffff; +} + +.superdoc-esign-form-actions { + justify-content: flex-end; +} diff --git a/packages/esign/demo/src/main.tsx b/packages/esign/demo/src/main.tsx new file mode 100644 index 0000000000..b2bc486bd9 --- /dev/null +++ b/packages/esign/demo/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { App } from './App'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/packages/esign/demo/src/vite-env.d.ts b/packages/esign/demo/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/packages/esign/demo/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/esign/demo/vite.config.ts b/packages/esign/demo/vite.config.ts new file mode 100644 index 0000000000..884133bb65 --- /dev/null +++ b/packages/esign/demo/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig(({ mode }) => ({ + plugins: [react()], + base: '/', + resolve: { + dedupe: ['react', 'react-dom'], + ...(mode === 'development' && { + alias: { + '@superdoc-dev/esign': path.resolve(__dirname, '../src/index.tsx'), + }, + }), + }, + server: { + proxy: { + '/v1': { + target: 'https://esign-demo-proxy-server-191591660773.us-central1.run.app', + changeOrigin: true, + secure: false, + }, + }, + }, +})); diff --git a/packages/esign/eslint.config.js b/packages/esign/eslint.config.js new file mode 100644 index 0000000000..965db512fc --- /dev/null +++ b/packages/esign/eslint.config.js @@ -0,0 +1,47 @@ +// @ts-check +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import reactPlugin from 'eslint-plugin-react'; +import reactHooksPlugin from 'eslint-plugin-react-hooks'; + +export default [ + { ignores: ['dist/', 'node_modules/'] }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['**/*.{ts,tsx}'], + plugins: { + react: reactPlugin, + 'react-hooks': reactHooksPlugin + }, + settings: { + react: { + version: 'detect' + } + }, + rules: { + '@typescript-eslint/no-explicit-any': 'warn', + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn' + } + }, + { + files: ['demo/server/**/*.js'], + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + globals: { + fetch: 'readonly', + process: 'readonly', + Buffer: 'readonly', + console: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly', + setInterval: 'readonly', + clearInterval: 'readonly', + setImmediate: 'readonly', + global: 'readonly' + } + } + } +]; diff --git a/packages/esign/package.json b/packages/esign/package.json new file mode 100644 index 0000000000..37ac9ad218 --- /dev/null +++ b/packages/esign/package.json @@ -0,0 +1,82 @@ +{ + "name": "@superdoc-dev/esign", + "version": "1.3.0", + "description": "React eSignature component for SuperDoc", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + }, + "./styles.css": "./src/styles.css" + }, + "files": [ + "dist", + "src/styles.css", + "README.md" + ], + "scripts": { + "dev": "vite build --watch", + "build": "tsc && vite build", + "type-check": "tsc --noEmit", + "lint": "eslint src", + "lint:fix": "eslint src --fix", + "test": "vitest run", + "test:watch": "vitest --watch", + "prepublishOnly": "pnpm run build" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/superdoc-dev/superdoc.git", + "directory": "packages/esign" + }, + "keywords": [ + "superdoc", + "esign", + "esignature", + "clickwrap", + "agreement", + "document", + "signature", + "react" + ], + "author": "SuperDoc Team", + "license": "AGPL-3.0", + "bugs": { + "url": "https://github.com/superdoc-dev/superdoc/issues" + }, + "homepage": "https://github.com/superdoc-dev/superdoc/tree/main/packages/esign#readme", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "superdoc": "^1.9.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "catalog:", + "@testing-library/react": "catalog:", + "@testing-library/user-event": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "eslint-plugin-react": "catalog:", + "eslint-plugin-react-hooks": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "superdoc": "workspace:*", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plugin-dts": "catalog:", + "vitest": "catalog:", + "jsdom": "catalog:" + }, + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=18" + } +} diff --git a/packages/esign/src/__tests__/SuperDocESign.test.tsx b/packages/esign/src/__tests__/SuperDocESign.test.tsx new file mode 100644 index 0000000000..568058a3e7 --- /dev/null +++ b/packages/esign/src/__tests__/SuperDocESign.test.tsx @@ -0,0 +1,500 @@ +import React, { createRef } from 'react'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeAll, afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import SuperDocESign from '../index'; +import type { + FieldComponentProps, + SuperDocESignHandle, + SuperDocESignProps, + AuditEvent, +} from '../types'; + +import { SuperDoc } from 'superdoc'; +import { getAuditEventTypes, resetAuditEvents } from '../test/setup'; + +const scrollListeners = new WeakMap(); +const originalAddEventListener = HTMLElement.prototype.addEventListener; + +beforeAll(() => { + HTMLElement.prototype.addEventListener = function ( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ) { + if (type === 'scroll' && typeof listener === 'function') { + scrollListeners.set(this as HTMLElement, listener); + } + return originalAddEventListener.call(this, type, listener, options); + }; +}); + +afterAll(() => { + HTMLElement.prototype.addEventListener = originalAddEventListener; +}); + +type ScrollMetrics = { + scrollTop: number; + scrollHeight: number; + clientHeight: number; +}; + +const configureScrollElement = (element: HTMLElement, initial: ScrollMetrics) => { + const metrics: ScrollMetrics = { ...initial }; + + Object.defineProperties(element, { + scrollTop: { + configurable: true, + get: () => metrics.scrollTop, + set: (value: number) => { + metrics.scrollTop = value; + }, + }, + scrollHeight: { + configurable: true, + get: () => metrics.scrollHeight, + set: (value: number) => { + metrics.scrollHeight = value; + }, + }, + clientHeight: { + configurable: true, + get: () => metrics.clientHeight, + set: (value: number) => { + metrics.clientHeight = value; + }, + }, + }); + + const dispatch = () => { + const listener = scrollListeners.get(element); + if (listener) { + listener.call(element, new Event('scroll')); + } + }; + + const update = (partial: Partial, shouldDispatch = true) => { + if (partial.scrollTop !== undefined) metrics.scrollTop = partial.scrollTop; + if (partial.scrollHeight !== undefined) metrics.scrollHeight = partial.scrollHeight; + if (partial.clientHeight !== undefined) metrics.clientHeight = partial.clientHeight; + if (shouldDispatch) dispatch(); + }; + + return { update, dispatch, metrics }; +}; + +type MockFn = ReturnType; +type SuperDocMockType = typeof SuperDoc & { + mockUpdateStructuredContentById: MockFn; + mockGetStructuredContentTags: MockFn; + mockDestroy: MockFn; +}; + +const superDocMock = SuperDoc as unknown as SuperDocMockType; + +const baseDocument: SuperDocESignProps['document'] = { + source: '

Test Document

', + mode: 'full', + validation: { + scroll: { + required: false, + }, + }, +}; + +const renderComponent = ( + props: Partial = {}, + options: { ref?: React.RefObject } = {}, +) => { + const { document: customDocument, ...restProps } = props; + + const mergedProps: SuperDocESignProps = { + eventId: 'evt_test', + fields: {}, + onSubmit: vi.fn(), + ...restProps, + document: { + ...baseDocument, + ...(customDocument || {}), + }, + } as SuperDocESignProps; + + return render(); +}; + +const waitForSuperDocReady = async () => { + await waitFor(() => { + expect(superDocMock.mockGetStructuredContentTags).toHaveBeenCalled(); + }); +}; + +beforeEach(() => { + vi.clearAllMocks(); + superDocMock.mockGetStructuredContentTags.mockReset(); + superDocMock.mockGetStructuredContentTags.mockReturnValue([]); + superDocMock.mockUpdateStructuredContentById.mockReset(); + superDocMock.mockDestroy.mockReset(); + resetAuditEvents(); +}); + +describe('SuperDocESign component', () => { + it('renders with minimum required props', async () => { + renderComponent(); + + await waitForSuperDocReady(); + + expect(screen.getByTestId('superdoc-esign-document')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /download/i })).toBeInTheDocument(); + }); + + it('requires scroll completion before enabling submit when validation is enforced', async () => { + const onSubmit = vi.fn(); + const ref = createRef(); + + const { getByPlaceholderText, getByRole, getByTestId } = renderComponent( + { + onSubmit, + document: { + source: '

Scroll document

', + mode: 'full', + validation: { scroll: { required: true } }, + }, + fields: { + signer: [ + { + id: 'sig-1', + type: 'signature', + label: 'Signature', + validation: { required: true }, + }, + ], + }, + }, + { ref }, + ); + + const scrollContainer = getByTestId('superdoc-scroll-container'); + const scrollController = configureScrollElement(scrollContainer, { + scrollHeight: 200, + clientHeight: 100, + scrollTop: 0, + }); + + await waitForSuperDocReady(); + + const submitButton = getByRole('button', { name: /submit/i }); + const input = getByPlaceholderText('Type your full name'); + fireEvent.change(input, { target: { value: 'Jane Doe' } }); + + await waitFor(() => expect(submitButton).toBeDisabled()); + + act(() => { + scrollController.update({ + scrollHeight: 1000, + clientHeight: 100, + scrollTop: 950, + }); + }); + + await waitFor(() => { + const state = ref.current?.getState(); + expect(state?.scrolled).toBe(true); + expect(state?.isValid).toBe(true); + }); + + const updatedSubmitButton = getByRole('button', { name: /submit/i }); + await userEvent.click(updatedSubmitButton); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + }); + + it('invokes field and state change callbacks and updates SuperDoc document', async () => { + const onFieldChange = vi.fn(); + const onStateChange = vi.fn(); + + const { getByPlaceholderText } = renderComponent({ + onFieldChange, + onStateChange, + fields: { + signer: [ + { + id: 'sig-field', + type: 'signature', + label: 'Signature', + validation: { required: true }, + }, + ], + }, + }); + + await waitForSuperDocReady(); + + const input = getByPlaceholderText('Type your full name'); + fireEvent.change(input, { target: { value: 'John Doe' } }); + + await waitFor(() => { + expect(onFieldChange).toHaveBeenCalled(); + expect(onStateChange).toHaveBeenCalled(); + }); + + const lastFieldChange = onFieldChange.mock.calls.at(-1)?.[0]; + expect(lastFieldChange).toMatchObject({ + id: 'sig-field', + value: 'John Doe', + }); + + const lastState = onStateChange.mock.calls.at(-1)?.[0]; + expect(lastState?.fields.get('sig-field')).toBe('John Doe'); + + expect(superDocMock.mockUpdateStructuredContentById).toHaveBeenCalledWith( + 'sig-field', + expect.objectContaining({ + json: expect.objectContaining({ + attrs: expect.objectContaining({ src: expect.any(String) }), + }), + }), + ); + }); + + it('tracks audit trail and exposes ref methods', async () => { + const ref = createRef(); + + const { getByPlaceholderText, getByRole, getByTestId } = renderComponent( + { + document: { + source: '

Scroll doc

', + mode: 'full', + validation: { scroll: { required: true } }, + }, + fields: { + signer: [ + { + id: 'sig-field', + type: 'signature', + label: 'Signature', + validation: { required: true }, + }, + ], + }, + }, + { ref }, + ); + + await waitForSuperDocReady(); + await waitFor(() => expect(ref.current).toBeTruthy()); + + const scrollContainer = getByTestId('superdoc-scroll-container'); + const scrollController = configureScrollElement(scrollContainer, { + scrollHeight: 300, + clientHeight: 100, + scrollTop: 0, + }); + + const input = getByPlaceholderText('Type your full name'); + fireEvent.change(input, { target: { value: 'Audit User' } }); + + act(() => { + scrollController.update({ scrollTop: 250 }); + }); + + const submitButton = getByRole('button', { name: /submit/i }); + await waitFor(() => expect(submitButton).not.toBeDisabled(), { + timeout: 2000, + }); + await userEvent.click(submitButton); + + await waitFor(() => { + const auditTrail = ref.current?.getAuditTrail() ?? []; + expect(auditTrail.length).toBeGreaterThanOrEqual(4); + }); + + const auditTrail = ref.current?.getAuditTrail() ?? []; + const types = auditTrail.map((event) => event.type); + expect(types[0]).toBe('ready'); + expect(types).to.include('field_change'); + expect(types.filter((type) => type === 'scroll').length).toBeGreaterThanOrEqual(1); + expect(types).to.include('submit'); + + auditTrail.forEach((event: AuditEvent) => { + expect(typeof event.timestamp).toBe('string'); + expect(Number.isNaN(new Date(event.timestamp).getTime())).toBe(false); + }); + + const stateBeforeReset = ref.current?.getState(); + expect(stateBeforeReset).toMatchObject({ + scrolled: true, + isValid: true, + isSubmitting: false, + }); + expect(stateBeforeReset?.fields.get('sig-field')).toBe('Audit User'); + + act(() => { + ref.current?.reset(); + }); + + const stateAfterReset = ref.current?.getState(); + expect(stateAfterReset).toMatchObject({ + scrolled: false, + isValid: false, + }); + expect(stateAfterReset?.fields.size).toBe(0); + expect(ref.current?.getAuditTrail()).toEqual([]); + }); + + it('prefers custom components when provided', async () => { + const onSubmit = vi.fn(); + const onDownload = vi.fn(); + + const CustomField: React.FC = ({ onChange, label }) => ( +
+ {label} + +
+ ); + const SubmitButton: React.FC<{ + onClick: () => void; + isDisabled: boolean; + isValid: boolean; + isSubmitting: boolean; + }> = ({ onClick, isDisabled, isValid }) => ( + + ); + + const DownloadButton: React.FC<{ + onClick: () => void; + fileName?: string; + isDisabled: boolean; + }> = ({ onClick, isDisabled }) => ( + + ); + + renderComponent({ + onSubmit, + onDownload, + fields: { + signer: [ + { + id: 'custom-field', + type: 'text', + label: 'Custom Field', + component: CustomField, + validation: { required: true }, + }, + ], + }, + submit: { + component: SubmitButton as unknown as React.ComponentType, + }, + download: { + component: DownloadButton as unknown as React.ComponentType, + }, + }); + + await waitFor(() => expect(superDocMock).toHaveBeenCalled()); + await waitForSuperDocReady(); + + expect(screen.getByText('Custom Field')).toBeInTheDocument(); + expect(screen.queryByPlaceholderText('Type your full name')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByText('Set Custom Value')); + + const submitButton = screen.getByRole('button', { name: 'Send It' }); + await waitFor(() => expect(submitButton).not.toBeDisabled()); + await userEvent.click(submitButton); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + + const downloadButton = screen.getByRole('button', { name: 'Grab Copy' }); + expect(downloadButton).toBeEnabled(); + await userEvent.click(downloadButton); + await waitFor(() => expect(onDownload).toHaveBeenCalledTimes(1)); + + const downloadPayload = onDownload.mock.calls.at(0)?.[0]; + expect(downloadPayload).toMatchObject({ + eventId: 'evt_test', + fields: { + signer: [{ id: 'custom-field', value: 'custom-value' }], + }, + fileName: 'document.pdf', + }); + }); + + it('generates submit payload with expected structure and audit trail', async () => { + const onSubmit = vi.fn(); + + const { getByPlaceholderText, getByRole, getByTestId } = renderComponent({ + onSubmit, + document: { + source: '

Full payload doc

', + mode: 'full', + validation: { scroll: { required: true } }, + }, + fields: { + document: [ + { + id: 'doc-field', + value: 'Document Value', + }, + ], + signer: [ + { + id: 'sig-field', + type: 'signature', + label: 'Signature', + validation: { required: true }, + }, + ], + }, + }); + + const scrollContainer = getByTestId('superdoc-scroll-container'); + const scrollController = configureScrollElement(scrollContainer, { + scrollHeight: 400, + clientHeight: 100, + scrollTop: 0, + }); + + await waitForSuperDocReady(); + + fireEvent.change(getByPlaceholderText('Type your full name'), { + target: { value: 'Payload User' }, + }); + + act(() => { + scrollController.update({ scrollTop: 390 }); + }); + + const submitButton = getByRole('button', { name: /submit/i }); + await waitFor(() => expect(submitButton).not.toBeDisabled()); + await userEvent.click(submitButton); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + + await waitFor(() => { + const types = getAuditEventTypes(); + expect(types).to.include('submit'); + }); + + const submitData = onSubmit.mock.calls[0][0]; + expect(submitData.eventId).toBe('evt_test'); + expect(typeof submitData.timestamp).toBe('string'); + expect(Number.isNaN(new Date(submitData.timestamp).getTime())).toBe(false); + expect(typeof submitData.duration).toBe('number'); + expect(submitData.isFullyCompleted).toBe(true); + + expect(submitData.documentFields).toEqual([{ id: 'doc-field', value: 'Document Value' }]); + + expect(submitData.signerFields).toEqual([{ id: 'sig-field', value: 'Payload User' }]); + + const auditTypes = submitData.auditTrail.map((event: AuditEvent) => event.type); + expect(auditTypes).to.include.members(['ready', 'field_change']); + expect(auditTypes).to.include('submit'); + }); +}); diff --git a/packages/esign/src/__tests__/signature.test.ts b/packages/esign/src/__tests__/signature.test.ts new file mode 100644 index 0000000000..28d7282a48 --- /dev/null +++ b/packages/esign/src/__tests__/signature.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest'; + +import { textToImageDataUrl } from '../utils/signature'; + +describe('textToImageDataUrl', () => { + it('returns a data URL for a typed signature', () => { + const result = textToImageDataUrl('Jane Doe'); + // the mock is generated by the test/setup.ts file + expect(result).toBe('data:image/png;base64,mock'); + }); +}); diff --git a/packages/esign/src/defaults/CheckboxInput.tsx b/packages/esign/src/defaults/CheckboxInput.tsx new file mode 100644 index 0000000000..64741b98e0 --- /dev/null +++ b/packages/esign/src/defaults/CheckboxInput.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import type { FieldComponentProps } from '../types'; + +export const CheckboxInput: React.FC = ({ + value, + onChange, + isDisabled, + label, +}) => { + return ( + + ); +}; diff --git a/packages/esign/src/defaults/DownloadButton.tsx b/packages/esign/src/defaults/DownloadButton.tsx new file mode 100644 index 0000000000..7b78a26524 --- /dev/null +++ b/packages/esign/src/defaults/DownloadButton.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import type { DownloadButtonProps, DownloadConfig } from '../types'; + +export const createDownloadButton = (config?: DownloadConfig) => { + const Component: React.FC = ({ + onClick, + fileName, + isDisabled, + isDownloading, + }) => { + const label = config?.label || 'Download'; + const disabled = isDisabled || isDownloading; + + return ( + + ); + }; + + return Component; +}; diff --git a/packages/esign/src/defaults/SignatureInput.tsx b/packages/esign/src/defaults/SignatureInput.tsx new file mode 100644 index 0000000000..f81c1d154b --- /dev/null +++ b/packages/esign/src/defaults/SignatureInput.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import type { FieldComponentProps } from '../types'; + +export const SignatureInput: React.FC = ({ + value, + onChange, + isDisabled, + label, +}) => { + return ( +
+ {label && } + onChange(e.target.value)} + disabled={isDisabled} + placeholder="Type your full name" + style={{ + fontFamily: 'cursive', + fontSize: '18px', + }} + /> +
+ ); +}; diff --git a/packages/esign/src/defaults/SubmitButton.tsx b/packages/esign/src/defaults/SubmitButton.tsx new file mode 100644 index 0000000000..b16b58348e --- /dev/null +++ b/packages/esign/src/defaults/SubmitButton.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import type { SubmitButtonProps, SubmitConfig } from '../types'; + +export const createSubmitButton = (config?: SubmitConfig) => { + const Component: React.FC = ({ + onClick, + isValid, + isDisabled, + isSubmitting, + }) => { + const label = config?.label || 'Submit'; + const disabled = !isValid || isDisabled || isSubmitting; + + return ( + + ); + }; + + return Component; +}; diff --git a/packages/esign/src/defaults/index.ts b/packages/esign/src/defaults/index.ts new file mode 100644 index 0000000000..0186763e7d --- /dev/null +++ b/packages/esign/src/defaults/index.ts @@ -0,0 +1,4 @@ +export { SignatureInput } from './SignatureInput'; +export { CheckboxInput } from './CheckboxInput'; +export { createDownloadButton } from './DownloadButton'; +export { createSubmitButton } from './SubmitButton'; diff --git a/packages/esign/src/index.tsx b/packages/esign/src/index.tsx new file mode 100644 index 0000000000..2234951c89 --- /dev/null +++ b/packages/esign/src/index.tsx @@ -0,0 +1,477 @@ +import { useRef, useState, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react'; +import type { SuperDoc } from 'superdoc'; +import type * as Types from './types'; +import { textToImageDataUrl } from './utils/signature'; +import { + SignatureInput, + CheckboxInput, + createDownloadButton, + createSubmitButton, +} from './defaults'; + +export * from './types'; +export { textToImageDataUrl }; +export { SignatureInput, CheckboxInput }; + +type Editor = NonNullable; + +const SuperDocESign = forwardRef( + (props, ref) => { + const { + eventId, + document, + fields = {}, + download, + submit, + onSubmit, + onDownload, + onStateChange, + onFieldChange, + onFieldsDiscovered, + isDisabled = false, + className, + style, + documentHeight = '600px', + } = props; + + const [scrolled, setScrolled] = useState(!document.validation?.scroll?.required); + const [fieldValues, setFieldValues] = useState>(new Map()); + const [isValid, setIsValid] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isDownloading, setIsDownloading] = useState(false); + const [auditTrail, setAuditTrail] = useState([]); + const [isReady, setIsReady] = useState(false); + + const containerRef = useRef(null); + const superdocRef = useRef(null); + const startTimeRef = useRef(Date.now()); + const fieldsRef = useRef(fields); + const auditTrailRef = useRef([]); + const onFieldsDiscoveredRef = useRef(onFieldsDiscovered); + fieldsRef.current = fields; + onFieldsDiscoveredRef.current = onFieldsDiscovered; + + useEffect(() => { + auditTrailRef.current = auditTrail; + }, [auditTrail]); + + const updateFieldInDocument = useCallback((field: Types.FieldUpdate) => { + if (!superdocRef.current?.activeEditor) return; + const editor = superdocRef.current.activeEditor; + + const signerField = fieldsRef.current.signer?.find((f) => f.id === field.id); + + let updatePayload; + + if (signerField?.type === 'signature' && field.value) { + const imageUrl = + typeof field.value === 'string' && field.value.startsWith('data:image/') + ? field.value + : textToImageDataUrl(String(field.value)); + + updatePayload = { + json: { + type: 'image', + attrs: { src: imageUrl, alt: 'Signature' }, + }, + }; + } else { + updatePayload = { text: String(field.value ?? '') }; + } + + if (field.id) { + editor.commands.updateStructuredContentById?.(field.id, updatePayload); + } + }, []); + + const discoverAndApplyFields = useCallback( + (editor: Editor) => { + if (!editor) return; + + const tags = editor.helpers.structuredContentCommands.getStructuredContentTags( + editor.state, + ); + + const configValues = new Map(); + + fieldsRef.current.document?.forEach((f) => { + if (f.id) configValues.set(f.id, f.value); + }); + + fieldsRef.current.signer?.forEach((f) => { + if (f.value !== undefined) { + configValues.set(f.id, f.value); + } + }); + + const discovered: Types.FieldInfo[] = tags + .map(({ node }: any) => ({ + id: node.attrs.id, + label: node.attrs.label, + value: configValues.get(node.attrs.id) ?? node.textContent ?? '', + })) + .filter((f: Types.FieldInfo) => f.id); + + if (discovered.length > 0) { + onFieldsDiscoveredRef.current?.(discovered); + + const allFields = [ + ...(fieldsRef.current.document || []), + ...(fieldsRef.current.signer || []), + ]; + + allFields + .filter((field) => field.value !== undefined) + .forEach((field) => + updateFieldInDocument({ + id: field.id, + value: field.value!, + }), + ); + } + }, + [updateFieldInDocument], + ); + + const addAuditEvent = (event: Omit): Types.AuditEvent[] => { + const auditEvent: Types.AuditEvent = { + ...event, + timestamp: new Date().toISOString(), + }; + const auditMock = (globalThis as any)?.__SUPERDOC_AUDIT_MOCK__; + if (auditMock) { + auditMock(auditEvent); + } + const nextTrail = [...auditTrailRef.current, auditEvent]; + auditTrailRef.current = nextTrail; + setAuditTrail(nextTrail); + return nextTrail; + }; + + // Initialize SuperDoc - uses abort pattern to handle React 18 Strict Mode + // which intentionally double-invokes effects to help identify cleanup issues + useEffect(() => { + if (!containerRef.current) return; + + let aborted = false; + let instance: SuperDoc | null = null; + + const initSuperDoc = async () => { + const { SuperDoc } = await import('superdoc'); + + // If cleanup ran while we were importing, abort + if (aborted) return; + + instance = new SuperDoc({ + selector: containerRef.current!, + document: document.source, + documentMode: 'viewing', + modules: { + comments: false, + }, + // @ts-expect-error - layoutMode is not supported in SuperDoc v1.1.0 yet + layoutMode: document.layoutMode, + layoutMargins: document.layoutMargins, + onReady: () => { + // Guard callback execution if cleanup already ran + if (aborted) return; + if (instance?.activeEditor) { + discoverAndApplyFields(instance.activeEditor); + } + addAuditEvent({ type: 'ready' }); + setIsReady(true); + }, + }); + + superdocRef.current = instance; + }; + + initSuperDoc(); + + return () => { + aborted = true; + if (instance) { + if (typeof instance.destroy === 'function') { + instance.destroy(); + } + } + superdocRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- compare margin primitives to avoid re-init on every render + }, [ + document.source, + document.mode, + document.layoutMode, + document.layoutMargins?.top, + document.layoutMargins?.bottom, + document.layoutMargins?.left, + document.layoutMargins?.right, + discoverAndApplyFields, + ]); + + useEffect(() => { + if (!document.validation?.scroll?.required || !isReady) return; + + const scrollContainer = containerRef.current; + if (!scrollContainer) return; + + const handleScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = scrollContainer; + const scrollPercentage = scrollTop / (scrollHeight - clientHeight); + + if (scrollPercentage >= 0.95 || scrollHeight <= clientHeight) { + setScrolled(true); + addAuditEvent({ + type: 'scroll', + data: { percent: Math.round(scrollPercentage * 100) }, + }); + } + }; + + scrollContainer.addEventListener('scroll', handleScroll); + handleScroll(); + + return () => scrollContainer.removeEventListener('scroll', handleScroll); + }, [document.validation?.scroll?.required, isReady]); + + const handleFieldChange = useCallback( + (fieldId: string, value: Types.FieldValue) => { + setFieldValues((prev) => { + const previousValue = prev.get(fieldId); + const newMap = new Map(prev); + newMap.set(fieldId, value); + + updateFieldInDocument({ + id: fieldId, + value: value, + }); + + addAuditEvent({ + type: 'field_change', + data: { fieldId, value, previousValue }, + }); + + onFieldChange?.({ + id: fieldId, + value, + previousValue, + }); + + return newMap; + }); + }, + [onFieldChange, updateFieldInDocument], + ); + const checkIsValid = useCallback((): boolean => { + if (document.validation?.scroll?.required && !scrolled) { + return false; + } + + return (fields.signer || []).every((field) => { + if (!field.validation?.required) return true; + const value = fieldValues.get(field.id); + return value && (typeof value !== 'string' || value.trim()); + }); + }, [scrolled, fields.signer, fieldValues, document.validation?.scroll?.required]); + useEffect(() => { + const valid = checkIsValid(); + setIsValid(valid); + + const state: Types.SigningState = { + scrolled, + fields: fieldValues, + isValid: valid, + isSubmitting, + }; + onStateChange?.(state); + }, [scrolled, fieldValues, isSubmitting, checkIsValid, onStateChange]); + + const handleDownload = useCallback(async () => { + if (isDisabled || isDownloading) return; + + setIsDownloading(true); + + const downloadData: Types.DownloadData = { + eventId, + documentSource: document.source, + fields: { + document: fields.document || [], + signer: (fields.signer || []).map((field) => ({ + id: field.id, + value: fieldValues.get(field.id) ?? null, + })), + }, + fileName: download?.fileName || 'document.pdf', + }; + + try { + await onDownload?.(downloadData); + } finally { + setIsDownloading(false); + } + }, [ + isDisabled, + isDownloading, + eventId, + document.source, + fields, + fieldValues, + download, + onDownload, + ]); + + const handleSubmit = useCallback(async () => { + if (!isValid || isDisabled || isSubmitting) return; + + setIsSubmitting(true); + addAuditEvent({ type: 'submit' }); + + const nextAuditTrail = addAuditEvent({ type: 'submit' }); + + const submitData: Types.SubmitData = { + eventId, + timestamp: new Date().toISOString(), + duration: Math.floor((Date.now() - startTimeRef.current) / 1000), + auditTrail: nextAuditTrail, + documentFields: fields.document || [], + signerFields: (fields.signer || []).map((field) => ({ + id: field.id, + value: fieldValues.get(field.id) ?? null, + })), + isFullyCompleted: isValid, + }; + + try { + await onSubmit(submitData); + } finally { + setIsSubmitting(false); + } + }, [isValid, isDisabled, isSubmitting, eventId, fields, fieldValues, onSubmit]); + + const renderField = (field: Types.SignerField) => { + const Component = field.component || getDefaultComponent(field.type); + + return ( + handleFieldChange(field.id, value)} + isDisabled={isDisabled} + label={field.label} + /> + ); + }; + + const getDefaultComponent = (type: 'signature' | 'checkbox' | 'text') => { + switch (type) { + case 'signature': + case 'text': + return SignatureInput; + case 'checkbox': + return CheckboxInput; + } + }; + + const renderDocumentControls = () => { + const DownloadButton = download?.component || createDownloadButton(download); + + if (!DownloadButton) return null; + + return ( + + ); + }; + + const renderFormActions = () => { + if (document.mode === 'download') { + return null; + } + + const SubmitButton = submit?.component || createSubmitButton(submit); + + return ( +
+ +
+ ); + }; + + const documentControls = renderDocumentControls(); + const formActions = renderFormActions(); + + useImperativeHandle( + ref, + () => ({ + getState: () => ({ + scrolled, + fields: fieldValues, + isValid, + isSubmitting, + }), + getAuditTrail: () => auditTrailRef.current, + reset: () => { + setScrolled(!document.validation?.scroll?.required); + setFieldValues(new Map()); + setIsValid(false); + auditTrailRef.current = []; + setAuditTrail([]); + }, + updateFieldInDocument, + }), + [ + scrolled, + fieldValues, + isValid, + isSubmitting, + document.validation?.scroll?.required, + updateFieldInDocument, + ], + ); + + return ( +
+ {/* Document viewer section */} +
+ {documentControls && ( +
+
{documentControls}
+
+ )} +
+
+ + {/* Controls section - separate from document */} +
+ {/* Signer fields */} + {fields.signer && fields.signer.length > 0 && ( +
+ {fields.signer.map(renderField)} +
+ )} + + {/* Action buttons */} + {formActions} +
+
+ ); + }, +); + +SuperDocESign.displayName = 'SuperDocESign'; + +export default SuperDocESign; diff --git a/packages/esign/src/styles.css b/packages/esign/src/styles.css new file mode 100644 index 0000000000..985f3e11f4 --- /dev/null +++ b/packages/esign/src/styles.css @@ -0,0 +1,77 @@ +/** + * SuperDoc eSign - Default Styles + * + * These styles provide sensible defaults for the eSign component. + * Import this file to get a basic layout, or skip it and write your own CSS. + * + * Usage: + * import '@superdoc-dev/esign/styles.css'; + * + * All classes can be customized with standard CSS: + * + * .superdoc-esign-container - Root container (also accepts className prop) + * .superdoc-esign-document - Document section wrapper + * .superdoc-esign-document-toolbar - Toolbar with download button + * .superdoc-esign-document-controls - Control buttons container + * .superdoc-esign-document-viewer - Scroll container (SuperDoc mounts inside) + * .superdoc-esign-controls - Bottom section with fields + submit + * .superdoc-esign-fields - Signer fields container + * .superdoc-esign-actions - Action buttons container + * .superdoc-esign-form-actions - Form submit button container (alias for actions) + */ + +.superdoc-esign-document { + display: flex; + flex-direction: column; +} + +.superdoc-esign-document-toolbar { + display: flex; + justify-content: flex-end; + align-items: center; + padding: 8px 12px; +} + +.superdoc-esign-document-controls { + display: flex; + gap: 8px; +} + +.superdoc-esign-controls { + margin-top: 20px; +} + +.superdoc-esign-fields { + margin-bottom: 20px; +} + +.superdoc-esign-actions { + display: flex; + gap: 10px; +} + +/* Button loading state */ +.superdoc-esign-btn--loading { + position: relative; +} + +/* Spinner */ +.superdoc-esign-spinner { + width: 16px; + height: 16px; + border: 2px solid #d0d5dd; + border-top-color: #333; + border-radius: 50%; + animation: superdoc-esign-spin 0.8s linear infinite; +} + +.superdoc-esign-spinner--light { + border-color: rgba(255, 255, 255, 0.3); + border-top-color: #fff; +} + +@keyframes superdoc-esign-spin { + to { + transform: rotate(360deg); + } +} diff --git a/packages/esign/src/test/setup.ts b/packages/esign/src/test/setup.ts new file mode 100644 index 0000000000..8357addd3c --- /dev/null +++ b/packages/esign/src/test/setup.ts @@ -0,0 +1,89 @@ +import '@testing-library/jest-dom/vitest'; + +const mockUpdateStructuredContentById = vi.fn(); +const mockGetStructuredContentTags = vi.fn(() => []); +const mockDestroy = vi.fn(); + +const auditEvents: Array<{ type: string; data?: Record }> = []; + +export const resetAuditEvents = () => { + auditEvents.length = 0; +}; + +export const recordAuditEvent = (type: string, data?: Record) => { + auditEvents.push({ type, data }); +}; + +export const getAuditEventTypes = () => auditEvents.map((event) => event.type); + +if (typeof window !== 'undefined') { + (window as any).__SUPERDOC_AUDIT_MOCK__ = (event: { + type: string; + data?: Record; + }) => { + recordAuditEvent(event.type, event.data); + }; +} + +vi.stubGlobal( + '__SUPERDOC_AUDIT_MOCK__', + (event: { type: string; data?: Record }) => { + recordAuditEvent(event.type, event.data); + }, +); + +const mockEditor = { + commands: { + updateStructuredContentById: mockUpdateStructuredContentById, + }, + helpers: { + structuredContentCommands: { + getStructuredContentTags: mockGetStructuredContentTags, + }, + }, + state: {}, +}; + +const SuperDocMock = vi.fn((options: any = {}) => { + if (options?.onReady) { + if (typeof queueMicrotask === 'function') { + queueMicrotask(() => options.onReady()); + } else { + Promise.resolve().then(() => options.onReady()); + } + } + + return { + destroy: mockDestroy, + activeEditor: mockEditor, + on: vi.fn(), + }; +}); + +(SuperDocMock as any).mockEditor = mockEditor; +(SuperDocMock as any).mockUpdateStructuredContentById = mockUpdateStructuredContentById; +(SuperDocMock as any).mockGetStructuredContentTags = mockGetStructuredContentTags; +(SuperDocMock as any).mockDestroy = mockDestroy; +(SuperDocMock as any).mockAuditEvents = auditEvents; +(SuperDocMock as any).resetAuditEvents = resetAuditEvents; +(SuperDocMock as any).recordAuditEvent = recordAuditEvent; +(SuperDocMock as any).getAuditEventTypes = getAuditEventTypes; + +vi.mock('superdoc', () => ({ + SuperDoc: SuperDocMock, +})); + +const canvasProto = globalThis.HTMLCanvasElement?.prototype; + +if (canvasProto) { + canvasProto.getContext = vi.fn(() => ({ + font: '', + fillStyle: '', + textAlign: '', + textBaseline: '', + measureText: () => ({ width: 100 }), + fillText: () => undefined, + })) as any; + + canvasProto.toDataURL = vi.fn(() => 'data:image/png;base64,mock'); +} diff --git a/packages/esign/src/types.ts b/packages/esign/src/types.ts new file mode 100644 index 0000000000..72f9b6a350 --- /dev/null +++ b/packages/esign/src/types.ts @@ -0,0 +1,159 @@ +import type { SuperDoc } from 'superdoc'; // eslint-disable-line + +export type FieldValue = string | boolean | number | null | undefined; + +export interface FieldReference { + id: string; +} + +export interface DocumentField extends FieldReference { + value: FieldValue; +} + +export interface SignerField extends FieldReference { + type: 'signature' | 'checkbox' | 'text'; + label?: string; + value?: FieldValue; + validation?: { + required?: boolean; + }; + component?: React.ComponentType; +} + +export interface FieldComponentProps { + value: FieldValue; + onChange: (value: FieldValue) => void; + isDisabled: boolean; + isValid?: boolean; + label?: string; + error?: string; +} + +export interface DownloadButtonProps { + onClick: () => void; + fileName?: string; + isDisabled: boolean; + isDownloading: boolean; +} + +export interface SubmitButtonProps { + onClick: () => void; + isValid: boolean; + isDisabled: boolean; + isSubmitting: boolean; +} + +export interface DownloadConfig { + fileName?: string; + label?: string; + component?: React.ComponentType; +} + +export interface SubmitConfig { + label?: string; + component?: React.ComponentType; +} + +export interface LayoutMargins { + top?: number; + bottom?: number; + left?: number; + right?: number; +} + +export interface DocumentConfig { + source: string | File | Blob; + mode?: 'full' | 'download'; + validation?: { + scroll?: { + required?: boolean; + }; + }; + /** + * Document layout mode: + * - 'paginated' (default): Fixed page width, shows page breaks + * - 'responsive': 100% width, text reflows to fit container (useful for mobile/accessibility) + * Note: 'responsive' takes precedence over pagination - pagination is ignored when layoutMode is 'responsive' + */ + layoutMode?: 'responsive' | 'paginated'; + /** + * Custom margins in pixels for responsive layout mode. + * Only applies when layoutMode is 'responsive'. + */ + layoutMargins?: LayoutMargins; +} + +export interface SuperDocESignProps { + eventId: string; + + document: DocumentConfig; + + fields?: { + document?: DocumentField[]; + signer?: SignerField[]; + }; + + download?: DownloadConfig; + submit?: SubmitConfig; + + // Events + onSubmit: (data: SubmitData) => void | Promise; + onDownload?: (data: DownloadData) => void | Promise; + onStateChange?: (state: SigningState) => void; + onFieldChange?: (field: FieldChange) => void; + onFieldsDiscovered?: (fields: FieldInfo[]) => void; + + isDisabled?: boolean; + className?: string; + style?: React.CSSProperties; + documentHeight?: string; +} + +export interface SigningState { + scrolled: boolean; + fields: Map; + isValid: boolean; + isSubmitting: boolean; +} + +export interface SuperDocESignHandle { + getState: () => SigningState; + getAuditTrail: () => AuditEvent[]; + reset: () => void; + updateFieldInDocument: (field: FieldUpdate) => void; +} + +export interface DownloadData { + eventId: string; + documentSource: string | File | Blob; + fields: { + document: DocumentField[]; + signer: SignerFieldValue[]; + }; + fileName: string; +} + +export interface SubmitData { + eventId: string; + timestamp: string; + duration: number; + auditTrail: AuditEvent[]; + documentFields: Array; + signerFields: Array; + isFullyCompleted: boolean; +} + +export interface AuditEvent { + timestamp: string; + type: 'ready' | 'scroll' | 'field_change' | 'submit'; + data?: Record; +} + +export type FieldInfo = DocumentField & { label?: string }; +export type FieldUpdate = DocumentField; +export type FieldChange = DocumentField & { previousValue?: FieldValue }; + +export interface SignerFieldValue { + id: string; + value: FieldValue; +} diff --git a/packages/esign/src/utils/signature.ts b/packages/esign/src/utils/signature.ts new file mode 100644 index 0000000000..70ea8c73df --- /dev/null +++ b/packages/esign/src/utils/signature.ts @@ -0,0 +1,27 @@ +// Convert typed signature text into a PNG data URL for consistent rendering. +export const textToImageDataUrl = (text: string): string => { + const canvas = globalThis.document.createElement('canvas'); + const ctx = canvas.getContext('2d')!; + + const fontSize = 30; + ctx.font = `italic ${fontSize}px cursive`; + + const metrics = ctx.measureText(text); + const textWidth = metrics.width; + + const estimatedHeight = fontSize * 1.3; + const paddingX = 4; + const paddingY = 6; + + canvas.width = Math.ceil(textWidth + paddingX * 2) + 20; + canvas.height = Math.ceil(estimatedHeight + paddingY * 2); + + ctx.font = `italic ${fontSize}px cursive`; + ctx.fillStyle = 'black'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + ctx.fillText(text, canvas.width / 2, canvas.height / 2); + + return canvas.toDataURL('image/png'); +}; diff --git a/packages/esign/tsconfig.json b/packages/esign/tsconfig.json new file mode 100644 index 0000000000..5a5a55459a --- /dev/null +++ b/packages/esign/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "types": ["vitest/globals"], + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": [ + "src", + "dev/index copy.tsx" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/packages/esign/vite.config.ts b/packages/esign/vite.config.ts new file mode 100644 index 0000000000..4b2f28074a --- /dev/null +++ b/packages/esign/vite.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + build: { + lib: { + entry: 'src/index.tsx', + formats: ['es', 'cjs'], + fileName: (format) => (format === 'es' ? 'index.mjs' : 'index.js'), + }, + rollupOptions: { + external: ['react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime', 'superdoc'], + }, + }, + plugins: [react(), dts()], + test: { + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + globals: true, + clearMocks: true, + restoreMocks: true, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4718044ca2..47811918e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -211,11 +211,11 @@ catalogs: specifier: ^1.33.8 version: 1.41.4 react: - specifier: ^19.2.0 - version: 19.2.3 + specifier: 19.2.0 + version: 19.2.0 react-dom: - specifier: ^19.2.0 - version: 19.2.4 + specifier: 19.2.0 + version: 19.2.0 rehype-parse: specifier: ^9.0.1 version: 9.0.1 @@ -543,6 +543,103 @@ importers: specifier: 'catalog:' version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.2)(happy-dom@20.3.4)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.0)(postcss@8.5.6))(tsx@4.21.0)(yaml@2.8.2) + packages/esign: + devDependencies: + '@testing-library/jest-dom': + specifier: 'catalog:' + version: 6.9.1 + '@testing-library/react': + specifier: 'catalog:' + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@testing-library/user-event': + specifier: 'catalog:' + version: 14.6.1(@testing-library/dom@10.4.1) + '@types/react': + specifier: 'catalog:' + version: 19.2.9 + '@types/react-dom': + specifier: 'catalog:' + version: 19.2.3(@types/react@19.2.9) + '@vitejs/plugin-react': + specifier: 'catalog:' + version: 5.1.2(vite@7.2.7(@types/node@22.19.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) + eslint-plugin-react: + specifier: 'catalog:' + version: 7.37.5(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-react-hooks: + specifier: 'catalog:' + version: 7.0.1(eslint@9.39.1(jiti@2.6.1)) + jsdom: + specifier: 'catalog:' + version: 27.3.0(canvas@3.2.0)(postcss@8.5.6) + react: + specifier: 'catalog:' + version: 19.2.0 + react-dom: + specifier: 'catalog:' + version: 19.2.0(react@19.2.0) + superdoc: + specifier: workspace:* + version: link:../superdoc + typescript: + specifier: 'catalog:' + version: 5.9.3 + vite: + specifier: 'catalog:' + version: 7.2.7(@types/node@22.19.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + vite-plugin-dts: + specifier: 'catalog:' + version: 4.5.4(@types/node@22.19.2)(rollup@4.53.3)(typescript@5.9.3)(vite@7.2.7(@types/node@22.19.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) + vitest: + specifier: 'catalog:' + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.2)(happy-dom@20.3.4)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.0)(postcss@8.5.6))(tsx@4.21.0)(yaml@2.8.2) + + packages/esign/demo: + dependencies: + '@superdoc-dev/esign': + specifier: workspace:* + version: link:.. + react: + specifier: 'catalog:' + version: 19.2.0 + react-dom: + specifier: 'catalog:' + version: 19.2.0(react@19.2.0) + signature_pad: + specifier: ^5.1.1 + version: 5.1.3 + superdoc: + specifier: workspace:* + version: link:../../superdoc + devDependencies: + '@types/react': + specifier: 'catalog:' + version: 19.2.9 + '@types/react-dom': + specifier: 'catalog:' + version: 19.2.3(@types/react@19.2.9) + '@vitejs/plugin-react': + specifier: 'catalog:' + version: 5.1.2(vite@7.2.7(@types/node@22.19.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vite: + specifier: 'catalog:' + version: 7.2.7(@types/node@22.19.2)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + + packages/esign/demo/server: + dependencies: + cors: + specifier: ^2.8.5 + version: 2.8.5 + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + express: + specifier: ^4.19.2 + version: 4.21.2 + packages/layout-engine: {} packages/layout-engine/contracts: {} @@ -998,7 +1095,7 @@ importers: version: 6.9.1 '@testing-library/react': specifier: 'catalog:' - version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.4(react@19.2.3))(react@19.2.3) + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@testing-library/user-event': specifier: 'catalog:' version: 14.6.1(@testing-library/dom@10.4.1) @@ -1022,10 +1119,10 @@ importers: version: 27.3.0(canvas@3.2.0)(postcss@8.5.6) react: specifier: 'catalog:' - version: 19.2.3 + version: 19.2.0 react-dom: specifier: 'catalog:' - version: 19.2.4(react@19.2.3) + version: 19.2.0(react@19.2.0) superdoc: specifier: workspace:* version: link:../superdoc @@ -1049,10 +1146,10 @@ importers: version: link:.. react: specifier: 'catalog:' - version: 19.2.3 + version: 19.2.0 react-dom: specifier: 'catalog:' - version: 19.2.4(react@19.2.3) + version: 19.2.0(react@19.2.0) superdoc: specifier: workspace:* version: link:../../superdoc @@ -4685,6 +4782,10 @@ packages: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -8157,6 +8258,11 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + react-dom@19.2.0: + resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==} + peerDependencies: + react: ^19.2.0 + react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -8208,6 +8314,10 @@ packages: '@types/react': optional: true + react@19.2.0: + resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} + engines: {node: '>=0.10.0'} + react@19.2.3: resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} engines: {node: '>=0.10.0'} @@ -8680,6 +8790,9 @@ packages: resolution: {integrity: sha512-iuh+gPf28RkltuJC7W5MRi6XAjTDCAPC/prJUpQoG4vIP3MJZ+GTydVnodXA7pwvTKb2cA0m9OFZW/cdWy/I/w==} engines: {node: '>=6'} + signature_pad@5.1.3: + resolution: {integrity: sha512-zyxW5vuJVnQdGcU+kAj9FYl7WaAunY3kA5S7mPg0xJiujL9+sPAWfSQHS5tXaJXDUa4FuZeKhfdCDQ6K3wfkpQ==} + simple-concat@1.0.1: resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} @@ -12349,12 +12462,12 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 - '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.4(react@19.2.3))(react@19.2.3)': + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@babel/runtime': 7.28.6 '@testing-library/dom': 10.4.1 - react: 19.2.3 - react-dom: 19.2.4(react@19.2.3) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) optionalDependencies: '@types/react': 19.2.9 '@types/react-dom': 19.2.3(@types/react@19.2.9) @@ -14306,6 +14419,8 @@ snapshots: dependencies: is-obj: 2.0.0 + dotenv@16.6.1: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -18805,6 +18920,11 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 + react-dom@19.2.0(react@19.2.0): + dependencies: + react: 19.2.0 + scheduler: 0.27.0 + react-dom@19.2.4(react@19.2.3): dependencies: react: 19.2.3 @@ -18848,6 +18968,8 @@ snapshots: optionalDependencies: '@types/react': 19.2.9 + react@19.2.0: {} + react@19.2.3: {} read-cache@1.0.0: @@ -19628,6 +19750,8 @@ snapshots: figures: 2.0.0 pkg-conf: 2.1.0 + signature_pad@5.1.3: {} + simple-concat@1.0.1: {} simple-eval@1.0.1: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c3663a0bf4..c1cd0fe8c7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -13,8 +13,8 @@ catalog: '@testing-library/user-event': ^14.6.1 '@types/react': ^19.2.6 '@types/react-dom': ^19.2.3 - react: ^19.2.0 - react-dom: ^19.2.0 + react: 19.2.0 + react-dom: 19.2.0 eslint-plugin-react: ^7.37.5 eslint-plugin-react-hooks: ^7.0.1 # Core dependencies From 740f5d2826f06a4555c71cf9faf240fbb422434e Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Fri, 30 Jan 2026 17:34:30 -0300 Subject: [PATCH 2/2] fix: left overs --- packages/esign/src/index.tsx | 2 -- packages/esign/tsconfig.json | 3 +-- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/esign/src/index.tsx b/packages/esign/src/index.tsx index 2234951c89..2ff6505c7b 100644 --- a/packages/esign/src/index.tsx +++ b/packages/esign/src/index.tsx @@ -324,8 +324,6 @@ const SuperDocESign = forwardRef