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 0000000000..a88bb4fc66 Binary files /dev/null and b/packages/esign/demo/public/favicon.png differ 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..2ff6505c7b --- /dev/null +++ b/packages/esign/src/index.tsx @@ -0,0 +1,475 @@ +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); + 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..8a5d6056c0 --- /dev/null +++ b/packages/esign/tsconfig.json @@ -0,0 +1,29 @@ +{ + "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" + ], + "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