From c732c850824977cfbb2c5d3a84beb029497a9172 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Tue, 27 Jan 2026 14:15:04 +1100 Subject: [PATCH 01/32] chore: add manual dispatch to publish workflow and remove chromium builds (#2415) --- .github/workflows/publish.yml | 140 ++++++---------------------------- docker/start.sh | 2 +- 2 files changed, 23 insertions(+), 119 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 962da1ec9..50137d2e1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,6 +3,12 @@ name: Publish Docker on: push: branches: ['release'] + workflow_dispatch: + inputs: + tag: + description: 'Git tag to build and publish (e.g., v1.0.0)' + required: true + type: string jobs: build_and_publish_platform_containers: @@ -18,6 +24,7 @@ jobs: steps: - uses: actions/checkout@v4 with: + ref: ${{ inputs.tag || github.ref }} fetch-tags: true - name: Login to DockerHub @@ -38,8 +45,11 @@ jobs: BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }} NEXT_PRIVATE_TELEMETRY_KEY: ${{ secrets.NEXT_PRIVATE_TELEMETRY_KEY }} NEXT_PRIVATE_TELEMETRY_HOST: ${{ secrets.NEXT_PRIVATE_TELEMETRY_HOST }} + APP_VERSION: ${{ inputs.tag || '' }} run: | - APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" + if [ -z "$APP_VERSION" ]; then + APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" + fi GIT_SHA="$(git rev-parse HEAD)" docker build \ @@ -65,47 +75,6 @@ jobs: env: BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }} - - name: Build the chromium docker image - env: - BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }} - run: | - APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" - GIT_SHA="$(git rev-parse HEAD)" - - docker build \ - -f ./docker/Dockerfile.chromium \ - --progress=plain \ - --build-arg TAG="$GIT_SHA" \ - -t "documenso/documenso-$BUILD_PLATFORM:latest-chromium" \ - -t "documenso/documenso-$BUILD_PLATFORM:$GIT_SHA-chromium" \ - -t "documenso/documenso-$BUILD_PLATFORM:$APP_VERSION-chromium" \ - -t "ghcr.io/documenso/documenso-$BUILD_PLATFORM:latest-chromium" \ - -t "ghcr.io/documenso/documenso-$BUILD_PLATFORM:$GIT_SHA-chromium" \ - -t "ghcr.io/documenso/documenso-$BUILD_PLATFORM:$APP_VERSION-chromium" \ - . - - - name: Push the chromium docker image to DockerHub - env: - BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }} - run: | - APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" - GIT_SHA="$(git rev-parse HEAD)" - - docker push "documenso/documenso-$BUILD_PLATFORM:latest-chromium" - docker push "documenso/documenso-$BUILD_PLATFORM:$GIT_SHA-chromium" - docker push "documenso/documenso-$BUILD_PLATFORM:$APP_VERSION-chromium" \ - - - name: Push the chromium docker image to GitHub Container Registry - env: - BUILD_PLATFORM: ${{ matrix.os == 'warp-ubuntu-latest-arm64-4x' && 'arm64' || 'amd64' }} - run: | - APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" - GIT_SHA="$(git rev-parse HEAD)" - - docker push "ghcr.io/documenso/documenso-$BUILD_PLATFORM:latest-chromium" - docker push "ghcr.io/documenso/documenso-$BUILD_PLATFORM:$GIT_SHA-chromium" - docker push "ghcr.io/documenso/documenso-$BUILD_PLATFORM:$APP_VERSION-chromium" - create_and_publish_manifest: name: Create and publish manifest runs-on: ubuntu-latest @@ -114,6 +83,7 @@ jobs: - name: Checkout uses: actions/checkout@v4 with: + ref: ${{ inputs.tag || github.ref }} fetch-tags: true - name: Login to DockerHub @@ -130,8 +100,12 @@ jobs: password: ${{ secrets.GH_TOKEN }} - name: Create and push DockerHub manifest + env: + APP_VERSION: ${{ inputs.tag || '' }} run: | - APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" + if [ -z "$APP_VERSION" ]; then + APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" + fi GIT_SHA="$(git rev-parse HEAD)" # Check if the version is stable (no rc or beta in the version) @@ -166,46 +140,13 @@ jobs: docker manifest push documenso/documenso:$GIT_SHA docker manifest push documenso/documenso:$APP_VERSION - - name: Create and push DockerHub chromium manifest - run: | - APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" - GIT_SHA="$(git rev-parse HEAD)" - - # Check if the version is stable (no rc or beta in the version) - if [[ "$APP_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - docker manifest create \ - documenso/documenso:latest-chromium \ - --amend documenso/documenso-amd64:latest-chromium \ - --amend documenso/documenso-arm64:latest-chromium - - docker manifest push documenso/documenso:latest-chromium - fi - - if [[ "$APP_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then - docker manifest create \ - documenso/documenso:rc-chromium \ - --amend documenso/documenso-amd64:rc-chromium \ - --amend documenso/documenso-arm64:rc-chromium - - docker manifest push documenso/documenso:rc-chromium - fi - - docker manifest create \ - documenso/documenso:$GIT_SHA-chromium \ - --amend documenso/documenso-amd64:$GIT_SHA-chromium \ - --amend documenso/documenso-arm64:$GIT_SHA-chromium - - docker manifest create \ - documenso/documenso:$APP_VERSION-chromium \ - --amend documenso/documenso-amd64:$APP_VERSION-chromium \ - --amend documenso/documenso-arm64:$APP_VERSION-chromium - - docker manifest push documenso/documenso:$GIT_SHA-chromium - docker manifest push documenso/documenso:$APP_VERSION-chromium - - name: Create and push Github Container Registry manifest + env: + APP_VERSION: ${{ inputs.tag || '' }} run: | - APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" + if [ -z "$APP_VERSION" ]; then + APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" + fi GIT_SHA="$(git rev-parse HEAD)" # Check if the version is stable (no rc or beta in the version) @@ -239,40 +180,3 @@ jobs: docker manifest push ghcr.io/documenso/documenso:$GIT_SHA docker manifest push ghcr.io/documenso/documenso:$APP_VERSION - - - name: Create and push Github Container Registry chromium manifest - run: | - APP_VERSION="$(git name-rev --tags --name-only $(git rev-parse HEAD) | head -n 1 | sed 's/\^0//')" - GIT_SHA="$(git rev-parse HEAD)" - - # Check if the version is stable (no rc or beta in the version) - if [[ "$APP_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - docker manifest create \ - ghcr.io/documenso/documenso:latest-chromium \ - --amend ghcr.io/documenso/documenso-amd64:latest-chromium \ - --amend ghcr.io/documenso/documenso-arm64:latest-chromium - - docker manifest push ghcr.io/documenso/documenso:latest-chromium - fi - - if [[ "$APP_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then - docker manifest create \ - ghcr.io/documenso/documenso:rc-chromium \ - --amend ghcr.io/documenso/documenso-amd64:rc-chromium \ - --amend ghcr.io/documenso/documenso-arm64:rc-chromium - - docker manifest push ghcr.io/documenso/documenso:rc-chromium - fi - - docker manifest create \ - ghcr.io/documenso/documenso:$GIT_SHA-chromium \ - --amend ghcr.io/documenso/documenso-amd64:$GIT_SHA-chromium \ - --amend ghcr.io/documenso/documenso-arm64:$GIT_SHA-chromium - - docker manifest create \ - ghcr.io/documenso/documenso:$APP_VERSION-chromium \ - --amend ghcr.io/documenso/documenso-amd64:$APP_VERSION-chromium \ - --amend ghcr.io/documenso/documenso-arm64:$APP_VERSION-chromium - - docker manifest push ghcr.io/documenso/documenso:$GIT_SHA-chromium - docker manifest push ghcr.io/documenso/documenso:$APP_VERSION-chromium diff --git a/docker/start.sh b/docker/start.sh index 56b225e14..2598eae83 100755 --- a/docker/start.sh +++ b/docker/start.sh @@ -11,7 +11,7 @@ CERT_PATH="${NEXT_PRIVATE_SIGNING_LOCAL_FILE_PATH:-/opt/documenso/cert.p12}" if [ -f "$CERT_PATH" ] && [ -r "$CERT_PATH" ]; then printf "✅ Certificate file found and readable - document signing is ready!\n" else - printf "⚠️ Certificate not found or not readable\n" + printf "⚠️ Certificate not found or not readable\n" printf "💡 Tip: Documenso will still start, but document signing will be unavailable\n" printf "🔧 Check: http://localhost:3000/api/certificate-status for detailed status\n" fi From 17b261df1f25f2fed585be6bdbad1f390648a4ab Mon Sep 17 00:00:00 2001 From: Jorge Ramirez Date: Mon, 26 Jan 2026 20:00:37 -0800 Subject: [PATCH 02/32] fix(api): add take parameter to template search query for pagination (#2396) This PR fixes a bug in the `/api/v2/template` endpoint where the pagination parameter `perPage` was being ignored. Previously, the endpoint would return all matching templates regardless of the requested limit, which could lead to performance issues and incorrect API behavior. --- packages/lib/server-only/template/find-templates.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/lib/server-only/template/find-templates.ts b/packages/lib/server-only/template/find-templates.ts index 3a46dd4b4..621459255 100644 --- a/packages/lib/server-only/template/find-templates.ts +++ b/packages/lib/server-only/template/find-templates.ts @@ -79,6 +79,7 @@ export const findTemplates = async ({ }, }, skip: Math.max(page - 1, 0) * perPage, + take: perPage, orderBy: { createdAt: 'desc', }, From 7fc6f5bb6e79b5c41055349e140536c090a3f69d Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Tue, 27 Jan 2026 15:00:53 +1100 Subject: [PATCH 03/32] fix: make teamId optional in support form validation (#2417) The contact form accepts teamId as an optional param based on where the user clicks "Support" from. Previously, when opened from a non-team context, the null teamId would be parsed to NaN and fail validation, causing the form to error out. Now the validation only runs when a teamId is actually provided. --- packages/trpc/server/profile-router/router.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/trpc/server/profile-router/router.ts b/packages/trpc/server/profile-router/router.ts index 3f903eb49..eca57177d 100644 --- a/packages/trpc/server/profile-router/router.ts +++ b/packages/trpc/server/profile-router/router.ts @@ -90,10 +90,12 @@ export const profileRouter = router({ const parsedTeamId = teamId ? Number(teamId) : null; - if (Number.isNaN(parsedTeamId)) { - throw new AppError(AppErrorCode.INVALID_BODY, { - message: 'Invalid team ID provided', - }); + if (typeof parsedTeamId === 'number') { + if (Number.isNaN(parsedTeamId) || parsedTeamId <= 0) { + throw new AppError(AppErrorCode.INVALID_BODY, { + message: 'Invalid team ID provided', + }); + } } return await submitSupportTicket({ From 6028ad915841d5040b8d9ccc83e3d99364458f4d Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Tue, 27 Jan 2026 15:44:01 +1100 Subject: [PATCH 04/32] chore: add translations (#2412) Co-authored-by: Crowdin Bot --- packages/lib/translations/de/web.po | 13 +++++++------ packages/lib/translations/es/web.po | 13 +++++++------ packages/lib/translations/fr/web.po | 13 +++++++------ packages/lib/translations/it/web.po | 13 +++++++------ packages/lib/translations/ja/web.po | 13 +++++++------ packages/lib/translations/ko/web.po | 13 +++++++------ packages/lib/translations/nl/web.po | 13 +++++++------ packages/lib/translations/pl/web.po | 23 ++++++++++++----------- packages/lib/translations/zh/web.po | 13 +++++++------ 9 files changed, 68 insertions(+), 59 deletions(-) diff --git a/packages/lib/translations/de/web.po b/packages/lib/translations/de/web.po index ccb8924d4..a648f545d 100644 --- a/packages/lib/translations/de/web.po +++ b/packages/lib/translations/de/web.po @@ -8,7 +8,7 @@ msgstr "" "Language: de\n" "Project-Id-Version: documenso-app\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-01-13 02:39\n" +"PO-Revision-Date: 2026-01-13 10:04\n" "Last-Translator: \n" "Language-Team: German\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" @@ -1951,7 +1951,7 @@ msgstr "Audit-Protokoll" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "Audit Log Details" -msgstr "" +msgstr "Audit-Log-Details" #: apps/remix/app/components/general/document/document-page-view-dropdown.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx @@ -3115,7 +3115,7 @@ msgstr "Standardorganisation Rolle für neue Benutzer" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Recipients" -msgstr "" +msgstr "Standardempfänger" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Signature Settings" @@ -7136,7 +7136,7 @@ msgstr "Empfängermetriken" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Recipients that will be automatically added to new documents." -msgstr "" +msgstr "Empfänger, die neuen Dokumenten automatisch hinzugefügt werden." #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx msgid "Recipients will be able to sign the document once sent" @@ -7758,7 +7758,7 @@ msgstr "Passkey auswählen" #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx msgid "Select recipients" -msgstr "" +msgstr "Empfänger auswählen" #: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx @@ -10396,7 +10396,7 @@ msgstr "Einladungen ansehen" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "View JSON" -msgstr "" +msgstr "JSON anzeigen" #: apps/remix/app/components/general/template/template-page-view-recent-activity.tsx msgid "View more" @@ -11754,3 +11754,4 @@ msgstr "Ihr Verifizierungscode:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com" + diff --git a/packages/lib/translations/es/web.po b/packages/lib/translations/es/web.po index 8665b3bcb..3921a7ce0 100644 --- a/packages/lib/translations/es/web.po +++ b/packages/lib/translations/es/web.po @@ -8,7 +8,7 @@ msgstr "" "Language: es\n" "Project-Id-Version: documenso-app\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-01-13 02:39\n" +"PO-Revision-Date: 2026-01-13 10:04\n" "Last-Translator: \n" "Language-Team: Spanish\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" @@ -1951,7 +1951,7 @@ msgstr "Registro de Auditoría" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "Audit Log Details" -msgstr "" +msgstr "Detalles del registro de auditoría" #: apps/remix/app/components/general/document/document-page-view-dropdown.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx @@ -3115,7 +3115,7 @@ msgstr "Rol de organización predeterminado para nuevos usuarios" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Recipients" -msgstr "" +msgstr "Destinatarios predeterminados" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Signature Settings" @@ -7136,7 +7136,7 @@ msgstr "Métricas de destinatarios" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Recipients that will be automatically added to new documents." -msgstr "" +msgstr "Destinatarios que se agregarán automáticamente a los nuevos documentos." #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx msgid "Recipients will be able to sign the document once sent" @@ -7758,7 +7758,7 @@ msgstr "Seleccionar clave de acceso" #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx msgid "Select recipients" -msgstr "" +msgstr "Seleccionar destinatarios" #: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx @@ -10396,7 +10396,7 @@ msgstr "Ver invitaciones" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "View JSON" -msgstr "" +msgstr "Ver JSON" #: apps/remix/app/components/general/template/template-page-view-recent-activity.tsx msgid "View more" @@ -11754,3 +11754,4 @@ msgstr "Su código de verificación:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "su-dominio.com otro-dominio.com" + diff --git a/packages/lib/translations/fr/web.po b/packages/lib/translations/fr/web.po index 81e677037..7bec53f8f 100644 --- a/packages/lib/translations/fr/web.po +++ b/packages/lib/translations/fr/web.po @@ -8,7 +8,7 @@ msgstr "" "Language: fr\n" "Project-Id-Version: documenso-app\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-01-13 02:39\n" +"PO-Revision-Date: 2026-01-13 10:04\n" "Last-Translator: \n" "Language-Team: French\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" @@ -1951,7 +1951,7 @@ msgstr "Journal d'audit" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "Audit Log Details" -msgstr "" +msgstr "Détails du journal d’audit" #: apps/remix/app/components/general/document/document-page-view-dropdown.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx @@ -3115,7 +3115,7 @@ msgstr "Rôle par défaut de l'organisation pour les nouveaux utilisateurs" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Recipients" -msgstr "" +msgstr "Destinataires par défaut" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Signature Settings" @@ -7136,7 +7136,7 @@ msgstr "Métriques des destinataires" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Recipients that will be automatically added to new documents." -msgstr "" +msgstr "Les destinataires qui seront automatiquement ajoutés aux nouveaux documents." #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx msgid "Recipients will be able to sign the document once sent" @@ -7758,7 +7758,7 @@ msgstr "Sélectionner la clé d'authentification" #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx msgid "Select recipients" -msgstr "" +msgstr "Sélectionner des destinataires" #: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx @@ -10396,7 +10396,7 @@ msgstr "Voir les invitations" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "View JSON" -msgstr "" +msgstr "Afficher le JSON" #: apps/remix/app/components/general/template/template-page-view-recent-activity.tsx msgid "View more" @@ -11754,3 +11754,4 @@ msgstr "Votre code de vérification :" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com" + diff --git a/packages/lib/translations/it/web.po b/packages/lib/translations/it/web.po index f3de8fe23..6667b91ec 100644 --- a/packages/lib/translations/it/web.po +++ b/packages/lib/translations/it/web.po @@ -8,7 +8,7 @@ msgstr "" "Language: it\n" "Project-Id-Version: documenso-app\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-01-13 02:39\n" +"PO-Revision-Date: 2026-01-13 10:04\n" "Last-Translator: \n" "Language-Team: Italian\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" @@ -1951,7 +1951,7 @@ msgstr "Registro di controllo" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "Audit Log Details" -msgstr "" +msgstr "Dettagli registro di controllo" #: apps/remix/app/components/general/document/document-page-view-dropdown.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx @@ -3115,7 +3115,7 @@ msgstr "Ruolo dell'organizzazione predefinito per nuovi utenti" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Recipients" -msgstr "" +msgstr "Destinatari predefiniti" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Signature Settings" @@ -7136,7 +7136,7 @@ msgstr "Metriche dei destinatari" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Recipients that will be automatically added to new documents." -msgstr "" +msgstr "Destinatari che verranno aggiunti automaticamente ai nuovi documenti." #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx msgid "Recipients will be able to sign the document once sent" @@ -7758,7 +7758,7 @@ msgstr "Seleziona una chiave di accesso" #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx msgid "Select recipients" -msgstr "" +msgstr "Seleziona destinatari" #: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx @@ -10396,7 +10396,7 @@ msgstr "Visualizza inviti" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "View JSON" -msgstr "" +msgstr "Visualizza JSON" #: apps/remix/app/components/general/template/template-page-view-recent-activity.tsx msgid "View more" @@ -11754,3 +11754,4 @@ msgstr "Il tuo codice di verifica:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "tuo-dominio.com altro-dominio.com" + diff --git a/packages/lib/translations/ja/web.po b/packages/lib/translations/ja/web.po index 7f20c1272..c945e3278 100644 --- a/packages/lib/translations/ja/web.po +++ b/packages/lib/translations/ja/web.po @@ -8,7 +8,7 @@ msgstr "" "Language: ja\n" "Project-Id-Version: documenso-app\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-01-13 02:39\n" +"PO-Revision-Date: 2026-01-13 10:04\n" "Last-Translator: \n" "Language-Team: Japanese\n" "Plural-Forms: nplurals=1; plural=0;\n" @@ -1951,7 +1951,7 @@ msgstr "監査ログ" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "Audit Log Details" -msgstr "" +msgstr "監査ログの詳細" #: apps/remix/app/components/general/document/document-page-view-dropdown.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx @@ -3115,7 +3115,7 @@ msgstr "新規ユーザーの既定の組織ロール" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Recipients" -msgstr "" +msgstr "デフォルトの受信者" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Signature Settings" @@ -7136,7 +7136,7 @@ msgstr "受信者は文書のコピーを保持したままです" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Recipients that will be automatically added to new documents." -msgstr "" +msgstr "新しいドキュメントに自動的に追加される受信者です。" #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx msgid "Recipients will be able to sign the document once sent" @@ -7758,7 +7758,7 @@ msgstr "パスキーを選択" #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx msgid "Select recipients" -msgstr "" +msgstr "受信者を選択" #: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx @@ -10396,7 +10396,7 @@ msgstr "招待を表示" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "View JSON" -msgstr "" +msgstr "JSON を表示" #: apps/remix/app/components/general/template/template-page-view-recent-activity.tsx msgid "View more" @@ -11754,3 +11754,4 @@ msgstr "認証コード:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com" + diff --git a/packages/lib/translations/ko/web.po b/packages/lib/translations/ko/web.po index 71aa6f62d..824138161 100644 --- a/packages/lib/translations/ko/web.po +++ b/packages/lib/translations/ko/web.po @@ -8,7 +8,7 @@ msgstr "" "Language: ko\n" "Project-Id-Version: documenso-app\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-01-13 02:39\n" +"PO-Revision-Date: 2026-01-13 10:04\n" "Last-Translator: \n" "Language-Team: Korean\n" "Plural-Forms: nplurals=1; plural=0;\n" @@ -1951,7 +1951,7 @@ msgstr "감사 로그" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "Audit Log Details" -msgstr "" +msgstr "감사 로그 세부 정보" #: apps/remix/app/components/general/document/document-page-view-dropdown.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx @@ -3115,7 +3115,7 @@ msgstr "새 사용자 기본 조직 역할" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Recipients" -msgstr "" +msgstr "기본 수신인들" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Signature Settings" @@ -7136,7 +7136,7 @@ msgstr "수신자 지표" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Recipients that will be automatically added to new documents." -msgstr "" +msgstr "새 문서에 자동으로 추가될 수신인들입니다." #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx msgid "Recipients will be able to sign the document once sent" @@ -7758,7 +7758,7 @@ msgstr "패스키 선택" #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx msgid "Select recipients" -msgstr "" +msgstr "수신인 선택" #: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx @@ -10396,7 +10396,7 @@ msgstr "초대 보기" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "View JSON" -msgstr "" +msgstr "JSON 보기" #: apps/remix/app/components/general/template/template-page-view-recent-activity.tsx msgid "View more" @@ -11754,3 +11754,4 @@ msgstr "인증 코드:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com" + diff --git a/packages/lib/translations/nl/web.po b/packages/lib/translations/nl/web.po index 23dfb42b0..f546004a5 100644 --- a/packages/lib/translations/nl/web.po +++ b/packages/lib/translations/nl/web.po @@ -8,7 +8,7 @@ msgstr "" "Language: nl\n" "Project-Id-Version: documenso-app\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-01-13 02:39\n" +"PO-Revision-Date: 2026-01-13 10:04\n" "Last-Translator: \n" "Language-Team: Dutch\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" @@ -1951,7 +1951,7 @@ msgstr "Auditlog" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "Audit Log Details" -msgstr "" +msgstr "Details van auditlogboek" #: apps/remix/app/components/general/document/document-page-view-dropdown.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx @@ -3115,7 +3115,7 @@ msgstr "Standaard organisatierol voor nieuwe gebruikers" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Recipients" -msgstr "" +msgstr "Standaardontvangers" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Signature Settings" @@ -7136,7 +7136,7 @@ msgstr "Ontvangerstatistieken" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Recipients that will be automatically added to new documents." -msgstr "" +msgstr "Ontvangers die automatisch aan nieuwe documenten worden toegevoegd." #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx msgid "Recipients will be able to sign the document once sent" @@ -7758,7 +7758,7 @@ msgstr "Passkey selecteren" #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx msgid "Select recipients" -msgstr "" +msgstr "Ontvangers selecteren" #: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx @@ -10396,7 +10396,7 @@ msgstr "Uitnodigingen bekijken" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "View JSON" -msgstr "" +msgstr "JSON bekijken" #: apps/remix/app/components/general/template/template-page-view-recent-activity.tsx msgid "View more" @@ -11754,3 +11754,4 @@ msgstr "Uw verificatiecode:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com" + diff --git a/packages/lib/translations/pl/web.po b/packages/lib/translations/pl/web.po index f5fb94836..caa11a5ca 100644 --- a/packages/lib/translations/pl/web.po +++ b/packages/lib/translations/pl/web.po @@ -8,7 +8,7 @@ msgstr "" "Language: pl\n" "Project-Id-Version: documenso-app\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-01-13 02:39\n" +"PO-Revision-Date: 2026-01-15 04:18\n" "Last-Translator: \n" "Language-Team: Polish\n" "Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n" @@ -1951,7 +1951,7 @@ msgstr "Dziennik logów" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "Audit Log Details" -msgstr "" +msgstr "Szczegóły dziennika logu" #: apps/remix/app/components/general/document/document-page-view-dropdown.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx @@ -3115,7 +3115,7 @@ msgstr "Domyślna rola w organizacji dla nowych użytkowników" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Recipients" -msgstr "" +msgstr "Domyślni odbiorcy" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Signature Settings" @@ -4343,7 +4343,7 @@ msgstr "Załączniki" #: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-text-dialog.tsx msgid "Enter" -msgstr "Wpisz lub wprowadź wartość" +msgstr "Wpisz" #: apps/remix/app/components/dialogs/folder-create-dialog.tsx msgid "Enter a name for your new folder. Folders help you organise your items." @@ -4363,7 +4363,7 @@ msgstr "Wpisz inicjały" #: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx msgid "Enter Name" -msgstr "Wpisz imię i nazwisko" +msgstr "Wpisz nazwę" #: apps/remix/app/components/dialogs/sign-field-number-dialog.tsx msgid "Enter Number" @@ -6761,15 +6761,15 @@ msgstr "Wpisz wartość" #: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx msgid "Please enter your email address" -msgstr "Wpisz swój adres e-mail" +msgstr "Wpisz adres e-mail" #: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx msgid "Please enter your full name" -msgstr "Wpisz swoje pełne imię i nazwisko" +msgstr "Wpisz nazwę" #: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx msgid "Please enter your initials" -msgstr "Wprowadź swoje inicjały" +msgstr "Wpisz inicjały" #: apps/remix/app/components/general/document-signing/document-signing-mobile-widget.tsx msgid "Please mark as viewed to complete" @@ -7136,7 +7136,7 @@ msgstr "Metryki odbiorców" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Recipients that will be automatically added to new documents." -msgstr "" +msgstr "Odbiorcy, którzy będą automatycznie dodawani do nowych dokumentów." #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx msgid "Recipients will be able to sign the document once sent" @@ -7758,7 +7758,7 @@ msgstr "Wybierz klucz dostępu" #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx msgid "Select recipients" -msgstr "" +msgstr "Wybierz odbiorców" #: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx @@ -10396,7 +10396,7 @@ msgstr "Wyświetl zaproszenia" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "View JSON" -msgstr "" +msgstr "Zobacz JSON" #: apps/remix/app/components/general/template/template-page-view-recent-activity.tsx msgid "View more" @@ -11754,3 +11754,4 @@ msgstr "Twój kod weryfikacyjny:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com" + diff --git a/packages/lib/translations/zh/web.po b/packages/lib/translations/zh/web.po index 39a82f678..46ebe3869 100644 --- a/packages/lib/translations/zh/web.po +++ b/packages/lib/translations/zh/web.po @@ -8,7 +8,7 @@ msgstr "" "Language: zh\n" "Project-Id-Version: documenso-app\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-01-13 02:39\n" +"PO-Revision-Date: 2026-01-13 10:04\n" "Last-Translator: \n" "Language-Team: Chinese Simplified\n" "Plural-Forms: nplurals=1; plural=0;\n" @@ -1951,7 +1951,7 @@ msgstr "审计日志" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "Audit Log Details" -msgstr "" +msgstr "审计日志详情" #: apps/remix/app/components/general/document/document-page-view-dropdown.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx @@ -3115,7 +3115,7 @@ msgstr "新用户的默认组织角色" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Recipients" -msgstr "" +msgstr "默认收件人" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Default Signature Settings" @@ -7136,7 +7136,7 @@ msgstr "收件人仍将保留其文档副本" #: apps/remix/app/components/forms/document-preferences-form.tsx msgid "Recipients that will be automatically added to new documents." -msgstr "" +msgstr "将自动添加到新文档中的收件人。" #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx msgid "Recipients will be able to sign the document once sent" @@ -7758,7 +7758,7 @@ msgstr "选择通行密钥" #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx msgid "Select recipients" -msgstr "" +msgstr "选择收件人" #: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx @@ -10396,7 +10396,7 @@ msgstr "查看邀请" #: apps/remix/app/components/tables/admin-document-logs-table.tsx msgid "View JSON" -msgstr "" +msgstr "查看 JSON" #: apps/remix/app/components/general/template/template-page-view-recent-activity.tsx msgid "View more" @@ -11754,3 +11754,4 @@ msgstr "您的验证码:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com" + From 9c6ee88cc412ae1232179098b05189481e8a2c11 Mon Sep 17 00:00:00 2001 From: Ted Liang Date: Tue, 27 Jan 2026 15:52:34 +1100 Subject: [PATCH 05/32] fix: security CVE-2026-23527 (#2399) --- package-lock.json | 251 ++++++++++++----------------------- package.json | 5 +- packages/prisma/package.json | 4 +- packages/trpc/package.json | 2 +- 4 files changed, 91 insertions(+), 171 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5ad9d9bd6..111bdccca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,6 @@ "@lingui/conf": "^5.6.0", "@lingui/core": "^5.6.0", "ai": "^5.0.104", - "inngest-cli": "^1.13.7", "luxon": "^3.7.2", "patch-package": "^8.0.1", "posthog-node": "4.18.0", @@ -42,6 +41,7 @@ "dotenv-cli": "^11.0.0", "eslint": "^8.57.0", "husky": "^9.1.7", + "inngest-cli": "^1.16.1", "lint-staged": "^16.2.7", "nanoid": "^5.1.6", "nodemailer": "^7.0.10", @@ -53,11 +53,10 @@ "prisma": "^6.19.0", "prisma-extension-kysely": "^3.0.0", "prisma-json-types-generator": "^3.6.2", - "prisma-kysely": "^2.2.1", + "prisma-kysely": "^2.3.0", "rimraf": "^6.1.2", "superjson": "^2.2.5", "syncpack": "^14.0.0-alpha.27", - "trpc-to-openapi": "2.4.0", "turbo": "^1.13.4", "vite": "^7.2.4", "vite-plugin-static-copy": "^3.1.4", @@ -4026,6 +4025,19 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -17745,6 +17757,7 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0" @@ -18988,12 +19001,13 @@ } }, "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "license": "ISC", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=10" + "node": ">=18" } }, "node_modules/ci-info": { @@ -23886,36 +23900,6 @@ "node": ">=14.14" } }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fs-minipass/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -24650,19 +24634,19 @@ } }, "node_modules/h3": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.1.tgz", - "integrity": "sha512-+ORaOBttdUm1E2Uu/obAyCguiI7MbBvsLTndc3gyK3zU+SYLoZXlyCP9Xgy0gikkGufFLTZXCXD6+4BsufnmHA==", + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.5.tgz", + "integrity": "sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg==", "license": "MIT", "dependencies": { "cookie-es": "^1.2.2", - "crossws": "^0.3.3", + "crossws": "^0.3.5", "defu": "^6.1.4", - "destr": "^2.0.3", + "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", - "node-mock-http": "^1.0.0", + "node-mock-http": "^1.0.4", "radix3": "^1.1.2", - "ufo": "^1.5.4", + "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, @@ -25494,16 +25478,17 @@ } }, "node_modules/inngest-cli": { - "version": "1.13.7", - "resolved": "https://registry.npmjs.org/inngest-cli/-/inngest-cli-1.13.7.tgz", - "integrity": "sha512-u3gbUsjsTHfh5quKE3C3sw/NENFPjiWG602SNZya0Eto44U9KGVT0nHp9UoZF5qiG7X83fJqqNEjDKN4FB9JaQ==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/inngest-cli/-/inngest-cli-1.16.1.tgz", + "integrity": "sha512-B5Z2vgidh6HM7HVsQFZSffJpnTKB0Rm20NDL1qOjBtF8ZZgun+mJNjUYAcqOU4l8SVVEG0nfPnphsWPLZHXdsA==", + "dev": true, "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "adm-zip": "^0.5.10", "debug": "^4.3.4", "node-fetch": "^2.6.7", - "tar": "^6.2.1" + "tar": "^7.5.3" }, "bin": { "inngest": "bin/inngest", @@ -27877,9 +27862,9 @@ } }, "node_modules/mermaid": { - "version": "11.12.1", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.1.tgz", - "integrity": "sha512-UlIZrRariB11TY1RtTgUWp65tphtBv4CSq7vyS2ZZ2TgoMjs2nloq+wFqxiwcxlhHUvs7DPGgMjs2aeQxz5h9g==", + "version": "11.12.2", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.2.tgz", + "integrity": "sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==", "license": "MIT", "dependencies": { "@braintree/sanitize-url": "^7.1.1", @@ -28800,54 +28785,24 @@ } }, "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, "license": "MIT", "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" + "minipass": "^7.1.2" }, "engines": { - "node": ">= 8" + "node": ">= 18" } }, - "node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - }, "node_modules/mj-context-menu": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/mj-context-menu/-/mj-context-menu-0.6.1.tgz", "integrity": "sha512-7NO5s6n10TIV96d4g2uDpG7ZDpIhMh0QNfGdJw/W47JswFcosz457wqz/b5sAKvl12sxINGFCn80NZHKwxQEXA==", "license": "Apache-2.0" }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/mlly": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", @@ -29478,9 +29433,9 @@ } }, "node_modules/node-mock-http": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.3.tgz", - "integrity": "sha512-jN8dK25fsfnMrVsEhluUTPkBFY+6ybu7jSB1n+ri/vOGjJxU8J9CZhpSGkHXSkFjtUhbmoncG/YG9ta5Ludqog==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.4.tgz", + "integrity": "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==", "license": "MIT" }, "node_modules/node-releases": { @@ -31389,51 +31344,22 @@ } }, "node_modules/prisma-kysely": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/prisma-kysely/-/prisma-kysely-2.2.1.tgz", - "integrity": "sha512-hxue1/ZzyBJ8qx6bSBNUjF+A+tl78PM5Sg/wIAIph9hxwdiGcWTr8JPQveva4zvA57bwRJhAJjDvdy8MK4UNuA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/prisma-kysely/-/prisma-kysely-2.3.0.tgz", + "integrity": "sha512-/+VF2t2DlY+t/27hhyH5ULWxBAAsMBPgOo8Ltq7uXpBz49lUf4XuO1ff9HV0l7J3aDojG+YyczEvY0og9uRqsw==", "license": "MIT", "dependencies": { - "@mrleebo/prisma-ast": "^0.13.0", - "@prisma/generator-helper": "6.16.2", - "@prisma/internals": "6.16.2", - "typescript": "^5.9.2", - "zod": "^4.1.5" + "@mrleebo/prisma-ast": "^0.13.1", + "@prisma/generator-helper": "^6.2.0", + "@prisma/internals": "^6.2.0", + "typescript": "^5.9.3", + "zod": "^4.3.5" }, "bin": { "prisma-kysely": "dist/bin.js" }, "peerDependencies": { - "prisma": "~6.16" - } - }, - "node_modules/prisma-kysely/node_modules/@prisma/debug": { - "version": "6.16.2", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.16.2.tgz", - "integrity": "sha512-bo4/gA/HVV6u8YK2uY6glhNsJ7r+k/i5iQ9ny/3q5bt9ijCj7WMPUwfTKPvtEgLP+/r26Z686ly11hhcLiQ8zA==", - "license": "Apache-2.0" - }, - "node_modules/prisma-kysely/node_modules/@prisma/dmmf": { - "version": "6.16.2", - "resolved": "https://registry.npmjs.org/@prisma/dmmf/-/dmmf-6.16.2.tgz", - "integrity": "sha512-o9ztgdbj2KZXl6DL+oP56TTC0poTLPns9/MeU761b49E1IQ/fd0jwdov1bidlNOiwio8Nsou23xNrYE/db10aA==", - "license": "Apache-2.0" - }, - "node_modules/prisma-kysely/node_modules/@prisma/generator": { - "version": "6.16.2", - "resolved": "https://registry.npmjs.org/@prisma/generator/-/generator-6.16.2.tgz", - "integrity": "sha512-7bwRmtMIgfe1rUynh1p9VlmYvEiidbRO6aBphPBS6YGEGSvNe8+QExbRpsqFlFBvIX76BhZCxuEj7ZwALMYRKQ==", - "license": "Apache-2.0" - }, - "node_modules/prisma-kysely/node_modules/@prisma/generator-helper": { - "version": "6.16.2", - "resolved": "https://registry.npmjs.org/@prisma/generator-helper/-/generator-helper-6.16.2.tgz", - "integrity": "sha512-8tVnWM8ETJNrvI5CT9eKCW23+aPLNkidC+g9NJn7ghXm60Q7GGlLX5tmvn5dE8tXvs/FSX3MN7KNmNJpOr89Hw==", - "license": "Apache-2.0", - "dependencies": { - "@prisma/debug": "6.16.2", - "@prisma/dmmf": "6.16.2", - "@prisma/generator": "6.16.2" + "prisma": ">=6.2.0 <7.0.0" } }, "node_modules/prismjs": { @@ -34964,36 +34890,31 @@ } }, "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "license": "ISC", + "version": "7.5.6", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.6.tgz", + "integrity": "sha512-xqUeu2JAIJpXyvskvU3uvQW8PAmHrtXp2KDuMJwQqW8Sqq0CaZBAQ+dKS3RBXVhU4wC5NjAdKrmh84241gO9cA==", + "dev": true, + "license": "BlueOak-1.0.0", "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" }, "engines": { - "node": ">=10" - } - }, - "node_modules/tar/node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "license": "ISC", - "engines": { - "node": ">=8" + "node": ">=18" } }, "node_modules/tar/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } }, "node_modules/teeny-request": { "version": "10.1.0", @@ -35270,9 +35191,9 @@ } }, "node_modules/trpc-to-openapi": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/trpc-to-openapi/-/trpc-to-openapi-2.4.0.tgz", - "integrity": "sha512-B6xrwOC3Ab0q1BWD/QbJzK4OUpCLoT02hAzshSUXEuIZGcJZkMG/OJ4/3gd20dyr8aI+CrFirpWKRIo7JmHbMQ==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/trpc-to-openapi/-/trpc-to-openapi-2.1.5.tgz", + "integrity": "sha512-cX6AlrIBEK0FJe8XJ0wFCroXFqTRpF3J5v4vU5D0Tq7YI1vj8oOIY50iaJX4Gv1PZTA6Dm9DMF7R1MUtlPZ4dQ==", "license": "MIT", "workspaces": [ ".", @@ -35285,17 +35206,17 @@ "examples/with-nuxtjs" ], "dependencies": { - "co-body": "6.2.0", - "h3": "1.15.1", + "co-body": "^6.1.0", + "h3": "^1.6.4", "openapi3-ts": "4.4.0" }, "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "4.6.1" }, "peerDependencies": { - "@trpc/server": "^11.1.0", + "@trpc/server": "^11.0.0-rc.648", "zod": "^3.23.8", - "zod-openapi": "4.2.4" + "zod-openapi": "^4.1.0" } }, "node_modules/trpc-to-openapi/node_modules/@rollup/rollup-linux-x64-gnu": { @@ -35729,9 +35650,9 @@ } }, "node_modules/ufo": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", - "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", "license": "MIT" }, "node_modules/uint8array-extras": { @@ -37549,7 +37470,7 @@ "prisma": "^6.19.0", "prisma-extension-kysely": "^3.0.0", "prisma-json-types-generator": "^3.6.2", - "prisma-kysely": "^2.2.1", + "prisma-kysely": "^2.3.0", "ts-pattern": "^5.9.0", "zod": "^3.25.76", "zod-prisma-types": "3.3.5" @@ -37604,7 +37525,7 @@ "formidable": "^3.5.4", "luxon": "^3.7.2", "superjson": "^2.2.5", - "trpc-to-openapi": "2.4.0", + "trpc-to-openapi": "^2.1.5", "ts-pattern": "^5.9.0", "zod": "^3.25.76", "zod-form-data": "^2.0.8", diff --git a/package.json b/package.json index fc6635969..748b8f7a6 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "dotenv-cli": "^11.0.0", "eslint": "^8.57.0", "husky": "^9.1.7", + "inngest-cli": "^1.16.1", "lint-staged": "^16.2.7", "nanoid": "^5.1.6", "nodemailer": "^7.0.10", @@ -72,11 +73,10 @@ "prisma": "^6.19.0", "prisma-extension-kysely": "^3.0.0", "prisma-json-types-generator": "^3.6.2", - "prisma-kysely": "^2.2.1", + "prisma-kysely": "^2.3.0", "rimraf": "^6.1.2", "superjson": "^2.2.5", "syncpack": "^14.0.0-alpha.27", - "trpc-to-openapi": "2.4.0", "turbo": "^1.13.4", "vite": "^7.2.4", "vite-plugin-static-copy": "^3.1.4", @@ -90,7 +90,6 @@ "@lingui/conf": "^5.6.0", "@lingui/core": "^5.6.0", "ai": "^5.0.104", - "inngest-cli": "^1.13.7", "luxon": "^3.7.2", "patch-package": "^8.0.1", "posthog-node": "4.18.0", diff --git a/packages/prisma/package.json b/packages/prisma/package.json index 8e71040ab..c4b91c8bb 100644 --- a/packages/prisma/package.json +++ b/packages/prisma/package.json @@ -26,7 +26,7 @@ "nanoid": "^5.1.6", "prisma": "^6.19.0", "prisma-extension-kysely": "^3.0.0", - "prisma-kysely": "^2.2.1", + "prisma-kysely": "^2.3.0", "prisma-json-types-generator": "^3.6.2", "ts-pattern": "^5.9.0", "zod": "^3.25.76", @@ -38,4 +38,4 @@ "tsx": "^4.20.6", "typescript": "5.6.2" } -} \ No newline at end of file +} diff --git a/packages/trpc/package.json b/packages/trpc/package.json index 139ee58f2..97355c419 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -21,7 +21,7 @@ "formidable": "^3.5.4", "luxon": "^3.7.2", "superjson": "^2.2.5", - "trpc-to-openapi": "2.4.0", + "trpc-to-openapi": "^2.1.5", "ts-pattern": "^5.9.0", "zod": "^3.25.76", "zod-form-data": "^2.0.8", From 65e30b88bef2192c6ec42cc13eb739a8ed4e2156 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Tue, 27 Jan 2026 16:21:09 +1100 Subject: [PATCH 06/32] fix: persist formValues in document creation endpoints (#2419) --- packages/api/v1/implementation.ts | 1 + .../trpc/server/document-router/create-document-temporary.ts | 2 ++ .../server/document-router/create-document-temporary.types.ts | 2 ++ packages/trpc/server/document-router/create-document.ts | 1 + 4 files changed, 6 insertions(+) diff --git a/packages/api/v1/implementation.ts b/packages/api/v1/implementation.ts index 4dba574ab..b3d9825c0 100644 --- a/packages/api/v1/implementation.ts +++ b/packages/api/v1/implementation.ts @@ -796,6 +796,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, { title: body.title, }, attachments: body.attachments, + formValues: body.formValues, requestMetadata: metadata, }); diff --git a/packages/trpc/server/document-router/create-document-temporary.ts b/packages/trpc/server/document-router/create-document-temporary.ts index d6f49a3f8..0eca05823 100644 --- a/packages/trpc/server/document-router/create-document-temporary.ts +++ b/packages/trpc/server/document-router/create-document-temporary.ts @@ -38,6 +38,7 @@ export const createDocumentTemporaryRoute = authenticatedProcedure meta, folderId, attachments, + formValues, } = input; const { remaining } = await getServerLimits({ userId: user.id, teamId }); @@ -68,6 +69,7 @@ export const createDocumentTemporaryRoute = authenticatedProcedure title, externalId, visibility, + formValues, globalAccessAuth, globalActionAuth, recipients: (recipients || []).map((recipient) => ({ diff --git a/packages/trpc/server/document-router/create-document-temporary.types.ts b/packages/trpc/server/document-router/create-document-temporary.types.ts index 8895e1a99..51445caaa 100644 --- a/packages/trpc/server/document-router/create-document-temporary.types.ts +++ b/packages/trpc/server/document-router/create-document-temporary.types.ts @@ -27,6 +27,7 @@ import { /** * Temporariy endpoint for V2 Beta until we allow passthrough documents on create. + * @deprecated */ export const createDocumentTemporaryMeta: TrpcRouteMeta = { openapi: { @@ -36,6 +37,7 @@ export const createDocumentTemporaryMeta: TrpcRouteMeta = { description: 'You will need to upload the PDF to the provided URL returned. Note: Once V2 API is released, this will be removed since we will allow direct uploads, instead of using an upload URL.', tags: ['Document'], + deprecated: true, }, }; diff --git a/packages/trpc/server/document-router/create-document.ts b/packages/trpc/server/document-router/create-document.ts index a515db87f..0d88ac22c 100644 --- a/packages/trpc/server/document-router/create-document.ts +++ b/packages/trpc/server/document-router/create-document.ts @@ -78,6 +78,7 @@ export const createDocumentRoute = authenticatedProcedure visibility, globalAccessAuth, globalActionAuth, + formValues, recipients: (recipients || []).map((recipient) => ({ ...recipient, fields: (recipient.fields || []).map((field) => ({ From b590076d8506d9578d726c69b18ddfc035fb183a Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 27 Jan 2026 18:45:58 +1100 Subject: [PATCH 07/32] fix: allow past due subscriptions (#2420) Allow plans with past_due subscriptions to continue to use the platform until the subscription becomes inactive. --- packages/ee/server-only/limits/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ee/server-only/limits/server.ts b/packages/ee/server-only/limits/server.ts index 26f6c6203..22b96fd06 100644 --- a/packages/ee/server-only/limits/server.ts +++ b/packages/ee/server-only/limits/server.ts @@ -70,7 +70,7 @@ export const getServerLimits = async ({ } // Early return for users with an expired subscription. - if (subscription && subscription.status !== SubscriptionStatus.ACTIVE) { + if (subscription && subscription.status === SubscriptionStatus.INACTIVE) { return { quota: INACTIVE_PLAN_LIMITS, remaining: INACTIVE_PLAN_LIMITS, From 7a583aa7afdc0315093cf431471ba1714a2ecac1 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Tue, 27 Jan 2026 20:25:16 +1100 Subject: [PATCH 08/32] fix: preserve prompt parameter in OAuth authorize URL builder (#2421) The prompt option was being discarded for OAuth authorize URLs after adding support for the NEXT_PRIVATE_OIDC_PROMPT env var. This meant select_account (used elsewhere) was not being passed through. Now defaults prompt to the provided option (or 'login'), and only overwrites it when a valid OIDC prompt env var is set. Also adds a type guard to validate the env var value. --- .../server/lib/utils/handle-oauth-authorize-url.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/auth/server/lib/utils/handle-oauth-authorize-url.ts b/packages/auth/server/lib/utils/handle-oauth-authorize-url.ts index f62d27fa9..047543882 100644 --- a/packages/auth/server/lib/utils/handle-oauth-authorize-url.ts +++ b/packages/auth/server/lib/utils/handle-oauth-authorize-url.ts @@ -30,11 +30,17 @@ type HandleOAuthAuthorizeUrlOptions = { prompt?: 'none' | 'login' | 'consent' | 'select_account'; }; +const isOidcPrompt = (value: unknown): value is HandleOAuthAuthorizeUrlOptions['prompt'] => { + return value === 'none' || value === 'login' || value === 'consent' || value === 'select_account'; +}; + const oauthCookieMaxAge = 60 * 10; // 10 minutes. export const handleOAuthAuthorizeUrl = async (options: HandleOAuthAuthorizeUrlOptions) => { const { c, clientOptions, redirectPath } = options; + let prompt = options.prompt ?? 'login'; + if (!clientOptions.clientId || !clientOptions.clientSecret) { throw new AppError(AppErrorCode.NOT_SETUP); } @@ -63,12 +69,12 @@ export const handleOAuthAuthorizeUrl = async (options: HandleOAuthAuthorizeUrlOp ); // Pass the prompt to the authorization endpoint. - if (process.env.NEXT_PRIVATE_OIDC_PROMPT !== '') { - const prompt = process.env.NEXT_PRIVATE_OIDC_PROMPT ?? 'login'; - - url.searchParams.append('prompt', prompt); + if (process.env.NEXT_PRIVATE_OIDC_PROMPT && isOidcPrompt(process.env.NEXT_PRIVATE_OIDC_PROMPT)) { + prompt = process.env.NEXT_PRIVATE_OIDC_PROMPT; } + url.searchParams.set('prompt', prompt); + setCookie(c, `${clientOptions.id}_oauth_state`, state, { ...sessionCookieOptions, sameSite: 'lax', From d08049ed3b63159a750f7c618bcf7ae792d6ef1e Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Tue, 27 Jan 2026 20:25:31 +1100 Subject: [PATCH 09/32] v2.5.1 --- apps/remix/package.json | 2 +- package-lock.json | 6 +++--- package.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/remix/package.json b/apps/remix/package.json index e7e0fa184..baccc9898 100644 --- a/apps/remix/package.json +++ b/apps/remix/package.json @@ -106,5 +106,5 @@ "vite-plugin-babel-macros": "^1.0.6", "vite-tsconfig-paths": "^5.1.4" }, - "version": "2.5.0" + "version": "2.5.1" } diff --git a/package-lock.json b/package-lock.json index 111bdccca..001b67b7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@documenso/root", - "version": "2.5.0", + "version": "2.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@documenso/root", - "version": "2.5.0", + "version": "2.5.1", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -108,7 +108,7 @@ }, "apps/remix": { "name": "@documenso/remix", - "version": "2.5.0", + "version": "2.5.1", "dependencies": { "@cantoo/pdf-lib": "^2.5.3", "@documenso/api": "*", diff --git a/package.json b/package.json index 748b8f7a6..d6fe33e35 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "apps/*", "packages/*" ], - "version": "2.5.0", + "version": "2.5.1", "scripts": { "postinstall": "patch-package", "build": "turbo run build", From d3c898e317b67ff10bb64075b8bb1b082020a3b3 Mon Sep 17 00:00:00 2001 From: Timur Ercan Date: Tue, 27 Jan 2026 17:34:07 +0100 Subject: [PATCH 10/32] chore: update fair policy with support (#2422) updated fair policy and added fair self-host support --- apps/documentation/pages/users/fair-use.mdx | 37 ++++++++++++++------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/apps/documentation/pages/users/fair-use.mdx b/apps/documentation/pages/users/fair-use.mdx index 2fb4198dd..52ddd6b8b 100644 --- a/apps/documentation/pages/users/fair-use.mdx +++ b/apps/documentation/pages/users/fair-use.mdx @@ -7,28 +7,41 @@ import { Callout } from 'nextra/components'; # Fair Use Policy -We offer our plans without any limits on volume because we want our users and customers to make the most of their accounts. Estimating volume is incredibly hard, especially for shorter intervals like a quarter. We are not interested in selling volume packages our customers end up not using. +We like to overdeliver, but we cannot overcommit. -This is why the individual plan and the team plan do not include a limit on signing or API volume. If you are a customer of these [plans](https://documen.so/pricing), we ask you to abide by this fair use policy: +Our plans are designed to be generous and flexible without forcing customers into rigid volume limits they may never use. At the same time, estimating usage at scale is hard, especially over short periods. This fair use policy exists to keep plans sustainable while allowing us to add more value wherever possible without overformalizing restrictions. + +We offer our plans without any limits on volume because we want users and customers to make the most of their accounts. Estimating volume is incredibly hard, especially for shorter intervals like a quarter. We are not interested in selling volume packages our customers end up not using. + +This is why our plans not include a limit on signing or API volume. If you are a customer of these plans, we ask you to abide by this fair use policy. ### Spirit of the Plan -Use the limitless accounts as much as you like (they are meant to offer a lot) while respecting the spirit and intended scope of the account. +Use the limitless plans as much as you like. They are meant to offer a lot. Please respect the spirit and intended scope of the account. - What happens if I violate this policy? We will ask you to upgrade to a fitting plan or custom - pricing. We won’t block your account without reaching out. You can [message - us](mailto:support@documenso.com) for questions. + What happens if I go beyond the scope of this policy? We will ask you to upgrade to a fitting plan + or custom pricing. We will not block your account without reaching out. You can message us for + questions. +### Fair Support + +We believe in fair support as much as fair usage. + +Fair support includes reasonable and within reason application level help for self hosted users. We will help you get unstuck and point you in the right direction when issues come up. Support is provided in good faith and within reasonable time and effort limits. We are not your operations team and cannot take responsibility for running, monitoring, or maintaining your infrastructure. + +If you are unsure whether something falls within fair use or fair support, reach out. We are happy to talk it through. + ### DO -- Sign as many documents as you need with the individual plan for your single business or organization you are part of -- Use the API and automation tools to automate all your signing workflows -- Experiment with the plans and integrations, testing what you want to build +- Sign as many documents as you need with the individual plan for your single business or organization +- Use the API and automation tools to automate your signing workflows +- Experiment with plans and integrations while testing what you want to build ### DON'T -- Use the individual account's API to power a platform -- Run a huge company, signing thousands of documents per day on a two-user team plan using the API -- Let this policy make you overthink. If you are a paying customer, we want you to win +- Use an individual account API to power a platform or product +- Run a large company signing thousands of documents per day on a small team plan +- Expect enterprise level support for fair support plan +- Overthink this policy. If you are a paying customer, we want you to win From 8bc4f1a71340a06196fb8eb70b55683d6409a43a Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 28 Jan 2026 04:07:57 +0200 Subject: [PATCH 11/32] fix: exclude soft-deleted documents from folder count (#2410) --- packages/lib/server-only/folder/find-folders-internal.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/lib/server-only/folder/find-folders-internal.ts b/packages/lib/server-only/folder/find-folders-internal.ts index ccd894bd1..ea71aa0f1 100644 --- a/packages/lib/server-only/folder/find-folders-internal.ts +++ b/packages/lib/server-only/folder/find-folders-internal.ts @@ -66,12 +66,14 @@ export const findFoldersInternal = async ({ where: { type: EnvelopeType.DOCUMENT, folderId: folder.id, + deletedAt: null, }, }), prisma.envelope.count({ where: { type: EnvelopeType.TEMPLATE, folderId: folder.id, + deletedAt: null, }, }), prisma.folder.count({ From eb3b3b18ce7b42af6a6acfe7be19066bebbfe72d Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 28 Jan 2026 14:09:13 +1100 Subject: [PATCH 12/32] chore: add v1 deprecated docs (#2423) --- .../pages/developers/public-api/index.mdx | 13 +++++- packages/api/v1/contract.ts | 46 ++++++++++++++++--- packages/api/v1/openapi.ts | 3 +- 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/apps/documentation/pages/developers/public-api/index.mdx b/apps/documentation/pages/developers/public-api/index.mdx index f589c6c0f..1c844c394 100644 --- a/apps/documentation/pages/developers/public-api/index.mdx +++ b/apps/documentation/pages/developers/public-api/index.mdx @@ -31,9 +31,18 @@ Our new API V2 supports the following typed SDKs: ## API V1 - Deprecated -Check out the [API V1 documentation](https://app.documenso.com/api/v1/openapi) for details about the API endpoints, request parameters, response formats, and authentication methods. + + API V1 is deprecated. +
+ The V1 API will continue to be supported for the foreseeable future, but it is limited to + Legacy Documents (Documents created using the old non-envelope editor). -📖 [Documentation](https://documen.so/api-v2-docs) + Important: To work with the new Envelope document system, you + must use the + V2 API. +
+ +Check out the [API V1 documentation](https://app.documenso.com/api/v1/openapi) for details about the API endpoints, request parameters, response formats, and authentication methods. ## Availability diff --git a/packages/api/v1/contract.ts b/packages/api/v1/contract.ts index 50f096498..172c9833f 100644 --- a/packages/api/v1/contract.ts +++ b/packages/api/v1/contract.ts @@ -43,6 +43,9 @@ import { const c = initContract(); +const deprecatedDescription = + 'This endpoint is deprecated, but will continue to be supported. For more details, see https://docs.documenso.com/developers/public-api.'; + export const ApiContractV1 = c.router( { getDocuments: { @@ -55,6 +58,8 @@ export const ApiContractV1 = c.router( 404: ZUnsuccessfulResponseSchema, }, summary: 'Get all documents', + deprecated: true, + description: deprecatedDescription, }, getDocument: { @@ -66,6 +71,8 @@ export const ApiContractV1 = c.router( 404: ZUnsuccessfulResponseSchema, }, summary: 'Get a single document', + deprecated: true, + description: deprecatedDescription, }, downloadSignedDocument: { @@ -78,6 +85,8 @@ export const ApiContractV1 = c.router( 404: ZUnsuccessfulResponseSchema, }, summary: 'Download a signed document when the storage transport is S3', + deprecated: true, + description: deprecatedDescription, }, createDocument: { @@ -90,6 +99,8 @@ export const ApiContractV1 = c.router( 404: ZUnsuccessfulResponseSchema, }, summary: 'Upload a new document and get a presigned URL', + deprecated: true, + description: deprecatedDescription, }, createTemplate: { @@ -102,6 +113,8 @@ export const ApiContractV1 = c.router( 404: ZUnsuccessfulResponseSchema, }, summary: 'Create a new template and get a presigned URL', + deprecated: true, + description: deprecatedDescription, }, deleteTemplate: { @@ -114,6 +127,8 @@ export const ApiContractV1 = c.router( 404: ZUnsuccessfulResponseSchema, }, summary: 'Delete a template', + deprecated: true, + description: deprecatedDescription, }, getTemplate: { @@ -125,6 +140,8 @@ export const ApiContractV1 = c.router( 404: ZUnsuccessfulResponseSchema, }, summary: 'Get a single template', + deprecated: true, + description: deprecatedDescription, }, getTemplates: { @@ -137,6 +154,8 @@ export const ApiContractV1 = c.router( 404: ZUnsuccessfulResponseSchema, }, summary: 'Get all templates', + deprecated: true, + description: deprecatedDescription, }, createDocumentFromTemplate: { @@ -150,7 +169,7 @@ export const ApiContractV1 = c.router( }, summary: 'Create a new document from an existing template', deprecated: true, - description: `This has been deprecated in favour of "/api/v1/templates/:templateId/generate-document". You may face unpredictable behavior using this endpoint as it is no longer maintained.`, + description: `${deprecatedDescription} \n\nIf you must use the V1 API, use "/api/v1/templates/:templateId/generate-document" instead.`, }, generateDocumentFromTemplate: { @@ -165,8 +184,8 @@ export const ApiContractV1 = c.router( 500: ZUnsuccessfulResponseSchema, }, summary: 'Create a new document from an existing template', - description: - 'Create a new document from an existing template. Passing in values for title and meta will override the original values defined in the template. If you do not pass in values for recipients, it will use the values defined in the template.', + deprecated: true, + description: `${deprecatedDescription} \n\nCreate a new document from an existing template. Passing in values for title and meta will override the original values defined in the template. If you do not pass in values for recipients, it will use the values defined in the template.`, }, sendDocument: { @@ -181,9 +200,8 @@ export const ApiContractV1 = c.router( 500: ZUnsuccessfulResponseSchema, }, summary: 'Send a document for signing', - // I'm aware this should be in the variable itself, which it is, however it's difficult for users to find in our current UI. - description: - 'Notes\n\n`sendEmail` - Whether to send an email to the recipients asking them to action the document. If you disable this, you will need to manually distribute the document to the recipients using the generated signing links. Defaults to true', + deprecated: true, + description: `${deprecatedDescription} \n\nNotes\n\nsendEmail - Whether to send an email to the recipients asking them to action the document. If you disable this, you will need to manually distribute the document to the recipients using the generated signing links. Defaults to true`, }, resendDocument: { @@ -198,6 +216,8 @@ export const ApiContractV1 = c.router( 500: ZUnsuccessfulResponseSchema, }, summary: 'Re-send a document for signing', + deprecated: true, + description: deprecatedDescription, }, deleteDocument: { @@ -210,6 +230,8 @@ export const ApiContractV1 = c.router( 404: ZUnsuccessfulResponseSchema, }, summary: 'Delete a document', + deprecated: true, + description: deprecatedDescription, }, createRecipient: { @@ -224,6 +246,8 @@ export const ApiContractV1 = c.router( 500: ZUnsuccessfulResponseSchema, }, summary: 'Create a recipient for a document', + deprecated: true, + description: deprecatedDescription, }, updateRecipient: { @@ -238,6 +262,8 @@ export const ApiContractV1 = c.router( 500: ZUnsuccessfulResponseSchema, }, summary: 'Update a recipient for a document', + deprecated: true, + description: deprecatedDescription, }, deleteRecipient: { @@ -252,6 +278,8 @@ export const ApiContractV1 = c.router( 500: ZUnsuccessfulResponseSchema, }, summary: 'Delete a recipient from a document', + deprecated: true, + description: deprecatedDescription, }, createField: { @@ -266,6 +294,8 @@ export const ApiContractV1 = c.router( 500: ZUnsuccessfulResponseSchema, }, summary: 'Create a field for a document', + deprecated: true, + description: deprecatedDescription, }, updateField: { @@ -280,6 +310,8 @@ export const ApiContractV1 = c.router( 500: ZUnsuccessfulResponseSchema, }, summary: 'Update a field for a document', + deprecated: true, + description: deprecatedDescription, }, deleteField: { @@ -294,6 +326,8 @@ export const ApiContractV1 = c.router( 500: ZUnsuccessfulResponseSchema, }, summary: 'Delete a field from a document', + deprecated: true, + description: deprecatedDescription, }, }, { diff --git a/packages/api/v1/openapi.ts b/packages/api/v1/openapi.ts index 293395b18..43cecb2ac 100644 --- a/packages/api/v1/openapi.ts +++ b/packages/api/v1/openapi.ts @@ -11,7 +11,8 @@ export const OpenAPIV1 = Object.assign( info: { title: 'Documenso API', version: '1.0.0', - description: 'The Documenso API for retrieving, creating, updating and deleting documents.', + description: + 'API V1 is deprecated, but will continue to be supported. For more details, see https://docs.documenso.com/developers/public-api. \n\nThe Documenso API for retrieving, creating, updating and deleting documents.', }, servers: [ { From 28bc2dc9759bba877613696a597a3a8eab362779 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Wed, 28 Jan 2026 09:18:58 +0200 Subject: [PATCH 13/32] fix: send organisation member removal email to correct user (#2405) --- .../organisation-router/delete-organisation-members.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/trpc/server/organisation-router/delete-organisation-members.ts b/packages/trpc/server/organisation-router/delete-organisation-members.ts index a73991772..9725ecca7 100644 --- a/packages/trpc/server/organisation-router/delete-organisation-members.ts +++ b/packages/trpc/server/organisation-router/delete-organisation-members.ts @@ -100,13 +100,15 @@ export const deleteOrganisationMembers = async ({ organisationId, }, }); + }); + for (const member of membersToDelete) { await jobs.triggerJob({ name: 'send.organisation-member-left.email', payload: { organisationId, - memberUserId: userId, + memberUserId: member.userId, }, }); - }); + } }; From 155310b02805847891bc152fea77cb606911fbd1 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Wed, 28 Jan 2026 09:27:32 +0200 Subject: [PATCH 14/32] feat: add bulk document selection and move functionality (#2387) This PR introduces bulk actions for documents, allowing users to select multiple envelopes and perform actions such as moving or deleting 1 or more documents simultaneously. --- .../dialogs/envelopes-bulk-delete-dialog.tsx | 160 +++++++ .../dialogs/envelopes-bulk-move-dialog.tsx | 256 ++++++++++++ .../app/components/tables/documents-table.tsx | 57 ++- .../envelopes-table-bulk-action-bar.tsx | 55 +++ .../app/components/tables/templates-table.tsx | 57 ++- .../t.$teamUrl+/documents._index.tsx | 50 ++- .../t.$teamUrl+/templates._index.tsx | 52 ++- .../v2/test-unauthorized-api-access.spec.ts | 246 ----------- .../api-access-envelope-attachments.spec.ts | 220 ++++++++++ .../api-access-envelope-bulk.spec.ts | 390 ++++++++++++++++++ .../documents/bulk-document-actions.spec.ts | 253 ++++++++++++ .../templates/bulk-template-actions.spec.ts | 256 ++++++++++++ .../envelope-router/bulk-delete-envelopes.ts | 110 +++++ .../bulk-delete-envelopes.types.ts | 31 ++ .../envelope-router/bulk-move-envelopes.ts | 73 ++++ .../bulk-move-envelopes.types.ts | 38 ++ .../trpc/server/envelope-router/router.ts | 6 + packages/ui/primitives/data-table.tsx | 24 +- 18 files changed, 2068 insertions(+), 266 deletions(-) create mode 100644 apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx create mode 100644 apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx create mode 100644 apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx create mode 100644 packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-envelope-attachments.spec.ts create mode 100644 packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-envelope-bulk.spec.ts create mode 100644 packages/app-tests/e2e/documents/bulk-document-actions.spec.ts create mode 100644 packages/app-tests/e2e/templates/bulk-template-actions.spec.ts create mode 100644 packages/trpc/server/envelope-router/bulk-delete-envelopes.ts create mode 100644 packages/trpc/server/envelope-router/bulk-delete-envelopes.types.ts create mode 100644 packages/trpc/server/envelope-router/bulk-move-envelopes.ts create mode 100644 packages/trpc/server/envelope-router/bulk-move-envelopes.types.ts diff --git a/apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx b/apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx new file mode 100644 index 000000000..47820314d --- /dev/null +++ b/apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx @@ -0,0 +1,160 @@ +import { Plural, useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; +import { EnvelopeType } from '@prisma/client'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; + +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type EnvelopesBulkDeleteDialogProps = { + envelopeIds: string[]; + envelopeType: EnvelopeType; + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: () => void; +} & Omit; + +export const EnvelopesBulkDeleteDialog = ({ + envelopeIds, + envelopeType, + open, + onOpenChange, + onSuccess, + ...props +}: EnvelopesBulkDeleteDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const trpcUtils = trpc.useUtils(); + + const isDocument = envelopeType === EnvelopeType.DOCUMENT; + + const { mutateAsync: bulkDeleteEnvelopes, isPending } = trpc.envelope.bulk.delete.useMutation({ + onSuccess: async (result) => { + // Invalidate the appropriate query based on envelope type. + if (isDocument) { + await trpcUtils.document.findDocumentsInternal.invalidate(); + } else { + await trpcUtils.template.findTemplates.invalidate(); + } + + if (result.failedIds.length > 0) { + toast({ + title: isDocument ? t`Documents partially deleted` : t`Templates partially deleted`, + description: t`${result.deletedCount} item(s) deleted. ${result.failedIds.length} item(s) could not be deleted.`, + variant: 'destructive', + }); + } else { + toast({ + title: isDocument ? t`Documents deleted` : t`Templates deleted`, + description: t`${result.deletedCount} item(s) have been deleted.`, + variant: 'default', + }); + } + + onSuccess?.(); + onOpenChange(false); + }, + onError: () => { + toast({ + title: t`Error`, + description: t`An error occurred while deleting the items.`, + variant: 'destructive', + }); + }, + }); + + return ( + + + + + {isDocument ? Delete Documents : Delete Templates} + + + + {isDocument ? ( + + ) : ( + + )} + + + + + +

+ + Please note that this action is irreversible. + +

+ +

+ Once confirmed, the following will occur: +

+ +
    + {isDocument ? ( + <> +
  • + Selected documents will be permanently deleted +
  • +
  • + Pending documents will have their signing process cancelled +
  • +
  • + All recipients will be notified +
  • + + ) : ( + <> +
  • + Selected templates will be permanently deleted +
  • +
  • + Direct links associated with templates will be removed +
  • + + )} +
+
+
+ + + + + + +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx b/apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx new file mode 100644 index 000000000..be50e85a2 --- /dev/null +++ b/apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx @@ -0,0 +1,256 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Plural, useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; +import { EnvelopeType } from '@prisma/client'; +import type * as DialogPrimitive from '@radix-ui/react-dialog'; +import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { match } from 'ts-pattern'; +import { z } from 'zod'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type EnvelopesBulkMoveDialogProps = { + envelopeIds: string[]; + envelopeType: EnvelopeType; + open: boolean; + onOpenChange: (open: boolean) => void; + currentFolderId?: string; + onSuccess?: () => void; +} & Omit; + +const ZBulkMoveFormSchema = z.object({ + folderId: z.string().nullable(), +}); + +type TBulkMoveFormSchema = z.infer; + +export const EnvelopesBulkMoveDialog = ({ + envelopeIds, + envelopeType, + open, + onOpenChange, + currentFolderId, + onSuccess, + ...props +}: EnvelopesBulkMoveDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [searchTerm, setSearchTerm] = useState(''); + + const form = useForm({ + resolver: zodResolver(ZBulkMoveFormSchema), + defaultValues: { + folderId: currentFolderId ?? null, + }, + }); + + const isDocument = envelopeType === EnvelopeType.DOCUMENT; + + const { data: folders, isLoading: isFoldersLoading } = trpc.folder.findFoldersInternal.useQuery( + { + parentId: currentFolderId, + type: envelopeType, + }, + { + enabled: open, + }, + ); + + const { mutateAsync: bulkMoveEnvelopes } = trpc.envelope.bulk.move.useMutation(); + + const trpcUtils = trpc.useUtils(); + + useEffect(() => { + if (open) { + setSearchTerm(''); + + form.reset({ + folderId: currentFolderId, + }); + } + }, [open, currentFolderId]); + + const onSubmit = async (data: TBulkMoveFormSchema) => { + try { + await bulkMoveEnvelopes({ + envelopeIds, + folderId: data.folderId, + envelopeType, + }); + + // Invalidate the appropriate query based on envelope type. + if (isDocument) { + await trpcUtils.document.findDocumentsInternal.invalidate(); + } else { + await trpcUtils.template.findTemplates.invalidate(); + } + + toast({ + description: t`Selected items have been moved.`, + }); + + onSuccess?.(); + onOpenChange(false); + } catch (err) { + const error = AppError.parseError(err); + + const errorMessage = match(error.code) + .with( + AppErrorCode.NOT_FOUND, + () => t`The folder you are trying to move the items to does not exist.`, + ) + .with(AppErrorCode.UNAUTHORIZED, () => t`You are not allowed to move these items.`) + .with(AppErrorCode.INVALID_BODY, () => t`All items must be of the same type.`) + .otherwise(() => t`An error occurred while moving the items.`); + + toast({ + description: errorMessage, + variant: 'destructive', + }); + } + }; + + const filteredFolders = folders?.data.filter((folder) => + folder.name.toLowerCase().includes(searchTerm.toLowerCase()), + ); + + return ( + + + + + {isDocument ? ( + Move Documents to Folder + ) : ( + Move Templates to Folder + )} + + + + {isDocument ? ( + + ) : ( + + )} + + + +
+ + setSearchTerm(e.target.value)} + className="pl-8" + /> +
+ +
+ + ( + + + Folder + + + +
+ {isFoldersLoading ? ( +
+ +
+ ) : ( + <> + + + {filteredFolders?.map((folder) => ( + + ))} + + {searchTerm && filteredFolders?.length === 0 && ( +
+ No folders found +
+ )} + + )} +
+
+ +
+ )} + /> + + + + + + + + +
+
+ ); +}; diff --git a/apps/remix/app/components/tables/documents-table.tsx b/apps/remix/app/components/tables/documents-table.tsx index ebd4574c5..a9da6ac2d 100644 --- a/apps/remix/app/components/tables/documents-table.tsx +++ b/apps/remix/app/components/tables/documents-table.tsx @@ -12,7 +12,8 @@ import { useSession } from '@documenso/lib/client-only/providers/session'; import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/find-documents.types'; -import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; +import { Checkbox } from '@documenso/ui/primitives/checkbox'; +import type { DataTableColumnDef, RowSelectionState } from '@documenso/ui/primitives/data-table'; import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; import { Skeleton } from '@documenso/ui/primitives/skeleton'; @@ -30,6 +31,9 @@ export type DocumentsTableProps = { isLoading?: boolean; isLoadingError?: boolean; onMoveDocument?: (documentId: number) => void; + enableSelection?: boolean; + rowSelection?: RowSelectionState; + onRowSelectionChange?: (selection: RowSelectionState) => void; }; type DocumentsTableRow = TFindDocumentsResponse['data'][number]; @@ -39,6 +43,9 @@ export const DocumentsTable = ({ isLoading, isLoadingError, onMoveDocument, + enableSelection, + rowSelection, + onRowSelectionChange, }: DocumentsTableProps) => { const { _, i18n } = useLingui(); @@ -48,7 +55,34 @@ export const DocumentsTable = ({ const updateSearchParams = useUpdateSearchParams(); const columns = useMemo(() => { - return [ + const cols: DataTableColumnDef[] = []; + + if (enableSelection) { + cols.push({ + id: 'select', + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label={_(msg`Select all`)} + onClick={(e) => e.stopPropagation()} + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label={_(msg`Select row`)} + onClick={(e) => e.stopPropagation()} + /> + ), + enableSorting: false, + enableHiding: false, + size: 40, + }); + } + + cols.push( { header: _(msg`Created`), accessorKey: 'createdAt', @@ -93,8 +127,10 @@ export const DocumentsTable = ({ ), }, - ] satisfies DataTableColumnDef[]; - }, [team, onMoveDocument]); + ); + + return cols; + }, [team, onMoveDocument, enableSelection]); const onPaginationChange = (page: number, perPage: number) => { startTransition(() => { @@ -132,6 +168,11 @@ export const DocumentsTable = ({ rows: 5, component: ( <> + {enableSelection && ( + + + + )} @@ -152,13 +193,17 @@ export const DocumentsTable = ({ ), }} + enableRowSelection={enableSelection} + rowSelection={rowSelection} + onRowSelectionChange={onRowSelectionChange} + getRowId={(row) => row.envelopeId} > {(table) => } {isPending && ( -
- +
+
)}
diff --git a/apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx b/apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx new file mode 100644 index 000000000..84ee783b7 --- /dev/null +++ b/apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx @@ -0,0 +1,55 @@ +import { useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; +import { FolderInputIcon, Trash2Icon, XIcon } from 'lucide-react'; + +import { Button } from '@documenso/ui/primitives/button'; + +export type EnvelopesTableBulkActionBarProps = { + selectedCount: number; + onMoveClick: () => void; + onDeleteClick: () => void; + onClearSelection: () => void; +}; + +export const EnvelopesTableBulkActionBar = ({ + selectedCount, + onMoveClick, + onDeleteClick, + onClearSelection, +}: EnvelopesTableBulkActionBarProps) => { + const { t } = useLingui(); + + if (selectedCount === 0) { + return null; + } + + return ( +
+ + {selectedCount} selected + + +
+ + + + + + +
+ ); +}; diff --git a/apps/remix/app/components/tables/templates-table.tsx b/apps/remix/app/components/tables/templates-table.tsx index 59160ad89..df7ef6dc1 100644 --- a/apps/remix/app/components/tables/templates-table.tsx +++ b/apps/remix/app/components/tables/templates-table.tsx @@ -12,7 +12,8 @@ import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/org import { formatTemplatesPath } from '@documenso/lib/utils/teams'; import type { TFindTemplatesResponse } from '@documenso/trpc/server/template-router/schema'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; -import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table'; +import { Checkbox } from '@documenso/ui/primitives/checkbox'; +import type { DataTableColumnDef, RowSelectionState } from '@documenso/ui/primitives/data-table'; import { DataTable } from '@documenso/ui/primitives/data-table'; import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination'; import { Skeleton } from '@documenso/ui/primitives/skeleton'; @@ -32,6 +33,9 @@ type TemplatesTableProps = { isLoadingError?: boolean; documentRootPath: string; templateRootPath: string; + enableSelection?: boolean; + rowSelection?: RowSelectionState; + onRowSelectionChange?: (selection: RowSelectionState) => void; }; type TemplatesTableRow = TFindTemplatesResponse['data'][number]; @@ -42,6 +46,9 @@ export const TemplatesTable = ({ isLoadingError, documentRootPath, templateRootPath, + enableSelection, + rowSelection, + onRowSelectionChange, }: TemplatesTableProps) => { const { _, i18n } = useLingui(); const { remaining } = useLimits(); @@ -60,7 +67,34 @@ export const TemplatesTable = ({ }; const columns = useMemo(() => { - return [ + const cols: DataTableColumnDef[] = []; + + if (enableSelection) { + cols.push({ + id: 'select', + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label={_(msg`Select all`)} + onClick={(e) => e.stopPropagation()} + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label={_(msg`Select row`)} + onClick={(e) => e.stopPropagation()} + /> + ), + enableSorting: false, + enableHiding: false, + size: 40, + }); + } + + cols.push( { header: _(msg`Created`), accessorKey: 'createdAt', @@ -86,8 +120,8 @@ export const TemplatesTable = ({ - -
-

- {typeof value === 'number' ? value.toLocaleString('en-US') : value} -

+ {children || ( +

+ {typeof value === 'number' ? value.toLocaleString('en-US') : value} +

+ )} ); diff --git a/apps/remix/app/components/tables/admin-claims-table.tsx b/apps/remix/app/components/tables/admin-claims-table.tsx index 425472d0a..cf6d82aac 100644 --- a/apps/remix/app/components/tables/admin-claims-table.tsx +++ b/apps/remix/app/components/tables/admin-claims-table.tsx @@ -6,6 +6,7 @@ import { EditIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react'; import { Link, useSearchParams } from 'react-router'; import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import type { TLicenseClaim } from '@documenso/lib/types/license'; import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params'; import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription'; import { trpc } from '@documenso/trpc/react'; @@ -27,7 +28,11 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { ClaimDeleteDialog } from '../dialogs/claim-delete-dialog'; import { ClaimUpdateDialog } from '../dialogs/claim-update-dialog'; -export const AdminClaimsTable = () => { +type AdminClaimsTableProps = { + licenseFlags?: TLicenseClaim; +}; + +export const AdminClaimsTable = ({ licenseFlags }: AdminClaimsTableProps) => { const { t } = useLingui(); const { toast } = useToast(); @@ -97,11 +102,11 @@ export const AdminClaimsTable = () => { ); if (flags.length === 0) { - return

{t`None`}

; + return

{t`None`}

; } return ( -
    +
      {flags.map(({ key, label }) => (
    • {label}
    • ))} @@ -114,7 +119,7 @@ export const AdminClaimsTable = () => { cell: ({ row }) => ( - + @@ -124,6 +129,7 @@ export const AdminClaimsTable = () => { e.preventDefault()}>
      diff --git a/apps/remix/app/root.tsx b/apps/remix/app/root.tsx index 1b5ae1acd..76db197e0 100644 --- a/apps/remix/app/root.tsx +++ b/apps/remix/app/root.tsx @@ -7,7 +7,6 @@ import { data, isRouteErrorResponse, useLoaderData, - useLocation, } from 'react-router'; import { PreventFlashOnWrongTheme, ThemeProvider, useTheme } from 'remix-themes'; @@ -87,8 +86,6 @@ export async function loader({ request }: Route.LoaderArgs) { export function Layout({ children }: { children: React.ReactNode }) { const { theme } = useLoaderData() || {}; - const location = useLocation(); - return ( {children} @@ -129,6 +126,18 @@ export function LayoutContent({ children }: { children: React.ReactNode }) { + {/* Global license banner currently disabled. Need to wait until after a few releases. */} + {/* {licenseStatus === '?' && ( +
      +
      +
      + + This is an expired license instance of Documenso +
      +
      +
      + )} */} + diff --git a/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx b/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx index 33ef772f5..8757372b1 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx @@ -11,25 +11,37 @@ import { import { Link, Outlet, redirect, useLocation } from 'react-router'; import { getSession } from '@documenso/auth/server/lib/utils/get-session'; +import { LicenseClient } from '@documenso/lib/server-only/license/license-client'; import { isAdmin } from '@documenso/lib/utils/is-admin'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; +import { AdminLicenseStatusBanner } from '~/components/general/admin-license-status-banner'; + import type { Route } from './+types/_layout'; export async function loader({ request }: Route.LoaderArgs) { const { user } = await getSession(request); + const license = await LicenseClient.getInstance()?.getCachedLicense(); + if (!user || !isAdmin(user)) { throw redirect('/'); } + + return { + license: license || null, + }; } -export default function AdminLayout() { +export default function AdminLayout({ loaderData }: Route.ComponentProps) { + const { license } = loaderData; const { pathname } = useLocation(); return (
      + +

      Admin Panel

      diff --git a/apps/remix/app/routes/_authenticated+/admin+/claims.tsx b/apps/remix/app/routes/_authenticated+/admin+/claims.tsx index c9ed9d2e0..2028116cb 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/claims.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/claims.tsx @@ -4,13 +4,26 @@ import { useLingui } from '@lingui/react/macro'; import { useLocation, useSearchParams } from 'react-router'; import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; +import { LicenseClient } from '@documenso/lib/server-only/license/license-client'; import { Input } from '@documenso/ui/primitives/input'; import { ClaimCreateDialog } from '~/components/dialogs/claim-create-dialog'; import { SettingsHeader } from '~/components/general/settings-header'; import { AdminClaimsTable } from '~/components/tables/admin-claims-table'; -export default function Claims() { +import type { Route } from './+types/claims'; + +export async function loader() { + const licenseData = await LicenseClient.getInstance()?.getCachedLicense(); + + return { + licenseFlags: licenseData?.license?.flags, + }; +} + +export default function Claims({ loaderData }: Route.ComponentProps) { + const { licenseFlags } = loaderData; + const { t } = useLingui(); const [searchParams, setSearchParams] = useSearchParams(); @@ -47,7 +60,7 @@ export default function Claims() { subtitle={t`Manage all subscription claims`} hideDivider > - +
      @@ -58,7 +71,7 @@ export default function Claims() { className="mb-4" /> - +
      ); diff --git a/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx index 58a8449c7..159f9dd76 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx @@ -12,6 +12,8 @@ import type { z } from 'zod'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { SUBSCRIPTION_STATUS_MAP } from '@documenso/lib/constants/billing'; import { AppError } from '@documenso/lib/errors/app-error'; +import { LicenseClient } from '@documenso/lib/server-only/license/license-client'; +import type { TLicenseClaim } from '@documenso/lib/types/license'; import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription'; import { trpc } from '@documenso/trpc/react'; import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types'; @@ -40,7 +42,20 @@ import { SettingsHeader } from '~/components/general/settings-header'; import type { Route } from './+types/organisations.$id'; -export default function OrganisationGroupSettingsPage({ params }: Route.ComponentProps) { +export async function loader() { + const licenseData = await LicenseClient.getInstance()?.getCachedLicense(); + + return { + licenseFlags: licenseData?.license?.flags, + }; +} + +export default function OrganisationGroupSettingsPage({ + params, + loaderData, +}: Route.ComponentProps) { + const { licenseFlags } = loaderData; + const { t } = useLingui(); const { toast } = useToast(); @@ -129,7 +144,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen if (isLoadingOrganisation) { return (
      - +
      ); } @@ -239,7 +254,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen )} - +
      @@ -278,6 +293,7 @@ type TUpdateGenericOrganisationDataFormSchema = z.infer< type OrganisationAdminFormOptions = { organisation: TGetAdminOrganisationResponse; + licenseFlags?: TLicenseClaim; }; const GenericOrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) => { @@ -349,7 +365,7 @@ const GenericOrganisationAdminForm = ({ organisation }: OrganisationAdminFormOpt {!form.formState.errors.url && ( - + {field.value ? ( `${NEXT_PUBLIC_WEBAPP_URL()}/o/${field.value}` ) : ( @@ -381,12 +397,17 @@ const ZUpdateOrganisationBillingFormSchema = ZUpdateAdminOrganisationRequestSche type TUpdateOrganisationBillingFormSchema = z.infer; -const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) => { +const OrganisationAdminForm = ({ organisation, licenseFlags }: OrganisationAdminFormOptions) => { const { toast } = useToast(); const { t } = useLingui(); const { mutateAsync: updateOrganisation } = trpc.admin.organisation.update.useMutation(); + const hasRestrictedEnterpriseFeatures = Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).some( + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + (flag) => flag.isEnterprise && !licenseFlags?.[flag.key as keyof TLicenseClaim], + ); + const form = useForm({ resolver: zodResolver(ZUpdateOrganisationBillingFormSchema), defaultValues: { @@ -440,7 +461,7 @@ const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) = - +

      Inherited subscription claim @@ -493,7 +514,7 @@ const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) = {`https://dashboard.stripe.com/customers/${field.value}`} @@ -582,34 +603,57 @@ const OrganisationAdminForm = ({ organisation }: OrganisationAdminFormOptions) =
      - {Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label }) => ( - ( - - -
      - + {Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).map(({ key, label, isEnterprise }) => { + const isRestrictedFeature = + isEnterprise && !licenseFlags?.[key as keyof TLicenseClaim]; // eslint-disable-line @typescript-eslint/consistent-type-assertions - -
      -
      -
      - )} - /> - ))} + return ( + ( + + +
      + + + +
      +
      +
      + )} + /> + ); + })}
      + + {hasRestrictedEnterpriseFeatures && ( + + + ¹  + Your current license does not include these features.{' '} + + Learn more + + + + )}

      diff --git a/apps/remix/app/routes/_authenticated+/admin+/stats.tsx b/apps/remix/app/routes/_authenticated+/admin+/stats.tsx index bc43d1942..c99cfd0fa 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/stats.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/stats.tsx @@ -23,8 +23,10 @@ import { getUserWithSignedDocumentMonthlyGrowth, getUsersCount, } from '@documenso/lib/server-only/admin/get-users-stats'; +import { LicenseClient } from '@documenso/lib/server-only/license/license-client'; import { getSignerConversionMonthly } from '@documenso/lib/server-only/user/get-signer-conversion'; +import { AdminLicenseCard } from '~/components/general/admin-license-card'; import { MonthlyActiveUsersChart } from '~/components/general/admin-monthly-active-user-charts'; import { AdminStatsSignerConversionChart } from '~/components/general/admin-stats-signer-conversion-chart'; import { AdminStatsUsersWithDocumentsChart } from '~/components/general/admin-stats-users-with-documents'; @@ -42,6 +44,7 @@ export async function loader() { signerConversionMonthly, monthlyUsersWithDocuments, monthlyActiveUsers, + licenseData, ] = await Promise.all([ getUsersCount(), getOrganisationsWithSubscriptionsCount(), @@ -50,6 +53,7 @@ export async function loader() { getSignerConversionMonthly(), getUserWithSignedDocumentMonthlyGrowth(), getMonthlyActiveUsers(), + LicenseClient.getInstance()?.getCachedLicense(), ]); return { @@ -60,6 +64,7 @@ export async function loader() { signerConversionMonthly, monthlyUsersWithDocuments, monthlyActiveUsers, + licenseData: licenseData || null, }; } @@ -74,6 +79,7 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) { signerConversionMonthly, monthlyUsersWithDocuments, monthlyActiveUsers, + licenseData, } = loaderData; return ( @@ -94,6 +100,10 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) {
      +
      + +
      +

      diff --git a/apps/remix/server/router.ts b/apps/remix/server/router.ts index bf091a955..12ac294ce 100644 --- a/apps/remix/server/router.ts +++ b/apps/remix/server/router.ts @@ -10,6 +10,7 @@ import { tsRestHonoApp } from '@documenso/api/hono'; import { auth } from '@documenso/auth/server'; import { API_V2_BETA_URL, API_V2_URL } from '@documenso/lib/constants/app'; import { jobsClient } from '@documenso/lib/jobs/client'; +import { LicenseClient } from '@documenso/lib/server-only/license/license-client'; import { TelemetryClient } from '@documenso/lib/server-only/telemetry/telemetry-client'; import { getIpAddress } from '@documenso/lib/universal/get-ip-address'; import { env } from '@documenso/lib/utils/env'; @@ -140,4 +141,7 @@ if (env('NODE_ENV') !== 'development') { void TelemetryClient.start(); } +// Start license client to verify license on startup. +void LicenseClient.start(); + export default app; diff --git a/packages/app-tests/e2e/license/enterprise-feature-restrictions.spec.ts b/packages/app-tests/e2e/license/enterprise-feature-restrictions.spec.ts new file mode 100644 index 000000000..a4ea41ffa --- /dev/null +++ b/packages/app-tests/e2e/license/enterprise-feature-restrictions.spec.ts @@ -0,0 +1,326 @@ +import { expect, test } from '@playwright/test'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import type { TCachedLicense, TLicenseClaim } from '@documenso/lib/types/license'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; + +const LICENSE_FILE_NAME = '.documenso-license.json'; +const LICENSE_BACKUP_FILE_NAME = '.documenso-license-backup.json'; + +/** + * Get the path to the license file. + * + * The server reads from process.cwd() which is apps/remix when the dev server runs. + * Tests run from packages/app-tests, so we need to go up to the root then into apps/remix. + */ +const getLicenseFilePath = () => { + // From packages/app-tests/e2e/license -> ../../../../apps/remix/.documenso-license.json + return path.join(__dirname, '../../../../apps/remix', LICENSE_FILE_NAME); +}; + +/** + * Get the path to the backup license file. + */ +const getBackupLicenseFilePath = () => { + return path.join(__dirname, '../../../../apps/remix', LICENSE_BACKUP_FILE_NAME); +}; + +/** + * Backup the existing license file if it exists. + */ +const backupLicenseFile = async () => { + const licensePath = getLicenseFilePath(); + const backupPath = getBackupLicenseFilePath(); + + try { + await fs.access(licensePath); + await fs.rename(licensePath, backupPath); + } catch (e) { + // File doesn't exist, nothing to backup + console.log(e); + } +}; + +/** + * Restore the backup license file if it exists. + */ +const restoreLicenseFile = async () => { + const licensePath = getLicenseFilePath(); + const backupPath = getBackupLicenseFilePath(); + + try { + await fs.access(backupPath); + await fs.rename(backupPath, licensePath); + } catch (e) { + // Backup doesn't exist, nothing to restore + console.log(e); + } +}; + +/** + * Write a license file with the given data. + * Pass null to delete the license file. + */ +const writeLicenseFile = async (data: TCachedLicense | null) => { + const licensePath = getLicenseFilePath(); + + if (data === null) { + await fs.unlink(licensePath).catch(() => { + // File doesn't exist, ignore + }); + } else { + await fs.writeFile(licensePath, JSON.stringify(data, null, 2), 'utf-8'); + } +}; + +/** + * Create a mock license object with the given flags. + */ +const createMockLicenseWithFlags = (flags: TLicenseClaim): TCachedLicense => { + return { + lastChecked: new Date().toISOString(), + license: { + status: 'ACTIVE', + createdAt: new Date(), + name: 'Test License', + periodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now + cancelAtPeriodEnd: false, + licenseKey: 'test-license-key', + flags, + }, + requestedLicenseKey: 'test-license-key', + derivedStatus: 'ACTIVE', + unauthorizedFlagUsage: false, + }; +}; + +// Run tests serially to avoid race conditions with the license file +test.describe.configure({ mode: 'serial' }); + +// SKIPPING TEST UNTIL WE ADD A WAY TO OVERRIDE THE LICENSE FILE. +test.describe.skip('Enterprise Feature Restrictions', () => { + test.beforeAll(async () => { + // Backup any existing license file before running tests + await backupLicenseFile(); + }); + + test.afterAll(async () => { + // Restore the backup license file after all tests complete + await restoreLicenseFile(); + }); + + test.beforeEach(async () => { + // Clean up license file before each test to ensure clean state + await writeLicenseFile(null); + }); + + test.afterEach(async () => { + // Clean up license file after each test + await writeLicenseFile(null); + }); + + test('[ADMIN CLAIMS]: shows restricted features with asterisk when no license', async ({ + page, + }) => { + // Ensure no license file exists + await writeLicenseFile(null); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin/claims', + }); + + // Click Create claim button to open the dialog + await page.getByRole('button', { name: 'Create claim' }).click(); + + // Wait for dialog to open + await expect(page.getByRole('dialog')).toBeVisible(); + + // Check that enterprise features have asterisks (are restricted) + // These are the enterprise features that should be marked with * + await expect(page.getByText(/Email domains\s¹/)).toBeVisible(); + await expect(page.getByText(/Embed authoring\s¹/)).toBeVisible(); + await expect(page.getByText(/White label for embed authoring\s¹/)).toBeVisible(); + await expect(page.getByText(/21 CFR\s¹/)).toBeVisible(); + await expect(page.getByText(/Authentication portal\s¹/)).toBeVisible(); + + // Check that the alert is visible + await expect( + page.getByText('Your current license does not include these features.'), + ).toBeVisible(); + await expect(page.getByRole('link', { name: 'Learn more' })).toBeVisible(); + + // Check that enterprise feature checkboxes are disabled + const emailDomainsCheckbox = page.locator('#flag-emailDomains'); + await expect(emailDomainsCheckbox).toBeDisabled(); + + const cfr21Checkbox = page.locator('#flag-cfr21'); + await expect(cfr21Checkbox).toBeDisabled(); + + const authPortalCheckbox = page.locator('#flag-authenticationPortal'); + await expect(authPortalCheckbox).toBeDisabled(); + }); + + test('[ADMIN CLAIMS]: no restrictions when license has all enterprise features', async ({ + page, + }) => { + // Create a license with ALL enterprise features enabled + await writeLicenseFile( + createMockLicenseWithFlags({ + emailDomains: true, + embedAuthoring: true, + embedAuthoringWhiteLabel: true, + cfr21: true, + authenticationPortal: true, + billing: true, + }), + ); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin/claims', + }); + + // Click Create claim button to open the dialog + await page.getByRole('button', { name: 'Create claim' }).click(); + + // Wait for dialog to open + await expect(page.getByRole('dialog')).toBeVisible(); + + // Check that enterprise features do NOT have asterisks + // They should show without the * since the license covers them + await expect(page.getByText(/Email domains\s¹/)).not.toBeVisible(); + await expect(page.getByText(/Embed authoring\s¹/)).not.toBeVisible(); + await expect(page.getByText(/21 CFR\s¹/)).not.toBeVisible(); + await expect(page.getByText(/Authentication portal\s¹/)).not.toBeVisible(); + + // The plain labels should be visible (without asterisks) + await expect(page.locator('label[for="flag-emailDomains"]')).toContainText('Email domains'); + await expect(page.locator('label[for="flag-cfr21"]')).toContainText('21 CFR'); + + // The alert should NOT be visible + await expect( + page.getByText('Your current license does not include these features.'), + ).not.toBeVisible(); + + // Check that enterprise feature checkboxes are enabled + const emailDomainsCheckbox = page.locator('#flag-emailDomains'); + await expect(emailDomainsCheckbox).toBeEnabled(); + + const cfr21Checkbox = page.locator('#flag-cfr21'); + await expect(cfr21Checkbox).toBeEnabled(); + + const authPortalCheckbox = page.locator('#flag-authenticationPortal'); + await expect(authPortalCheckbox).toBeEnabled(); + }); + + test('[ADMIN CLAIMS]: only unlicensed features show asterisk with partial license', async ({ + page, + }) => { + // Create a license with SOME enterprise features (emailDomains and cfr21) + await writeLicenseFile( + createMockLicenseWithFlags({ + emailDomains: true, + cfr21: true, + // embedAuthoring, embedAuthoringWhiteLabel, authenticationPortal are NOT included + }), + ); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin/claims', + }); + + // Click Create claim button to open the dialog + await page.getByRole('button', { name: 'Create claim' }).click(); + + // Wait for dialog to open + await expect(page.getByRole('dialog')).toBeVisible(); + + // Features NOT in license should have asterisks + await expect(page.getByText(/Embed authoring\s¹/)).toBeVisible(); + await expect(page.getByText(/White label for embed authoring\s¹/)).toBeVisible(); + await expect(page.getByText(/Authentication portal\s¹/)).toBeVisible(); + + // Features IN license should NOT have asterisks + await expect(page.getByText(/Email domains\s¹/)).not.toBeVisible(); + await expect(page.getByText(/21 CFR\s¹/)).not.toBeVisible(); + + // The plain labels for licensed features should be visible + await expect(page.locator('label[for="flag-emailDomains"]')).toContainText('Email domains'); + await expect(page.locator('label[for="flag-cfr21"]')).toContainText('21 CFR'); + + // Alert should be visible since some features are restricted + await expect( + page.getByText('Your current license does not include these features.'), + ).toBeVisible(); + + // Licensed features should be enabled + const emailDomainsCheckbox = page.locator('#flag-emailDomains'); + await expect(emailDomainsCheckbox).toBeEnabled(); + + const cfr21Checkbox = page.locator('#flag-cfr21'); + await expect(cfr21Checkbox).toBeEnabled(); + + // Unlicensed features should be disabled + const embedAuthoringCheckbox = page.locator('#flag-embedAuthoring'); + await expect(embedAuthoringCheckbox).toBeDisabled(); + + const authPortalCheckbox = page.locator('#flag-authenticationPortal'); + await expect(authPortalCheckbox).toBeDisabled(); + }); + + test('[ADMIN CLAIMS]: non-enterprise features are always enabled', async ({ page }) => { + // Ensure no license file exists + await writeLicenseFile(null); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin/claims', + }); + + // Click Create claim button to open the dialog + await page.getByRole('button', { name: 'Create claim' }).click(); + + // Wait for dialog to open + await expect(page.getByRole('dialog')).toBeVisible(); + + // Non-enterprise features should NOT have asterisks + await expect(page.getByText(/Unlimited documents\s¹/)).not.toBeVisible(); + await expect(page.getByText(/Branding\s¹/)).not.toBeVisible(); + await expect(page.getByText(/Embed signing\s¹/)).not.toBeVisible(); + + // Non-enterprise features should always be enabled + const unlimitedDocsCheckbox = page.locator('#flag-unlimitedDocuments'); + await expect(unlimitedDocsCheckbox).toBeEnabled(); + + const brandingCheckbox = page.locator('#flag-allowCustomBranding'); + await expect(brandingCheckbox).toBeEnabled(); + + const embedSigningCheckbox = page.locator('#flag-embedSigning'); + await expect(embedSigningCheckbox).toBeEnabled(); + }); +}); diff --git a/packages/app-tests/e2e/license/license-status-banner.spec.ts b/packages/app-tests/e2e/license/license-status-banner.spec.ts new file mode 100644 index 000000000..7c40a6b90 --- /dev/null +++ b/packages/app-tests/e2e/license/license-status-banner.spec.ts @@ -0,0 +1,392 @@ +import { expect, test } from '@playwright/test'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import type { TCachedLicense } from '@documenso/lib/types/license'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; + +const LICENSE_FILE_NAME = '.documenso-license.json'; +const LICENSE_BACKUP_FILE_NAME = '.documenso-license-backup.json'; + +/** + * Get the path to the license file. + * + * The server reads from process.cwd() which is apps/remix when the dev server runs. + * Tests run from packages/app-tests, so we need to go up to the root then into apps/remix. + */ +const getLicenseFilePath = () => { + // From packages/app-tests/e2e/license -> ../../../../apps/remix/.documenso-license.json + return path.join(__dirname, '../../../../apps/remix', LICENSE_FILE_NAME); +}; + +/** + * Get the path to the backup license file. + */ +const getBackupLicenseFilePath = () => { + return path.join(__dirname, '../../../../apps/remix', LICENSE_BACKUP_FILE_NAME); +}; + +/** + * Backup the existing license file if it exists. + */ +const backupLicenseFile = async () => { + const licensePath = getLicenseFilePath(); + const backupPath = getBackupLicenseFilePath(); + + try { + await fs.access(licensePath); + await fs.rename(licensePath, backupPath); + } catch (e) { + // File doesn't exist, nothing to backup + console.log(e); + } +}; + +/** + * Restore the backup license file if it exists. + */ +const restoreLicenseFile = async () => { + const licensePath = getLicenseFilePath(); + const backupPath = getBackupLicenseFilePath(); + + try { + await fs.access(backupPath); + await fs.rename(backupPath, licensePath); + } catch (e) { + // Backup doesn't exist, nothing to restore + console.log(e); + } +}; + +/** + * Write a license file with the given data. + * Pass null to delete the license file. + */ +const writeLicenseFile = async (data: TCachedLicense | null) => { + const licensePath = getLicenseFilePath(); + + if (data === null) { + await fs.unlink(licensePath).catch(() => { + // File doesn't exist, ignore + }); + } else { + await fs.writeFile(licensePath, JSON.stringify(data, null, 2), 'utf-8'); + } +}; + +/** + * Create a mock license object with the given status and unauthorized flag. + */ +const createMockLicense = ( + status: 'ACTIVE' | 'EXPIRED' | 'PAST_DUE', + unauthorizedFlagUsage: boolean, +): TCachedLicense => { + return { + lastChecked: new Date().toISOString(), + license: { + status, + createdAt: new Date(), + name: 'Test License', + periodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days from now + cancelAtPeriodEnd: false, + licenseKey: 'test-license-key', + flags: {}, + }, + requestedLicenseKey: 'test-license-key', + derivedStatus: unauthorizedFlagUsage ? 'UNAUTHORIZED' : status, + unauthorizedFlagUsage, + }; +}; + +/** + * Create a mock license object with no license data (only unauthorized flag). + */ +const createMockUnauthorizedWithoutLicense = (): TCachedLicense => { + return { + lastChecked: new Date().toISOString(), + license: null, + unauthorizedFlagUsage: true, + derivedStatus: 'UNAUTHORIZED', + }; +}; + +// Run tests serially to avoid race conditions with the license file +test.describe.configure({ mode: 'serial' }); + +// SKIPPING TEST UNTIL WE ADD A WAY TO OVERRIDE THE LICENSE FILE. +test.describe.skip('License Status Banner', () => { + test.beforeAll(async () => { + // Backup any existing license file before running tests + await backupLicenseFile(); + }); + + test.afterAll(async () => { + // Restore the backup license file after all tests complete + await restoreLicenseFile(); + }); + + test.beforeEach(async () => { + // Clean up license file before each test to ensure clean state + await writeLicenseFile(null); + }); + + test.afterEach(async () => { + // Clean up license file after each test + await writeLicenseFile(null); + }); + + test('[ADMIN]: no banner when license file is missing', async ({ page }) => { + // Ensure no license file exists BEFORE any page loads + await writeLicenseFile(null); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + // Navigate to admin page - license is read during page load + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin', + }); + + // Verify we're on the admin page + await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible(); + + // Global banner should not be visible (no license file) + await expect( + page.getByText('This is an expired license instance of Documenso'), + ).not.toBeVisible(); + + // Admin banner messages should not be visible (no license file means no banner) + await expect(page.getByText('License payment overdue')).not.toBeVisible(); + await expect(page.getByText('License expired')).not.toBeVisible(); + await expect(page.getByText('Invalid License Type')).not.toBeVisible(); + await expect(page.getByText('Missing License')).not.toBeVisible(); + }); + + test('[ADMIN]: no banner when license is ACTIVE', async ({ page }) => { + // Create an ACTIVE license BEFORE any page loads + await writeLicenseFile(createMockLicense('ACTIVE', false)); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + // Navigate to admin page - license is read during page load + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin', + }); + + // Verify we're on the admin page + await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible(); + + // Global banner should not be visible (license is ACTIVE) + await expect( + page.getByText('This is an expired license instance of Documenso'), + ).not.toBeVisible(); + + // Admin banner messages should not be visible (license is ACTIVE) + await expect(page.getByText('License payment overdue')).not.toBeVisible(); + await expect(page.getByText('License expired')).not.toBeVisible(); + await expect(page.getByText('Invalid License Type')).not.toBeVisible(); + }); + + test('[ADMIN]: admin banner shows PAST_DUE warning', async ({ page }) => { + // Create a PAST_DUE license BEFORE any page loads + await writeLicenseFile(createMockLicense('PAST_DUE', false)); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + // Navigate to admin page - license is read during page load + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin', + }); + + // Verify we're on the admin page + await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible(); + + // Global banner should NOT be visible (only shows for EXPIRED + unauthorized) + await expect( + page.getByText('This is an expired license instance of Documenso'), + ).not.toBeVisible(); + + // Admin banner should show PAST_DUE message + await expect(page.getByText('License payment overdue')).toBeVisible(); + await expect( + page.getByText('Please update your payment to avoid service disruptions.'), + ).toBeVisible(); + + // Should have the "See Documentation" link + await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible(); + }); + + test('[ADMIN]: admin banner shows EXPIRED error', async ({ page }) => { + // Create an EXPIRED license WITHOUT unauthorized usage BEFORE any page loads + await writeLicenseFile(createMockLicense('EXPIRED', false)); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + // Navigate to admin page - license is read during page load + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin', + }); + + // Verify we're on the admin page + await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible(); + + // Global banner should NOT be visible (requires BOTH expired AND unauthorized) + await expect( + page.getByText('This is an expired license instance of Documenso'), + ).not.toBeVisible(); + + // Admin banner should show EXPIRED message + await expect(page.getByText('License expired')).toBeVisible(); + await expect( + page.getByText('Please renew your license to continue using enterprise features.'), + ).toBeVisible(); + + // Should have the "See Documentation" link + await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible(); + }); + + test.skip('[ADMIN]: global banner shows when EXPIRED with unauthorized usage', async ({ + page, + }) => { + // Create an EXPIRED license WITH unauthorized usage BEFORE any page loads + await writeLicenseFile(createMockLicense('EXPIRED', true)); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + // Navigate to admin page - license is read during page load + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin', + }); + + // Verify we're on the admin page + await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible(); + + // Global banner SHOULD be visible (EXPIRED + unauthorized) + await expect(page.getByText('This is an expired license instance of Documenso')).toBeVisible(); + + // Admin banner should show UNAUTHORIZED message (takes precedence over EXPIRED) + await expect(page.getByText('Invalid License Type')).toBeVisible(); + await expect( + page.getByText( + 'Your Documenso instance is using features that are not part of your license.', + ), + ).toBeVisible(); + }); + + test('[ADMIN]: admin banner shows UNAUTHORIZED when flags are misused with license', async ({ + page, + }) => { + // Create an ACTIVE license but WITH unauthorized flag usage BEFORE any page loads + await writeLicenseFile(createMockLicense('ACTIVE', true)); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + // Navigate to admin page - license is read during page load + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin', + }); + + // Verify we're on the admin page + await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible(); + + // Global banner should NOT be visible (requires EXPIRED status) + await expect( + page.getByText('This is an expired license instance of Documenso'), + ).not.toBeVisible(); + + // Admin banner should show UNAUTHORIZED message + await expect(page.getByText('Invalid License Type')).toBeVisible(); + await expect( + page.getByText( + 'Your Documenso instance is using features that are not part of your license.', + ), + ).toBeVisible(); + + // Should have the "See Documentation" link + await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible(); + }); + + test('[ADMIN]: admin banner shows Invalid License Type when unauthorized without license data', async ({ + page, + }) => { + // Create a license file with unauthorized flag but no license data BEFORE any page loads + // Note: Even without license data, the banner shows "Invalid License Type" because the + // license file exists (just with license: null). The "Missing License" message would only + // show if the entire license prop was null, which doesn't happen with a valid file. + await writeLicenseFile(createMockUnauthorizedWithoutLicense()); + + const { user: adminUser } = await seedUser({ + isAdmin: true, + }); + + // Navigate to admin page - license is read during page load + await apiSignin({ + page, + email: adminUser.email, + redirectPath: '/admin', + }); + + // Verify we're on the admin page + await expect(page.getByRole('heading', { name: 'Admin Panel' })).toBeVisible(); + + // Global banner should NOT be visible (no EXPIRED status, only unauthorized flag) + await expect( + page.getByText('This is an expired license instance of Documenso'), + ).not.toBeVisible(); + + // Admin banner should show Invalid License Type message (unauthorized flag is set) + await expect(page.getByText('Invalid License Type')).toBeVisible(); + await expect( + page.getByText( + 'Your Documenso instance is using features that are not part of your license.', + ), + ).toBeVisible(); + + // Should have the "See Documentation" link + await expect(page.getByRole('link', { name: 'See Documentation' })).toBeVisible(); + }); + + test.skip('[ADMIN]: global banner visible on non-admin pages when EXPIRED with unauthorized', async ({ + page, + }) => { + // Create an EXPIRED license WITH unauthorized usage BEFORE any page loads + await writeLicenseFile(createMockLicense('EXPIRED', true)); + + const { user } = await seedUser(); + + // Navigate to documents page - license is read during page load + await apiSignin({ + page, + email: user.email, + redirectPath: '/documents', + }); + + // Global banner SHOULD be visible on any authenticated page (EXPIRED + unauthorized) + await expect(page.getByText('This is an expired license instance of Documenso')).toBeVisible(); + }); +}); diff --git a/packages/app-tests/playwright.config.ts b/packages/app-tests/playwright.config.ts index c82e47f68..eba5f33f1 100644 --- a/packages/app-tests/playwright.config.ts +++ b/packages/app-tests/playwright.config.ts @@ -83,10 +83,21 @@ export default defineConfig({ testMatch: /e2e\/api\/.*\.spec\.ts/, workers: 10, // Limited by DB connections before it gets flakey. }, - // Run UI Tests + // License tests that share a single license file - must run serially + { + name: 'license', + testMatch: /e2e\/license\/.*\.spec\.ts/, + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1920, height: 1200 }, + }, + workers: 1, // Must run serially since they share a license file + }, + // Run UI Tests (excluding license tests which have their own project) { name: 'ui', testMatch: /e2e\/(?!api\/).*\.spec\.ts/, + testIgnore: /e2e\/license\/.*\.spec\.ts/, use: { ...devices['Desktop Chrome'], viewport: { width: 1920, height: 1200 }, diff --git a/packages/ee/FEATURES b/packages/ee/FEATURES index c557e79ef..50896cb3e 100644 --- a/packages/ee/FEATURES +++ b/packages/ee/FEATURES @@ -2,6 +2,7 @@ This file lists all features currently licensed under the Documenso Enterprise E Copyright (c) 2023 Documenso, Inc - The Stripe Billing Module +- Organisation Authentication Portal - Document Action Reauthentication (Passkeys and 2FA) - 21 CFR - Email domains diff --git a/packages/lib/server-only/license/license-client.ts b/packages/lib/server-only/license/license-client.ts new file mode 100644 index 000000000..0c03047dc --- /dev/null +++ b/packages/lib/server-only/license/license-client.ts @@ -0,0 +1,229 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { prisma } from '@documenso/prisma'; + +import { IS_BILLING_ENABLED } from '../../constants/app'; +import type { TLicenseClaim } from '../../types/license'; +import { + LICENSE_FILE_NAME, + type TCachedLicense, + type TLicenseResponse, + ZCachedLicenseSchema, + ZLicenseResponseSchema, +} from '../../types/license'; +import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '../../types/subscription'; +import { env } from '../../utils/env'; + +const LICENSE_KEY = env('NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY'); +const LICENSE_SERVER_URL = + env('INTERNAL_OVERRIDE_LICENSE_SERVER_URL') || 'https://license.documenso.com'; + +export class LicenseClient { + private static instance: LicenseClient | null = null; + + /** + * We cache the license in memory incase there is permission issues with + * retrieving the license from the local file system. + */ + private cachedLicense: TCachedLicense | null = null; + + private constructor() {} + + /** + * Start the license client. + * + * This will ping the license server with the configured license key and store + * the response locally in a JSON file. + */ + public static async start(): Promise { + if (LicenseClient.instance) { + return; + } + + const instance = new LicenseClient(); + + LicenseClient.instance = instance; + + try { + await instance.initialize(); + } catch (err) { + // Do nothing. + console.error('[License] Failed to verify license:', err); + } + } + + /** + * Get the current license client instance. + */ + public static getInstance(): LicenseClient | null { + return LicenseClient.instance; + } + + public async getCachedLicense(): Promise { + if (this.cachedLicense) { + return this.cachedLicense; + } + + const localLicenseFile = await this.loadFromFile(); + + return localLicenseFile; + } + + /** + * Force resync the license from the license server. + * + * This will re-ping the license server and update the cached license file. + */ + public async resync(): Promise { + await this.initialize(); + } + + private async initialize(): Promise { + console.log('[License] Checking license with server...'); + + const cachedLicense = await this.loadFromFile(); + + if (cachedLicense) { + this.cachedLicense = cachedLicense; + } + + const response = await this.pingLicenseServer(); + + // If server is not responding, or erroring, use the cached license. + if (!response) { + console.warn('[License] License server not responding, using cached license.'); + return; + } + + const allowedFlags = response?.data?.flags || {}; + + // Check for unauthorized flag usage + const unauthorizedFlagUsage = await this.checkUnauthorizedFlagUsage(allowedFlags); + + if (unauthorizedFlagUsage) { + console.warn('[License] Found unauthorized flag usage.'); + } + + let status: TCachedLicense['derivedStatus'] = 'NOT_FOUND'; + + if (response?.data?.status) { + status = response.data.status; + } + + if (unauthorizedFlagUsage) { + status = 'UNAUTHORIZED'; + } + + const data: TCachedLicense = { + lastChecked: new Date().toISOString(), + license: response?.data || null, + requestedLicenseKey: LICENSE_KEY, + unauthorizedFlagUsage, + derivedStatus: status, + }; + + this.cachedLicense = data; + await this.saveToFile(data); + + console.log('[License] License check completed successfully.'); + } + + /** + * Ping the license server to get the license response. + * + * If license not found returns null. + */ + private async pingLicenseServer(): Promise { + if (!LICENSE_KEY) { + return null; + } + + const endpoint = new URL('api/license', LICENSE_SERVER_URL).toString(); + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ license: LICENSE_KEY }), + }); + + if (!response.ok) { + throw new Error(`License server returned ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + + return ZLicenseResponseSchema.parse(data); + } + + private async saveToFile(data: TCachedLicense): Promise { + const licenseFilePath = path.join(process.cwd(), LICENSE_FILE_NAME); + + try { + await fs.writeFile(licenseFilePath, JSON.stringify(data, null, 2), 'utf-8'); + } catch (error) { + console.error('[License] Failed to save license file:', error); + } + } + + private async loadFromFile(): Promise { + const licenseFilePath = path.join(process.cwd(), LICENSE_FILE_NAME); + + try { + const fileContents = await fs.readFile(licenseFilePath, 'utf-8'); + + return ZCachedLicenseSchema.parse(JSON.parse(fileContents)); + } catch { + return null; + } + } + + /** + * Check if any organisation claims are using flags that are not permitted by the current license. + */ + private async checkUnauthorizedFlagUsage(licenseFlags: Partial): Promise { + // Get flags that are NOT permitted by the license by subtracting the allowed flags from the license flags. + const disallowedFlags = Object.values(SUBSCRIPTION_CLAIM_FEATURE_FLAGS).filter( + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + (flag) => flag.isEnterprise && !licenseFlags[flag.key as keyof TLicenseClaim], + ); + + let unauthorizedFlagUsage = false; + + if (IS_BILLING_ENABLED() && !licenseFlags.billing) { + unauthorizedFlagUsage = true; + } + + try { + const organisationWithUnauthorizedFlags = await prisma.organisationClaim.findFirst({ + where: { + OR: disallowedFlags.map((flag) => ({ + flags: { + path: [flag.key], + equals: true, + }, + })), + }, + select: { + id: true, + organisation: { + select: { + id: true, + }, + }, + flags: true, + }, + }); + + if (organisationWithUnauthorizedFlags) { + unauthorizedFlagUsage = true; + } + } catch (error) { + console.error('[License] Failed to check unauthorized flag usage:', error); + } + + return unauthorizedFlagUsage; + } +} diff --git a/packages/lib/types/license.ts b/packages/lib/types/license.ts new file mode 100644 index 000000000..b0194fff3 --- /dev/null +++ b/packages/lib/types/license.ts @@ -0,0 +1,83 @@ +import { z } from 'zod'; + +/** + * Note: Keep this in sync with the Documenso License Server schemas. + */ +export const ZLicenseClaimSchema = z.object({ + emailDomains: z.boolean().optional(), + embedAuthoring: z.boolean().optional(), + embedAuthoringWhiteLabel: z.boolean().optional(), + cfr21: z.boolean().optional(), + authenticationPortal: z.boolean().optional(), + billing: z.boolean().optional(), +}); + +/** + * Note: Keep this in sync with the Documenso License Server schemas. + */ +export const ZLicenseRequestSchema = z.object({ + license: z.string().min(1, 'License key is required'), +}); + +/** + * Note: Keep this in sync with the Documenso License Server schemas. + */ +export const ZLicenseResponseSchema = z.object({ + success: z.boolean(), + // Note that this is nullable, null means license was not found. + data: z + .object({ + status: z.enum(['ACTIVE', 'EXPIRED', 'PAST_DUE']), + createdAt: z.coerce.date(), + name: z.string(), + periodEnd: z.coerce.date(), + cancelAtPeriodEnd: z.boolean(), + licenseKey: z.string(), + flags: ZLicenseClaimSchema, + }) + .nullable(), +}); + +export type TLicenseClaim = z.infer; +export type TLicenseRequest = z.infer; +export type TLicenseResponse = z.infer; + +/** + * Schema for the cached license data stored in the file. + */ +export const ZCachedLicenseSchema = z.object({ + /** + * The last time the license was synced. + */ + lastChecked: z.string(), + + /** + * The raw license response from the license server. + */ + license: ZLicenseResponseSchema.shape.data, + + /** + * The license key that is currently stored on the system environment variable. + */ + requestedLicenseKey: z.string().optional(), + + /** + * Whether the current license has unauthorized flag usage. + */ + unauthorizedFlagUsage: z.boolean(), + + /** + * The derived status of the license. This is calculated based on the license response and the unauthorized flag usage. + */ + derivedStatus: z.enum([ + 'UNAUTHORIZED', // Unauthorized flag usage detected, overrides everything except PAST_DUE since that's a grace period. + 'ACTIVE', // License is active and everything is good. + 'EXPIRED', // License is expired and there is no unauthorized flag usage. + 'PAST_DUE', // License is past due. + 'NOT_FOUND', // Requested license key is not found. + ]), +}); + +export type TCachedLicense = z.infer; + +export const LICENSE_FILE_NAME = '.documenso-license.json'; diff --git a/packages/lib/types/subscription.ts b/packages/lib/types/subscription.ts index eba408cd8..580994428 100644 --- a/packages/lib/types/subscription.ts +++ b/packages/lib/types/subscription.ts @@ -42,6 +42,7 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record< { label: string; key: keyof TClaimFlags; + isEnterprise?: boolean; } > = { unlimitedDocuments: { @@ -59,10 +60,12 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record< emailDomains: { key: 'emailDomains', label: 'Email domains', + isEnterprise: true, }, embedAuthoring: { key: 'embedAuthoring', label: 'Embed authoring', + isEnterprise: true, }, embedSigning: { key: 'embedSigning', @@ -71,6 +74,7 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record< embedAuthoringWhiteLabel: { key: 'embedAuthoringWhiteLabel', label: 'White label for embed authoring', + isEnterprise: true, }, embedSigningWhiteLabel: { key: 'embedSigningWhiteLabel', @@ -79,10 +83,12 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record< cfr21: { key: 'cfr21', label: '21 CFR', + isEnterprise: true, }, authenticationPortal: { key: 'authenticationPortal', label: 'Authentication portal', + isEnterprise: true, }, allowLegacyEnvelopes: { key: 'allowLegacyEnvelopes', diff --git a/packages/trpc/server/admin-router/resync-license.ts b/packages/trpc/server/admin-router/resync-license.ts new file mode 100644 index 000000000..64048ce2c --- /dev/null +++ b/packages/trpc/server/admin-router/resync-license.ts @@ -0,0 +1,17 @@ +import { LicenseClient } from '@documenso/lib/server-only/license/license-client'; + +import { adminProcedure } from '../trpc'; +import { ZResyncLicenseRequestSchema, ZResyncLicenseResponseSchema } from './resync-license.types'; + +export const resyncLicenseRoute = adminProcedure + .input(ZResyncLicenseRequestSchema) + .output(ZResyncLicenseResponseSchema) + .mutation(async () => { + const client = LicenseClient.getInstance(); + + if (!client) { + return; + } + + await client.resync(); + }); diff --git a/packages/trpc/server/admin-router/resync-license.types.ts b/packages/trpc/server/admin-router/resync-license.types.ts new file mode 100644 index 000000000..652d4b588 --- /dev/null +++ b/packages/trpc/server/admin-router/resync-license.types.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const ZResyncLicenseRequestSchema = z.void(); + +export const ZResyncLicenseResponseSchema = z.void(); + +export type TResyncLicenseRequest = z.infer; +export type TResyncLicenseResponse = z.infer; diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index 7e1a7d5d7..c169e4813 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -17,6 +17,7 @@ import { getUserRoute } from './get-user'; import { promoteMemberToOwnerRoute } from './promote-member-to-owner'; import { resealDocumentRoute } from './reseal-document'; import { resetTwoFactorRoute } from './reset-two-factor-authentication'; +import { resyncLicenseRoute } from './resync-license'; import { updateAdminOrganisationRoute } from './update-admin-organisation'; import { updateOrganisationMemberRoleRoute } from './update-organisation-member-role'; import { updateRecipientRoute } from './update-recipient'; @@ -44,6 +45,9 @@ export const adminRouter = router({ stripe: { createCustomer: createStripeCustomerRoute, }, + license: { + resync: resyncLicenseRoute, + }, user: { get: getUserRoute, update: updateUserRoute, diff --git a/packages/tsconfig/process-env.d.ts b/packages/tsconfig/process-env.d.ts index 5a79b386f..4398d5fd2 100644 --- a/packages/tsconfig/process-env.d.ts +++ b/packages/tsconfig/process-env.d.ts @@ -2,6 +2,7 @@ declare namespace NodeJS { export interface ProcessEnv { PORT?: string; NEXT_PUBLIC_WEBAPP_URL?: string; + NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY?: string; NEXT_PRIVATE_GOOGLE_CLIENT_ID?: string; NEXT_PRIVATE_GOOGLE_CLIENT_SECRET?: string; diff --git a/turbo.json b/turbo.json index 7f54d9862..8bf840e53 100644 --- a/turbo.json +++ b/turbo.json @@ -50,6 +50,7 @@ "NEXT_PUBLIC_DISABLE_SIGNUP", "NEXT_PRIVATE_PLAIN_API_KEY", "NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT", + "NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY", "NEXT_PRIVATE_DATABASE_URL", "NEXT_PRIVATE_DIRECT_DATABASE_URL", "NEXT_PRIVATE_LOGGER_FILE_PATH", From 59a514c23810106ab49c8e6353295781a0bc6b52 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Thu, 29 Jan 2026 04:32:18 +0200 Subject: [PATCH 20/32] feat: allow non-team members as default recipients (#2404) --- ...efault-recipients-multiselect-combobox.tsx | 56 ++++++++++++++----- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx b/apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx index 3fbb9d424..b8fba1f7a 100644 --- a/apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx +++ b/apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx @@ -1,10 +1,13 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; +import { Trans, useLingui as useLinguiMacro } from '@lingui/react/macro'; import { RecipientRole } from '@prisma/client'; import type { TDefaultRecipient } from '@documenso/lib/types/default-recipients'; +import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients'; import { trpc } from '@documenso/trpc/react'; import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect'; +import { useToast } from '@documenso/ui/primitives/use-toast'; type DefaultRecipientsMultiSelectComboboxProps = { listValues: TDefaultRecipient[]; @@ -20,6 +23,8 @@ export const DefaultRecipientsMultiSelectCombobox = ({ organisationId, }: DefaultRecipientsMultiSelectComboboxProps) => { const { _ } = useLingui(); + const { t } = useLinguiMacro(); + const { toast } = useToast(); const { data: organisationData, isLoading: isLoadingOrganisation } = trpc.organisation.member.find.useQuery( @@ -60,31 +65,56 @@ export const DefaultRecipientsMultiSelectCombobox = ({ })); const onSelectionChange = (selected: Option[]) => { - const updatedRecipients = selected.map((option) => { - const existingRecipient = listValues.find((r) => r.email === option.value); - const member = members?.find((m) => m.email === option.value); + const invalidEmails = selected.filter( + (option) => !isRecipientEmailValidForSending({ email: option.value }), + ); - return { - email: option.value, - name: member?.name || option.value, - role: existingRecipient?.role ?? RecipientRole.CC, - }; - }); + if (invalidEmails.length > 0) { + toast({ + title: t`Invalid email`, + description: t`"${invalidEmails[0].value}" is not a valid email address.`, + variant: 'destructive', + }); + } + + const updatedRecipients = selected + .filter((option) => !invalidEmails.includes(option)) + .map((option) => { + const existingRecipient = listValues.find((r) => r.email === option.value); + const member = members?.find((m) => m.email === option.value); + + return { + email: option.value, + name: member?.name || option.value, + role: existingRecipient?.role ?? RecipientRole.CC, + }; + }); onChange(updatedRecipients); }; return ( Loading...

      : undefined} - emptyIndicator={

      No members found

      } + creatable + loadingIndicator={ + isLoading ? ( +

      + Loading... +

      + ) : undefined + } + emptyIndicator={ +

      + Type an email address to add a recipient +

      + } /> ); }; From a4d0e3e873a298e0be517c2aabb90e6862388b1b Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 29 Jan 2026 14:08:07 +1100 Subject: [PATCH 21/32] fix: resolve safari cert download issues (#2430) --- .../document-audit-log-download-button.tsx | 37 +++--------- .../document-certificate-download-button.tsx | 37 +++--------- .../download-document-audit-logs.ts | 28 ++++++--- .../download-document-audit-logs.types.ts | 3 +- .../download-document-certificate.ts | 59 +++++++++++++++---- .../download-document-certificate.types.ts | 3 +- 6 files changed, 90 insertions(+), 77 deletions(-) diff --git a/apps/remix/app/components/general/document/document-audit-log-download-button.tsx b/apps/remix/app/components/general/document/document-audit-log-download-button.tsx index 77e90eff8..9c8246e6c 100644 --- a/apps/remix/app/components/general/document/document-audit-log-download-button.tsx +++ b/apps/remix/app/components/general/document/document-audit-log-download-button.tsx @@ -3,6 +3,8 @@ import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import { DownloadIcon } from 'lucide-react'; +import { downloadFile } from '@documenso/lib/client-only/download-file'; +import { base64 } from '@documenso/lib/universal/base64'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -25,36 +27,15 @@ export const DocumentAuditLogDownloadButton = ({ const onDownloadAuditLogsClick = async () => { try { - const { url } = await downloadAuditLogs({ documentId }); + const { data, envelopeTitle } = await downloadAuditLogs({ documentId }); - const iframe = Object.assign(document.createElement('iframe'), { - src: url, + const buffer = new Uint8Array(base64.decode(data)); + const blob = new Blob([buffer], { type: 'application/pdf' }); + + downloadFile({ + data: blob, + filename: `${envelopeTitle} - Audit Logs.pdf`, }); - - Object.assign(iframe.style, { - position: 'fixed', - top: '0', - left: '0', - width: '0', - height: '0', - }); - - const onLoaded = () => { - if (iframe.contentDocument?.readyState === 'complete') { - iframe.contentWindow?.print(); - - iframe.contentWindow?.addEventListener('afterprint', () => { - document.body.removeChild(iframe); - }); - } - }; - - // When the iframe has loaded, print the iframe and remove it from the dom - iframe.addEventListener('load', onLoaded); - - document.body.appendChild(iframe); - - onLoaded(); } catch (error) { console.error(error); diff --git a/apps/remix/app/components/general/document/document-certificate-download-button.tsx b/apps/remix/app/components/general/document/document-certificate-download-button.tsx index 7584fe81b..67bc173c7 100644 --- a/apps/remix/app/components/general/document/document-certificate-download-button.tsx +++ b/apps/remix/app/components/general/document/document-certificate-download-button.tsx @@ -4,6 +4,8 @@ import { Trans } from '@lingui/react/macro'; import type { DocumentStatus } from '@prisma/client'; import { DownloadIcon } from 'lucide-react'; +import { downloadFile } from '@documenso/lib/client-only/download-file'; +import { base64 } from '@documenso/lib/universal/base64'; import { isDocumentCompleted } from '@documenso/lib/utils/document'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; @@ -29,36 +31,15 @@ export const DocumentCertificateDownloadButton = ({ const onDownloadCertificatesClick = async () => { try { - const { url } = await downloadCertificate({ documentId }); + const { data, envelopeTitle } = await downloadCertificate({ documentId }); - const iframe = Object.assign(document.createElement('iframe'), { - src: url, + const buffer = new Uint8Array(base64.decode(data)); + const blob = new Blob([buffer], { type: 'application/pdf' }); + + downloadFile({ + data: blob, + filename: `${envelopeTitle} - Certificate.pdf`, }); - - Object.assign(iframe.style, { - position: 'fixed', - top: '0', - left: '0', - width: '0', - height: '0', - }); - - const onLoaded = () => { - if (iframe.contentDocument?.readyState === 'complete') { - iframe.contentWindow?.print(); - - iframe.contentWindow?.addEventListener('afterprint', () => { - document.body.removeChild(iframe); - }); - } - }; - - // When the iframe has loaded, print the iframe and remove it from the dom - iframe.addEventListener('load', onLoaded); - - document.body.appendChild(iframe); - - onLoaded(); } catch (error) { console.error(error); diff --git a/packages/trpc/server/document-router/download-document-audit-logs.ts b/packages/trpc/server/document-router/download-document-audit-logs.ts index 7ce76049e..9da3c2a68 100644 --- a/packages/trpc/server/document-router/download-document-audit-logs.ts +++ b/packages/trpc/server/document-router/download-document-audit-logs.ts @@ -1,11 +1,9 @@ import { EnvelopeType } from '@prisma/client'; -import { DateTime } from 'luxon'; -import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { PDF_SIZE_A4_72PPI } from '@documenso/lib/constants/pdf'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; -import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt'; import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; -import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; +import { generateAuditLogPdf } from '@documenso/lib/server-only/pdf/generate-audit-log-pdf'; import { authenticatedProcedure } from '../trpc'; import { @@ -42,12 +40,26 @@ export const downloadDocumentAuditLogsRoute = authenticatedProcedure }); } - const encrypted = encryptSecondaryData({ - data: mapSecondaryIdToDocumentId(envelope.secondaryId).toString(), - expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(), + const certificatePdf = await generateAuditLogPdf({ + envelope, + recipients: envelope.recipients, + fields: envelope.fields, + language: envelope.documentMeta.language, + envelopeOwner: { + email: envelope.user.email, + name: envelope.user.name || '', + }, + envelopeItems: envelope.envelopeItems.map((item) => item.title), + pageWidth: PDF_SIZE_A4_72PPI.width, + pageHeight: PDF_SIZE_A4_72PPI.height, }); + const result = await certificatePdf.save(); + + const base64 = Buffer.from(result).toString('base64'); + return { - url: `${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encrypted}`, + data: base64, + envelopeTitle: envelope.title, }; }); diff --git a/packages/trpc/server/document-router/download-document-audit-logs.types.ts b/packages/trpc/server/document-router/download-document-audit-logs.types.ts index b4cc209c2..8e0ca39ad 100644 --- a/packages/trpc/server/document-router/download-document-audit-logs.types.ts +++ b/packages/trpc/server/document-router/download-document-audit-logs.types.ts @@ -5,7 +5,8 @@ export const ZDownloadDocumentAuditLogsRequestSchema = z.object({ }); export const ZDownloadDocumentAuditLogsResponseSchema = z.object({ - url: z.string(), + data: z.string(), + envelopeTitle: z.string(), }); export type TDownloadDocumentAuditLogsRequest = z.infer< diff --git a/packages/trpc/server/document-router/download-document-certificate.ts b/packages/trpc/server/document-router/download-document-certificate.ts index e81ec5082..82fc80588 100644 --- a/packages/trpc/server/document-router/download-document-certificate.ts +++ b/packages/trpc/server/document-router/download-document-certificate.ts @@ -1,12 +1,11 @@ import { EnvelopeType } from '@prisma/client'; -import { DateTime } from 'luxon'; -import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; -import { AppError } from '@documenso/lib/errors/app-error'; -import { encryptSecondaryData } from '@documenso/lib/server-only/crypto/encrypt'; -import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; +import { PDF_SIZE_A4_72PPI } from '@documenso/lib/constants/pdf'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; +import { generateCertificatePdf } from '@documenso/lib/server-only/pdf/generate-certificate-pdf'; import { isDocumentCompleted } from '@documenso/lib/utils/document'; -import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; +import { prisma } from '@documenso/prisma'; import { authenticatedProcedure } from '../trpc'; import { @@ -27,7 +26,7 @@ export const downloadDocumentCertificateRoute = authenticatedProcedure }, }); - const envelope = await getEnvelopeById({ + const { envelopeWhereInput } = await getEnvelopeWhereInput({ id: { type: 'documentId', id: documentId, @@ -37,16 +36,54 @@ export const downloadDocumentCertificateRoute = authenticatedProcedure teamId, }); + const envelope = await prisma.envelope.findFirst({ + where: envelopeWhereInput, + include: { + recipients: true, + fields: { + include: { + signature: true, + }, + }, + documentMeta: true, + user: { + select: { + email: true, + name: true, + }, + }, + }, + }); + + if (!envelope) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Envelope not found', + }); + } + if (!isDocumentCompleted(envelope.status)) { throw new AppError('DOCUMENT_NOT_COMPLETE'); } - const encrypted = encryptSecondaryData({ - data: mapSecondaryIdToDocumentId(envelope.secondaryId).toString(), - expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(), + const certificatePdf = await generateCertificatePdf({ + envelope, + recipients: envelope.recipients, + fields: envelope.fields, + language: envelope.documentMeta.language, + envelopeOwner: { + email: envelope.user.email, + name: envelope.user.name || '', + }, + pageWidth: PDF_SIZE_A4_72PPI.width, + pageHeight: PDF_SIZE_A4_72PPI.height, }); + const result = await certificatePdf.save(); + + const base64 = Buffer.from(result).toString('base64'); + return { - url: `${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/certificate?d=${encrypted}`, + data: base64, + envelopeTitle: envelope.title, }; }); diff --git a/packages/trpc/server/document-router/download-document-certificate.types.ts b/packages/trpc/server/document-router/download-document-certificate.types.ts index df81f1cad..ab0aeec22 100644 --- a/packages/trpc/server/document-router/download-document-certificate.types.ts +++ b/packages/trpc/server/document-router/download-document-certificate.types.ts @@ -5,7 +5,8 @@ export const ZDownloadDocumentCertificateRequestSchema = z.object({ }); export const ZDownloadDocumentCertificateResponseSchema = z.object({ - url: z.string(), + data: z.string(), + envelopeTitle: z.string(), }); export type TDownloadDocumentCertificateRequest = z.infer< From c83109628dc2efc52836b4653bbb112c77bdd621 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 29 Jan 2026 14:08:36 +1100 Subject: [PATCH 22/32] fix: add license logging (#2431) --- packages/lib/server-only/license/license-client.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/lib/server-only/license/license-client.ts b/packages/lib/server-only/license/license-client.ts index 0c03047dc..d64fec11f 100644 --- a/packages/lib/server-only/license/license-client.ts +++ b/packages/lib/server-only/license/license-client.ts @@ -127,6 +127,10 @@ export class LicenseClient { await this.saveToFile(data); console.log('[License] License check completed successfully.'); + console.log(`[License] Unauthorized Flag Usage: ${unauthorizedFlagUsage ? 'Yes' : 'No'}`); + console.log(`[License] Derived Status: ${status}`); + console.log(`[License] Status: ${response?.data?.status}`); + console.log(`[License] Flags: ${JSON.stringify(allowedFlags)}`); } /** From 97ceb317a81e0a5a50b88af7812fa2a1781ba1fc Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 29 Jan 2026 15:09:23 +1100 Subject: [PATCH 23/32] fix: license banner not correctly showing (#2432) --- .../general/admin-license-status-banner.tsx | 2 +- .../lib/server-only/license/license-client.ts | 28 +++++++++++++------ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/apps/remix/app/components/general/admin-license-status-banner.tsx b/apps/remix/app/components/general/admin-license-status-banner.tsx index 690f0bdfd..5dff17b09 100644 --- a/apps/remix/app/components/general/admin-license-status-banner.tsx +++ b/apps/remix/app/components/general/admin-license-status-banner.tsx @@ -14,7 +14,7 @@ export type AdminLicenseStatusBannerProps = { export const AdminLicenseStatusBanner = ({ license }: AdminLicenseStatusBannerProps) => { const licenseStatus = license?.derivedStatus; - if (!license || licenseStatus === 'ACTIVE') { + if (!license || licenseStatus === 'ACTIVE' || licenseStatus === 'NOT_FOUND') { return null; } diff --git a/packages/lib/server-only/license/license-client.ts b/packages/lib/server-only/license/license-client.ts index d64fec11f..4080660b7 100644 --- a/packages/lib/server-only/license/license-client.ts +++ b/packages/lib/server-only/license/license-client.ts @@ -19,9 +19,12 @@ const LICENSE_KEY = env('NEXT_PRIVATE_DOCUMENSO_LICENSE_KEY'); const LICENSE_SERVER_URL = env('INTERNAL_OVERRIDE_LICENSE_SERVER_URL') || 'https://license.documenso.com'; -export class LicenseClient { - private static instance: LicenseClient | null = null; +declare global { + // eslint-disable-next-line no-var + var __documenso_license_client__: LicenseClient | undefined; +} +export class LicenseClient { /** * We cache the license in memory incase there is permission issues with * retrieving the license from the local file system. @@ -35,15 +38,18 @@ export class LicenseClient { * * This will ping the license server with the configured license key and store * the response locally in a JSON file. + * + * Uses globalThis to store the singleton instance so that it's shared across + * different bundles (e.g. Hono and Remix) at runtime. */ public static async start(): Promise { - if (LicenseClient.instance) { + if (globalThis.__documenso_license_client__) { return; } const instance = new LicenseClient(); - LicenseClient.instance = instance; + globalThis.__documenso_license_client__ = instance; try { await instance.initialize(); @@ -55,9 +61,12 @@ export class LicenseClient { /** * Get the current license client instance. + * + * Returns the shared instance from globalThis, ensuring both Hono and Remix + * bundles access the same instance. */ public static getInstance(): LicenseClient | null { - return LicenseClient.instance; + return globalThis.__documenso_license_client__ ?? null; } public async getCachedLicense(): Promise { @@ -88,11 +97,14 @@ export class LicenseClient { this.cachedLicense = cachedLicense; } - const response = await this.pingLicenseServer(); + let response: TLicenseResponse | null = null; - // If server is not responding, or erroring, use the cached license. - if (!response) { + try { + response = await this.pingLicenseServer(); + } catch (err) { + // If server is not responding, or erroring, use the cached license. console.warn('[License] License server not responding, using cached license.'); + console.error(err); return; } From 25e148d4596314241a88ad0fdb220e768706c4df Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Thu, 29 Jan 2026 06:15:06 +0200 Subject: [PATCH 24/32] feat: update team member creation dialog with invite functionality (#2366) --- .../dialogs/team-member-create-dialog.tsx | 179 ++++++++++++++---- 1 file changed, 143 insertions(+), 36 deletions(-) diff --git a/apps/remix/app/components/dialogs/team-member-create-dialog.tsx b/apps/remix/app/components/dialogs/team-member-create-dialog.tsx index 8f822cfad..b8e5cfef5 100644 --- a/apps/remix/app/components/dialogs/team-member-create-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-member-create-dialog.tsx @@ -1,10 +1,10 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { Trans, useLingui } from '@lingui/react/macro'; import { TeamMemberRole } from '@prisma/client'; import type * as DialogPrimitive from '@radix-ui/react-dialog'; -import { InfoIcon } from 'lucide-react'; +import { InfoIcon, UserPlusIcon } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { Link } from 'react-router'; import { match } from 'ts-pattern'; @@ -13,6 +13,7 @@ import { z } from 'zod'; import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams'; import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations'; import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, @@ -44,6 +45,7 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; import { useToast } from '@documenso/ui/primitives/use-toast'; +import { OrganisationMemberInviteDialog } from '~/components/dialogs/organisation-member-invite-dialog'; import { useCurrentTeam } from '~/providers/team'; export type TeamMemberCreateDialogProps = { @@ -64,11 +66,14 @@ type TAddTeamMembersFormSchema = z.infer; export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDialogProps) => { const [open, setOpen] = useState(false); const [step, setStep] = useState<'SELECT' | 'MEMBERS'>('SELECT'); + const [inviteDialogOpen, setInviteDialogOpen] = useState(false); + const prevInviteDialogOpenRef = useRef(false); const { t } = useLingui(); const { toast } = useToast(); const team = useCurrentTeam(); + const utils = trpc.useUtils(); const form = useForm({ resolver: zodResolver(ZAddTeamMembersFormSchema), @@ -96,7 +101,29 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi ); }, [organisationMemberQuery, teamMemberQuery]); + const hasNoAvailableMembers = + !organisationMemberQuery.isLoading && avaliableOrganisationMembers.length === 0; + const onFormSubmit = async ({ members }: TAddTeamMembersFormSchema) => { + if (members.length === 0) { + if (hasNoAvailableMembers) { + setInviteDialogOpen(true); + return; + } + + // Don't show error if on SELECT step - the disabled Next button already communicates this + if (step === 'SELECT') { + return; + } + + toast({ + title: t`No members selected`, + description: t`Please select at least one member to add to the team.`, + variant: 'destructive', + }); + return; + } + try { await createTeamMembers({ teamId: team.id, @@ -123,9 +150,20 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi if (!open) { form.reset(); setStep('SELECT'); + setInviteDialogOpen(false); } }, [open, form]); + // Invalidate queries when invite dialog closes (transitions from true to false) to refresh available members + useEffect(() => { + if (prevInviteDialogOpenRef.current && !inviteDialogOpen) { + void utils.organisation.member.find.invalidate({ + organisationId: team.organisationId, + }); + } + prevInviteDialogOpenRef.current = inviteDialogOpen; + }, [inviteDialogOpen, utils, team.organisationId]); + return ( e.stopPropagation()} asChild> - + {trigger ?? ( + + )} @@ -149,7 +189,7 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi - + To be able to add members to a team, you must first add them to the organisation. For more information, please see the{' '} @@ -186,7 +226,18 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi .exhaustive()}
      - + { + if (e.key === 'Enter' && form.getValues('members').length === 0) { + e.preventDefault(); + if (hasNoAvailableMembers) { + setInviteDialogOpen(true); + } + // Don't show toast - the disabled Next button already communicates this + } + }} + >
      {step === 'SELECT' && ( <> @@ -194,46 +245,102 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi control={form.control} name="members" render={({ field }) => ( - + Members - ({ - label: member.name, - value: member.id, - }))} - loading={organisationMemberQuery.isLoading} - selectedValues={field.value.map( - (member) => member.organisationMemberId, - )} - onChange={(value) => { - field.onChange( - value.map((organisationMemberId) => ({ - organisationMemberId, - teamRole: - field.value.find( - (member) => - member.organisationMemberId === organisationMemberId, - )?.teamRole || TeamMemberRole.MEMBER, - })), - ); - }} - className="bg-background w-full" - emptySelectionPlaceholder={t`Select members`} - /> + {hasNoAvailableMembers ? ( +
      +
      + +
      +

      + No organisation members available +

      +

      + + To add members to this team, you must first add them to the + organisation. + +

      + + + Invite organisation members + + } + /> +
      + ) : ( + ({ + label: member.name, + value: member.id, + }))} + loading={organisationMemberQuery.isLoading} + selectedValues={field.value.map( + (member) => member.organisationMemberId, + )} + onChange={(value) => { + field.onChange( + value.map((organisationMemberId) => ({ + organisationMemberId, + teamRole: + field.value.find( + (member) => + member.organisationMemberId === organisationMemberId, + )?.teamRole || TeamMemberRole.MEMBER, + })), + ); + }} + className="w-full bg-background" + emptySelectionPlaceholder={t`Select members`} + /> + )}
      - - Select members to add to this team - + {!hasNoAvailableMembers && ( + <> + + Select members to add to this team + + + +
      + +
      + + Can't find someone?{' '} + + Invite them to the organisation first + + } + /> + +
      + + )}
      )} /> - + From 0f8b7670f488d7f7a88205405f45f73d37ac2a48 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Thu, 29 Jan 2026 16:05:08 +1100 Subject: [PATCH 25/32] fix: correct path prefix check for static assets caching (#2433) --- apps/remix/server/main.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/remix/server/main.js b/apps/remix/server/main.js index e0d54f65c..15fa84e12 100644 --- a/apps/remix/server/main.js +++ b/apps/remix/server/main.js @@ -17,7 +17,7 @@ server.use( serveStatic({ root: 'build/client', onFound: (path, c) => { - if (path.startsWith('./build/client/assets')) { + if (path.startsWith('build/client/assets')) { // Hard cache assets with hashed file names. c.header('Cache-Control', 'public, immutable, max-age=31536000'); } else { From eaee0d4bc6783e9a1d58254c986d7a21c7eca2c3 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Thu, 29 Jan 2026 18:44:58 +1100 Subject: [PATCH 26/32] v2.6.0 --- apps/remix/package.json | 2 +- package-lock.json | 6 +++--- package.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/remix/package.json b/apps/remix/package.json index baccc9898..d288b9dc8 100644 --- a/apps/remix/package.json +++ b/apps/remix/package.json @@ -106,5 +106,5 @@ "vite-plugin-babel-macros": "^1.0.6", "vite-tsconfig-paths": "^5.1.4" }, - "version": "2.5.1" + "version": "2.6.0" } diff --git a/package-lock.json b/package-lock.json index 001b67b7f..04c5bf7fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@documenso/root", - "version": "2.5.1", + "version": "2.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@documenso/root", - "version": "2.5.1", + "version": "2.6.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -108,7 +108,7 @@ }, "apps/remix": { "name": "@documenso/remix", - "version": "2.5.1", + "version": "2.6.0", "dependencies": { "@cantoo/pdf-lib": "^2.5.3", "@documenso/api": "*", diff --git a/package.json b/package.json index d6fe33e35..d2f892d2e 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "apps/*", "packages/*" ], - "version": "2.5.1", + "version": "2.6.0", "scripts": { "postinstall": "patch-package", "build": "turbo run build", From 8704c731c08f1bb72b68052602cabc1e9e007bce Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Thu, 29 Jan 2026 23:34:46 +1100 Subject: [PATCH 27/32] chore: upgrade libpdf (#2435) --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 04c5bf7fb..d4914cc34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "dependencies": { "@ai-sdk/google-vertex": "3.0.81", "@documenso/prisma": "*", - "@libpdf/core": "^0.2.2", + "@libpdf/core": "^0.2.5", "@lingui/conf": "^5.6.0", "@lingui/core": "^5.6.0", "ai": "^5.0.104", @@ -4167,9 +4167,9 @@ "license": "MIT" }, "node_modules/@libpdf/core": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.2.2.tgz", - "integrity": "sha512-qxYHUirZc4YSxTYkUVtLHqM9ypC9iKPOpHYfpDIUYAZyDGgcHh4SOwhkInHY/Q51TBajXRv6ZZd9fjSPvoQZnw==", + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.2.5.tgz", + "integrity": "sha512-+oTpNRkEdL1kVmeJr6qz2wf0yqJx5FmUVN2u0kDuX81wvxyzYOlMjmFD8qbbJqyYiNZp0J7IAcW6VsZr+MW1Uw==", "license": "MIT", "dependencies": { "@noble/ciphers": "^2.1.1", diff --git a/package.json b/package.json index d2f892d2e..3caf4d260 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,7 @@ "dependencies": { "@ai-sdk/google-vertex": "3.0.81", "@documenso/prisma": "*", - "@libpdf/core": "^0.2.2", + "@libpdf/core": "^0.2.5", "@lingui/conf": "^5.6.0", "@lingui/core": "^5.6.0", "ai": "^5.0.104", From 2df41b9f01f9142e19d3398eafd36f319fda9889 Mon Sep 17 00:00:00 2001 From: Konrad <11725227+mKoonrad@users.noreply.github.com> Date: Fri, 30 Jan 2026 02:30:33 +0100 Subject: [PATCH 28/32] feat(ui): rename sign up button for better clarity (#2427) --- apps/remix/app/components/forms/signup.tsx | 2 +- packages/app-tests/e2e/user/auth-flow.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/remix/app/components/forms/signup.tsx b/apps/remix/app/components/forms/signup.tsx index 1c4be853f..39cc4f0f3 100644 --- a/apps/remix/app/components/forms/signup.tsx +++ b/apps/remix/app/components/forms/signup.tsx @@ -402,7 +402,7 @@ export const SignUpForm = ({ size="lg" className="mt-6 w-full" > - Complete + Create account diff --git a/packages/app-tests/e2e/user/auth-flow.spec.ts b/packages/app-tests/e2e/user/auth-flow.spec.ts index f56d09a8f..8eda007b3 100644 --- a/packages/app-tests/e2e/user/auth-flow.spec.ts +++ b/packages/app-tests/e2e/user/auth-flow.spec.ts @@ -23,7 +23,7 @@ test('[USER] can sign up with email and password', async ({ page }: { page: Page await signSignaturePad(page); - await page.getByRole('button', { name: 'Complete', exact: true }).click(); + await page.getByRole('button', { name: 'Create account', exact: true }).click(); await page.waitForURL('/unverified-account'); From 39ebc8184af437612160fcb474bf428d0c8a7f3c Mon Sep 17 00:00:00 2001 From: Konrad <11725227+mKoonrad@users.noreply.github.com> Date: Fri, 30 Jan 2026 02:43:27 +0100 Subject: [PATCH 29/32] fix(i18n): add pluralization to envelopes-bulk-delete-dialog.tsx (#2428) --- .../dialogs/envelopes-bulk-delete-dialog.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx b/apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx index 47820314d..c99972fbb 100644 --- a/apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelopes-bulk-delete-dialog.tsx @@ -1,3 +1,4 @@ +import { plural } from '@lingui/core/macro'; import { Plural, useLingui } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro'; import { EnvelopeType } from '@prisma/client'; @@ -51,13 +52,22 @@ export const EnvelopesBulkDeleteDialog = ({ if (result.failedIds.length > 0) { toast({ title: isDocument ? t`Documents partially deleted` : t`Templates partially deleted`, - description: t`${result.deletedCount} item(s) deleted. ${result.failedIds.length} item(s) could not be deleted.`, + description: t`${plural(result.deletedCount, { + one: '# item deleted.', + other: '# items deleted.', + })} ${plural(result.failedIds.length, { + one: '# item could not be deleted.', + other: '# items could not be deleted.', + })}`, variant: 'destructive', }); } else { toast({ title: isDocument ? t`Documents deleted` : t`Templates deleted`, - description: t`${result.deletedCount} item(s) have been deleted.`, + description: plural(result.deletedCount, { + one: '# item has been deleted.', + other: '# items have been deleted.', + }), variant: 'default', }); } From 594a0f0c3f142c08f001a98acd9bb8524f00df86 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Mon, 2 Feb 2026 11:36:06 +1100 Subject: [PATCH 30/32] fix: store formValues in database when creating document from template (#2437) --- .../lib/server-only/template/create-document-from-template.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/lib/server-only/template/create-document-from-template.ts b/packages/lib/server-only/template/create-document-from-template.ts index 230b0a0e3..bb9ba9db2 100644 --- a/packages/lib/server-only/template/create-document-from-template.ts +++ b/packages/lib/server-only/template/create-document-from-template.ts @@ -551,6 +551,7 @@ export const createDocumentFromTemplate = async ({ visibility: template.visibility || settings.documentVisibility, useLegacyFieldInsertion: template.useLegacyFieldInsertion ?? false, documentMetaId: documentMeta.id, + formValues: formValues ?? undefined, recipients: { createMany: { data: allFinalRecipients.map((recipient) => { From 1669216a913bd67b82e7cc2b13143a03583abe16 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Tue, 3 Feb 2026 14:24:23 +1100 Subject: [PATCH 31/32] fix: flatten pdf-lib form fields before sealing document (#2441) - Fixes checkbox fields not displaying correctly in sealed documents by calling `flatten()` on the pdf-lib form before saving --- .../lib/jobs/definitions/internal/seal-document.handler.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/lib/jobs/definitions/internal/seal-document.handler.ts b/packages/lib/jobs/definitions/internal/seal-document.handler.ts index b46996e51..242e1db5d 100644 --- a/packages/lib/jobs/definitions/internal/seal-document.handler.ts +++ b/packages/lib/jobs/definitions/internal/seal-document.handler.ts @@ -388,6 +388,10 @@ const decorateAndSignPdf = async ({ } } + // Should never run into issues with this flatten since all + // arcoFields are created by pdf-lib itself. + legacy_pdfLibDoc.getForm().flatten(); + await pdfDoc.reload(await legacy_pdfLibDoc.save()); } From 9b190ef58288810daa19e2f7aef164a9d2a2d755 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Wed, 4 Feb 2026 03:41:46 +0200 Subject: [PATCH 32/32] docs: add info callout for enterprise-only embedded authoring (#2443) --- apps/documentation/pages/developers/_meta.js | 3 ++- .../authoring.mdx => embedded-authoring.mdx} | 15 ++++++++++++++- .../pages/developers/embedding/_meta.js | 1 - .../pages/developers/embedding/index.mdx | 8 +++++++- 4 files changed, 23 insertions(+), 4 deletions(-) rename apps/documentation/pages/developers/{embedding/authoring.mdx => embedded-authoring.mdx} (94%) diff --git a/apps/documentation/pages/developers/_meta.js b/apps/documentation/pages/developers/_meta.js index 2f0f9b24d..4286dc3a3 100644 --- a/apps/documentation/pages/developers/_meta.js +++ b/apps/documentation/pages/developers/_meta.js @@ -13,6 +13,7 @@ export default { title: 'API & Integration Guides', }, 'public-api': 'Public API', - embedding: 'Embedding', + embedding: 'Embedded Signing', + 'embedded-authoring': 'Embedded Authoring', webhooks: 'Webhooks', }; diff --git a/apps/documentation/pages/developers/embedding/authoring.mdx b/apps/documentation/pages/developers/embedded-authoring.mdx similarity index 94% rename from apps/documentation/pages/developers/embedding/authoring.mdx rename to apps/documentation/pages/developers/embedded-authoring.mdx index d3427f1c8..4cf6fd659 100644 --- a/apps/documentation/pages/developers/embedding/authoring.mdx +++ b/apps/documentation/pages/developers/embedded-authoring.mdx @@ -1,12 +1,25 @@ --- -title: Authoring +title: Embedded Authoring description: Learn how to use embedded authoring to create documents and templates in your application --- +import { Callout } from 'nextra/components'; + + + The embedded authoring feature is an enterprise only feature. Please contact us if you are + interested in using it. + + # Embedded Authoring In addition to embedding signing experiences, Documenso now supports embedded authoring, allowing you to integrate document and template creation and editing directly within your application. +## Embedded Signing vs Embedded Authoring + +Embedded signing allows you to embed your Documenso documents into your application for signing. Your users will be able to sign the document directly in your application. + +Embedded authoring allows you to integrate Documenso's document and template creation and editing into your application. You will be able to create and edit documents and templates directly in your application. + ## How Embedded Authoring Works The embedded authoring feature enables your users to create and edit documents and templates without leaving your application. This process works through secure presign tokens that authenticate the embedding session and manage permissions. diff --git a/apps/documentation/pages/developers/embedding/_meta.js b/apps/documentation/pages/developers/embedding/_meta.js index bfa67f172..c666bdaa1 100644 --- a/apps/documentation/pages/developers/embedding/_meta.js +++ b/apps/documentation/pages/developers/embedding/_meta.js @@ -7,5 +7,4 @@ export default { preact: 'Preact Integration', angular: 'Angular Integration', 'css-variables': 'CSS Variables', - authoring: 'Authoring', }; diff --git a/apps/documentation/pages/developers/embedding/index.mdx b/apps/documentation/pages/developers/embedding/index.mdx index 7fd3d4193..6d8d6531a 100644 --- a/apps/documentation/pages/developers/embedding/index.mdx +++ b/apps/documentation/pages/developers/embedding/index.mdx @@ -3,10 +3,16 @@ title: Get Started description: Learn how to use embedding to bring signing to your own website or application --- -# Embedding +# Embedded Signing Our embedding feature lets you integrate our document signing experience into your own application or website. Whether you're building with React, Preact, Vue, Svelte, Solid, Angular, or using generalized web components, this guide will help you get started with embedding Documenso. +## Embedded Signing vs Embedded Authoring + +Embedded signing allows you to embed your Documenso documents into your application for signing. Your users will be able to sign the document directly in your application. + +Embedded authoring allows you to integrate Documenso's document and template creation and editing into your application. You will be able to create and edit documents and templates directly in your application. + ## Availability Embedding is currently available for all users on a **Teams Plan** and above, as well as **Early Adopter's** within a team (Early Adopters can create a team for free).