From 307b0f5afdb311470ba3a8bb1b04464a44e46208 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Tue, 13 Jan 2026 09:44:58 +0200 Subject: [PATCH 01/19] fix build --- Dockerfile | 41 ++++++++++++++++++++--------------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3ce83e55..2e54bf38 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,7 +47,7 @@ FROM ruby:3.4.2-alpine AS app ENV RAILS_ENV=production ENV BUNDLE_WITHOUT="development:test" ENV LD_PRELOAD=/lib/libgcompat.so.0 -ENV OPENSSL_CONF=/app/openssl_legacy.cnf +ENV OPENSSL_CONF=/etc/openssl_legacy.cnf WORKDIR /app @@ -65,36 +65,35 @@ legacy = legacy_sect\n\ activate = 1\n\ \n\ [legacy_sect]\n\ -activate = 1' >> /app/openssl_legacy.cnf +activate = 1' >> /etc/openssl_legacy.cnf -COPY ./Gemfile ./Gemfile.lock ./ +COPY --chown=docuseal:docuseal ./Gemfile ./Gemfile.lock ./ RUN apk add --no-cache build-base && bundle install && apk del --no-cache build-base && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf && ln -sf /usr/lib/libonnxruntime.so.1 $(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first") RUN echo 'https://dl-cdn.alpinelinux.org/alpine/edge/main' >> /etc/apk/repositories && echo 'https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache onnxruntime -COPY ./bin ./bin -COPY ./app ./app -COPY ./config ./config -COPY ./db/migrate ./db/migrate -COPY ./log ./log -COPY ./lib ./lib -COPY ./public ./public -COPY ./tmp ./tmp -COPY LICENSE README.md Rakefile config.ru .version ./ -COPY .version ./public/version - -COPY --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts +COPY --chown=docuseal:docuseal ./bin ./bin +COPY --chown=docuseal:docuseal ./app ./app +COPY --chown=docuseal:docuseal ./config ./config +COPY --chown=docuseal:docuseal ./db/migrate ./db/migrate +COPY --chown=docuseal:docuseal ./log ./log +COPY --chown=docuseal:docuseal ./lib ./lib +COPY --chown=docuseal:docuseal ./public ./public +COPY --chown=docuseal:docuseal ./tmp ./tmp +COPY --chown=docuseal:docuseal LICENSE README.md Rakefile config.ru .version ./ +COPY --chown=docuseal:docuseal .version ./public/version + +COPY --chown=docuseal:docuseal --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts COPY --from=download /fonts/FreeSans.ttf /usr/share/fonts/freefont COPY --from=download /pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so COPY --from=download /pdfium-linux/licenses/pdfium.txt /usr/lib/libpdfium-LICENSE.txt -COPY --from=download /model.onnx /app/tmp/model.onnx -COPY --from=webpack /app/public/packs ./public/packs +COPY --chown=docuseal:docuseal --from=download /model.onnx /app/tmp/model.onnx +COPY --chown=docuseal:docuseal --from=webpack /app/public/packs ./public/packs -RUN ln -s /fonts /app/public/fonts -RUN bundle exec bootsnap precompile -j 1 --gemfile app/ lib/ - -RUN chown -R docuseal:docuseal /app +RUN ln -s /fonts /app/public/fonts && \ + bundle exec bootsnap precompile -j 1 --gemfile app/ lib/ && \ + chown -R docuseal:docuseal /app/tmp/cache WORKDIR /data/docuseal ENV HOME=/home/docuseal From 794ea4d0e810c5b386a373c0b892bb14928be696 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Tue, 13 Jan 2026 12:07:39 +0200 Subject: [PATCH 02/19] remove expired from pending filter --- app/models/submission.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/models/submission.rb b/app/models/submission.rb index 5630af76..d57ae5f8 100644 --- a/app/models/submission.rb +++ b/app/models/submission.rb @@ -78,8 +78,9 @@ class Submission < ApplicationRecord scope :active, -> { where(archived_at: nil) } scope :archived, -> { where.not(archived_at: nil) } scope :pending, lambda { - where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id]) - .and(Submitter.arel_table[:completed_at].eq(nil))).select(1).arel.exists) + where(expire_at: nil).or(where(expire_at: Time.current..)) + .where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id]) + .and(Submitter.arel_table[:completed_at].eq(nil))).select(1).arel.exists) } scope :completed, lambda { where.not(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id]) From 2fe32a2145685f817b0c983c003ddaf5e57e2f3b Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Tue, 13 Jan 2026 12:17:03 +0200 Subject: [PATCH 03/19] show version --- app/views/shared/_settings_nav.html.erb | 2 +- lib/docuseal.rb | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/views/shared/_settings_nav.html.erb b/app/views/shared/_settings_nav.html.erb index 8e26a8fb..ca074009 100644 --- a/app/views/shared/_settings_nav.html.erb +++ b/app/views/shared/_settings_nav.html.erb @@ -135,7 +135,7 @@ <%= Docuseal::SUPPORT_EMAIL %> - <% if Docuseal.version.present? %> + <% if Docuseal.version.present? && !Docuseal.multitenant? && can?(:manage, EncryptedConfig) %> v<%= Docuseal.version %> diff --git a/lib/docuseal.rb b/lib/docuseal.rb index 578d1fcc..7bba653c 100644 --- a/lib/docuseal.rb +++ b/lib/docuseal.rb @@ -39,6 +39,7 @@ module Docuseal CERTS = JSON.parse(ENV.fetch('CERTS', '{}')) TIMESERVER_URL = ENV.fetch('TIMESERVER_URL', nil) VERSION_FILE_PATH = Rails.root.join('.version') + VERSION_FILE2_PATH = Rails.public_path.join('version') DEFAULT_URL_OPTIONS = { host: HOST, @@ -48,7 +49,12 @@ module Docuseal module_function def version - @version ||= VERSION_FILE_PATH.read.strip if VERSION_FILE_PATH.exist? + @version ||= + if VERSION_FILE_PATH.exist? + VERSION_FILE_PATH.read.strip + elsif VERSION_FILE2_PATH.exist? + VERSION_FILE2_PATH.each_line.first.to_s.strip + end end def multitenant? From f8be19c20e46296b744f3a060fd11df93bfc61a7 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Tue, 13 Jan 2026 19:58:54 +0200 Subject: [PATCH 04/19] pg 18 --- docker-compose.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index a30ce3a6..fcc697be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,9 +13,9 @@ services: - DATABASE_URL=postgresql://postgres:postgres@postgres:5432/docuseal postgres: - image: postgres:15 + image: postgres:18 volumes: - - './pg_data:/var/lib/postgresql/data' + - './pg_data:/var/lib/postgresql/18/docker' environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres @@ -34,6 +34,6 @@ services: - 443:443 - 443:443/udp volumes: - - .:/data + - ./caddy:/data/caddy environment: - HOST=${HOST} From c05f8d47aafad35cee27cd2c9ac47f6243204190 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Wed, 14 Jan 2026 12:44:27 +0200 Subject: [PATCH 05/19] require 2fa --- app/views/submissions/_detailed_form.html.erb | 1 + app/views/submissions/_email_form.html.erb | 3 +- app/views/submissions/_extra_fields.html.erb | 0 .../submissions/_extra_phone_fields.html.erb | 0 app/views/submissions/_phone_form.html.erb | 3 +- config/locales/i18n.yml | 28 +++++++++++++++++++ 6 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 app/views/submissions/_extra_fields.html.erb create mode 100644 app/views/submissions/_extra_phone_fields.html.erb diff --git a/app/views/submissions/_detailed_form.html.erb b/app/views/submissions/_detailed_form.html.erb index 44e87732..6284c9ed 100644 --- a/app/views/submissions/_detailed_form.html.erb +++ b/app/views/submissions/_detailed_form.html.erb @@ -99,6 +99,7 @@ <% if has_phone_field %> <%= render 'send_sms', f: %> <% end %> + <%= render 'extra_fields', f: %>
<%= f.button button_title(title: t('add_recipients')), class: 'base-button' %> diff --git a/app/views/submissions/_email_form.html.erb b/app/views/submissions/_email_form.html.erb index b50595d2..a88e3a0b 100644 --- a/app/views/submissions/_email_form.html.erb +++ b/app/views/submissions/_email_form.html.erb @@ -47,8 +47,9 @@ <% end %>
- <%= render 'send_email', f:, template: %> <%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %> + <%= render 'send_email', f:, template: %> + <%= render 'extra_fields', f: %>
<%= content_for(:submit_button) || capture do %> diff --git a/app/views/submissions/_extra_fields.html.erb b/app/views/submissions/_extra_fields.html.erb new file mode 100644 index 00000000..e69de29b diff --git a/app/views/submissions/_extra_phone_fields.html.erb b/app/views/submissions/_extra_phone_fields.html.erb new file mode 100644 index 00000000..e69de29b diff --git a/app/views/submissions/_phone_form.html.erb b/app/views/submissions/_phone_form.html.erb index 305f6e87..68bf6d45 100644 --- a/app/views/submissions/_phone_form.html.erb +++ b/app/views/submissions/_phone_form.html.erb @@ -58,7 +58,8 @@
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %> - <%= render 'send_sms', f: %> + <%= render 'send_sms', f:, checked: true %> + <%= render 'extra_phone_fields', f: %>
<%= f.button button_title(title: t('add_recipients')), class: 'base-button' %> diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml index 6a399d1a..faa6cbed 100644 --- a/config/locales/i18n.yml +++ b/config/locales/i18n.yml @@ -29,6 +29,10 @@ en: &en pro: Pro thanks: Thanks private: Private + require_email_2fa: Require email 2FA + when_checked_each_signer_must_verify_their_email_with_a_one_time_code_before_accessing_the_document: When checked, each signer must verify their email with a one-time code before accessing the document. + require_phone_2fa: Require phone 2FA + when_checked_each_signer_must_verify_their_phone_with_a_one_time_code_before_accessing_the_document: When checked, each signer must verify their phone with a one-time code before accessing the document. select: Select enabled: Enabled disabled: Disabled @@ -1025,6 +1029,10 @@ es: &es stripe_account_has_been_connected: La cuenta de Stripe ha sido conectada. re_connect_stripe: Volver a conectar Stripe private: Privado + require_email_2fa: Requerir 2FA por correo electrónico + when_checked_each_signer_must_verify_their_email_with_a_one_time_code_before_accessing_the_document: Cuando está marcado, cada firmante debe verificar su correo electrónico con un código de un solo uso antes de acceder al documento. + require_phone_2fa: Requerir 2FA por teléfono + when_checked_each_signer_must_verify_their_phone_with_a_one_time_code_before_accessing_the_document: Cuando está marcado, cada firmante debe verificar su teléfono con un código de un solo uso antes de acceder al documento. resend_pending: Reenviar pendiente ensure_unique_recipients: Asegurar destinatarios únicos require_phone_2fa_to_open: Requiere 2FA por teléfono para abrir @@ -2003,6 +2011,10 @@ it: &it stripe_account_has_been_connected: L'account Stripe è stato collegato. re_connect_stripe: Ricollega Stripe private: Privato + require_email_2fa: Richiedi 2FA email + when_checked_each_signer_must_verify_their_email_with_a_one_time_code_before_accessing_the_document: Quando selezionato, ogni firmatario deve verificare la propria email con un codice monouso prima di accedere al documento. + require_phone_2fa: Richiedi 2FA telefono + when_checked_each_signer_must_verify_their_phone_with_a_one_time_code_before_accessing_the_document: Quando selezionato, ogni firmatario deve verificare il proprio numero di telefono con un codice monouso prima di accedere al documento. resend_pending: Reinvia in sospeso ensure_unique_recipients: Assicurarsi destinatari unici require_phone_2fa_to_open: Richiedi l'autenticazione a due fattori tramite telefono per aprire @@ -2982,6 +2994,10 @@ fr: &fr stripe_account_has_been_connected: Le compte Stripe a été connecté. re_connect_stripe: Reconnecter Stripe private: Privé + require_email_2fa: Exiger la 2FA par email + when_checked_each_signer_must_verify_their_email_with_a_one_time_code_before_accessing_the_document: Lorsque coché, chaque signataire doit vérifier son email avec un code à usage unique avant d'accéder au document. + require_phone_2fa: Exiger la 2FA par téléphone + when_checked_each_signer_must_verify_their_phone_with_a_one_time_code_before_accessing_the_document: Lorsque coché, chaque signataire doit vérifier son numéro de téléphone avec un code à usage unique avant d'accéder au document. resend_pending: Renvoyer en attente ensure_unique_recipients: Garantir des destinataires uniques require_phone_2fa_to_open: Exiger la 2FA par téléphone pour ouvrir @@ -3957,6 +3973,10 @@ pt: &pt stripe_account_has_been_connected: Conta Stripe foi conectada. re_connect_stripe: Reconectar Stripe private: Privado + require_email_2fa: Exigir 2FA por email + when_checked_each_signer_must_verify_their_email_with_a_one_time_code_before_accessing_the_document: Quando marcado, cada signatário deve verificar seu email com um código de uso único antes de acessar o documento. + require_phone_2fa: Exigir 2FA por telefone + when_checked_each_signer_must_verify_their_phone_with_a_one_time_code_before_accessing_the_document: Quando marcado, cada signatário deve verificar seu telefone com um código de uso único antes de acessar o documento. resend_pending: Re-enviar pendente ensure_unique_recipients: Garantir destinatários únicos require_phone_2fa_to_open: Necessário autenticação de dois fatores via telefone para abrir @@ -4921,6 +4941,10 @@ de: &de pro: Pro thanks: Danke private: Privat + require_email_2fa: E-Mail 2FA erforderlich + when_checked_each_signer_must_verify_their_email_with_a_one_time_code_before_accessing_the_document: Wenn aktiviert, muss jeder Unterzeichner seine E-Mail mit einem einmaligen Code verifizieren, bevor er auf das Dokument zugreifen kann. + require_phone_2fa: Telefon 2FA erforderlich + when_checked_each_signer_must_verify_their_phone_with_a_one_time_code_before_accessing_the_document: Wenn aktiviert, muss jeder Unterzeichner sein Telefon mit einem einmaligen Code verifizieren, bevor er auf das Dokument zugreifen kann. select: Auswählen enabled: Aktiviert disabled: Deaktiviert @@ -6287,6 +6311,10 @@ nl: &nl pro: Pro thanks: Bedankt private: Privé + require_email_2fa: E-mail 2FA vereist + when_checked_each_signer_must_verify_their_email_with_a_one_time_code_before_accessing_the_document: Wanneer aangevinkt, moet elke ondertekenaar zijn e-mail verifiëren met een eenmalige code voordat hij toegang krijgt tot het document. + require_phone_2fa: Telefoon 2FA vereist + when_checked_each_signer_must_verify_their_phone_with_a_one_time_code_before_accessing_the_document: Wanneer aangevinkt, moet elke ondertekenaar zijn telefoon verifiëren met een eenmalige code voordat hij toegang krijgt tot het document. select: Selecteer enabled: Ingeschakeld disabled: Uitgeschakeld From ce12af68b26f5bdbe26882d2b9403c84af93a50c Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Wed, 14 Jan 2026 14:32:00 +0200 Subject: [PATCH 06/19] fix required --- app/views/submissions/_detailed_form.html.erb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/submissions/_detailed_form.html.erb b/app/views/submissions/_detailed_form.html.erb index 6284c9ed..ac4c2607 100644 --- a/app/views/submissions/_detailed_form.html.erb +++ b/app/views/submissions/_detailed_form.html.erb @@ -30,14 +30,14 @@
"> - <%= tag.input type: 'email', multiple: true, name: 'submission[1][submitters][][email]', autocomplete: 'off', class: 'base-input !h-10 mt-1.5 w-full', placeholder: (local_assigns[:require_email_2fa] == true || local_assigns[:prefillable_fields].present? ? t(:email) : "#{t('email')} (#{t('optional')})"), value: item['email'].presence || ((params[:selfsign] && index.zero?) || item['is_requester'] ? current_user.email : ''), id: "detailed_email_#{item['uuid']}", required: local_assigns[:require_email_2fa] == true %> + <%= tag.input type: 'email', multiple: true, name: 'submission[1][submitters][][email]', autocomplete: 'off', class: 'base-input !h-10 mt-1.5 w-full', placeholder: (local_assigns[:require_email_2fa] == true || local_assigns[:prefillable_fields].present? ? t(:email) : "#{t('email')} (#{t('optional')})"), value: item['email'].presence || ((params[:selfsign] && index.zero?) || item['is_requester'] ? current_user.email : ''), id: "detailed_email_#{item['uuid']}", required: local_assigns[:require_email_2fa] == true && index.zero? %> <% has_phone_field = true %> "> - <%= tag.input type: 'tel', pattern: '^\+[0-9\s\-]+$', name: 'submission[1][submitters][][phone]', autocomplete: 'off', class: 'base-input !h-10 mt-1.5 w-full', placeholder: local_assigns[:require_phone_2fa] == true || local_assigns[:prefillable_fields].present? ? t(:phone) : "#{t('phone')} (#{t('optional')})", id: "detailed_phone_#{item['uuid']}", required: local_assigns[:require_phone_2fa] == true %> + <%= tag.input type: 'tel', pattern: '^\+[0-9\s\-]+$', name: 'submission[1][submitters][][phone]', autocomplete: 'off', class: 'base-input !h-10 mt-1.5 w-full', placeholder: local_assigns[:require_phone_2fa] == true || local_assigns[:prefillable_fields].present? ? t(:phone) : "#{t('phone')} (#{t('optional')})", id: "detailed_phone_#{item['uuid']}", required: local_assigns[:require_phone_2fa] == true && index.zero? %> From 6060a2b7987eca067d3320e1cd0d4e2f2644dc8a Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Wed, 14 Jan 2026 21:58:30 +0200 Subject: [PATCH 07/19] fix icon --- app/views/submissions/show.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/submissions/show.html.erb b/app/views/submissions/show.html.erb index 51dfa59c..a40d0c9a 100644 --- a/app/views/submissions/show.html.erb +++ b/app/views/submissions/show.html.erb @@ -133,7 +133,7 @@ <% bg_class = bg_classes[submitter_index % bg_classes.size] %>
- <%= svg_icon(SubmissionsController::FIELD_ICONS[field['type']], class: 'max-h-10 w-full h-full stroke-2 opacity-50') %> + <%= svg_icon(SubmissionsController::FIELD_ICONS.fetch(field['type'], 'text_size'), class: 'max-h-10 w-full h-full stroke-2 opacity-50') %>
<% end %> From 092a19966c868c89800682c5fc4ed615e82daff6 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Thu, 15 Jan 2026 17:23:26 +0200 Subject: [PATCH 08/19] adjust condition --- app/javascript/submission_form/form.vue | 24 +++++++++++++++++++----- lib/submitters/submit_values.rb | 25 +++++++++++++++++++++---- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/app/javascript/submission_form/form.vue b/app/javascript/submission_form/form.vue index fafccea7..a0a7940d 100644 --- a/app/javascript/submission_form/form.vue +++ b/app/javascript/submission_form/form.vue @@ -1244,17 +1244,31 @@ export default { } else if (['equal', 'contains'].includes(condition.action) && field) { if (field.options) { const option = field.options.find((o) => o.uuid === condition.value) - const values = [this.values[condition.field_uuid] ?? defaultValue].flat() - return values.includes(this.optionValue(option, field.options.indexOf(option))) + if (option) { + const values = [this.values[condition.field_uuid] ?? defaultValue].flat() + + return values.includes(this.optionValue(option, field.options.indexOf(option))) + } else { + return false + } } else { return [this.values[condition.field_uuid] ?? defaultValue].flat().includes(condition.value) } } else if (['not_equal', 'does_not_contain'].includes(condition.action) && field) { - const option = field.options.find((o) => o.uuid === condition.value) - const values = [this.values[condition.field_uuid] ?? defaultValue].flat() + if (field.options) { + const option = field.options.find((o) => o.uuid === condition.value) - return !values.includes(this.optionValue(option, field.options.indexOf(option))) + if (option) { + const values = [this.values[condition.field_uuid] ?? defaultValue].flat() + + return !values.includes(this.optionValue(option, field.options.indexOf(option))) + } else { + return false + } + } else { + return false + } } else { return true } diff --git a/lib/submitters/submit_values.rb b/lib/submitters/submit_values.rb index 613fca55..0c327acf 100644 --- a/lib/submitters/submit_values.rb +++ b/lib/submitters/submit_values.rb @@ -318,21 +318,38 @@ module Submitters end def check_field_condition(condition, submitter_values, fields_uuid_index) + value = submitter_values[condition['field_uuid']] + case condition['action'] when 'empty', 'unchecked' - submitter_values[condition['field_uuid']].blank? + value.blank? when 'not_empty', 'checked' - submitter_values[condition['field_uuid']].present? + value.present? when 'equal', 'contains' field = fields_uuid_index[condition['field_uuid']] + + return true unless field + + values = Array.wrap(value) + + return values.include?(condition['value']) unless field['options'] + option = field['options'].find { |o| o['uuid'] == condition['value'] } - values = Array.wrap(submitter_values[condition['field_uuid']]) + + return false unless option values.include?(option['value'].presence || "#{I18n.t('option')} #{field['options'].index(option) + 1}") when 'not_equal', 'does_not_contain' field = fields_uuid_index[condition['field_uuid']] + + return true unless field + return false unless field['options'] + option = field['options'].find { |o| o['uuid'] == condition['value'] } - values = Array.wrap(submitter_values[condition['field_uuid']]) + + return false unless option + + values = Array.wrap(value) values.exclude?(option['value'].presence || "#{I18n.t('option')} #{field['options'].index(option) + 1}") else From bf53204a410096e3ba4110164028fa96683849aa Mon Sep 17 00:00:00 2001 From: Alex Turchyn Date: Fri, 16 Jan 2026 12:07:59 +0200 Subject: [PATCH 09/19] add context menu to template builder --- app/javascript/template_builder/builder.vue | 132 +++++-- .../template_builder/context_menu.vue | 357 ++++++++++++++++++ app/javascript/template_builder/document.vue | 6 +- app/javascript/template_builder/i18n.js | 28 +- app/javascript/template_builder/page.vue | 103 ++++- 5 files changed, 582 insertions(+), 44 deletions(-) create mode 100644 app/javascript/template_builder/context_menu.vue diff --git a/app/javascript/template_builder/builder.vue b/app/javascript/template_builder/builder.vue index 13af471a..2eb1d0a8 100644 --- a/app/javascript/template_builder/builder.vue +++ b/app/javascript/template_builder/builder.vue @@ -376,6 +376,8 @@ @draw="[onDraw($event), withSelectedFieldType ? '' : drawFieldType = '', showDrawField = false]" @drop-field="onDropfield" @remove-area="removeArea" + @paste-field="pasteField" + @copy-field="copyField" /> f.areas?.includes(this.copiedArea)) - const currentArea = this.selectedAreaRef?.value || this.copiedArea + copyField () { + const area = this.selectedAreaRef.value - if (field && currentArea) { - const area = { - ...JSON.parse(JSON.stringify(this.copiedArea)), - attachment_uuid: currentArea.attachment_uuid, - page: currentArea.page, - x: currentArea.x, - y: currentArea.y + currentArea.h * 1.3 - } + if (!area) return - if (['radio', 'multiple'].includes(field.type)) { - this.copiedArea.option_uuid ||= field.options[0].uuid - area.option_uuid = v4() + const field = this.template.fields.find((f) => f.areas?.includes(area)) - const lastOption = field.options[field.options.length - 1] + if (!field) return - if (!field.areas.find((a) => lastOption.uuid === a.option_uuid)) { - area.option_uuid = lastOption.uuid - } else { - field.options.push({ uuid: area.option_uuid }) - } + const clipboardData = { + field: JSON.parse(JSON.stringify(field)), + area: JSON.parse(JSON.stringify(area)), + templateId: this.template.id, + timestamp: Date.now() + } + + delete clipboardData.field.areas + delete clipboardData.field.uuid + delete clipboardData.field.submitter_uuid + + try { + localStorage.setItem('docuseal_clipboard', JSON.stringify(clipboardData)) + } catch (e) { + console.error('Failed to save clipboard:', e) + } + }, + pasteField (targetPosition = null) { + let field = null + let area = null + let isSameTemplate = false + + const clipboard = localStorage.getItem('docuseal_clipboard') + + if (clipboard) { + const data = JSON.parse(clipboard) - field.areas.push(area) + if (Date.now() - data.timestamp < 3600000) { + field = data.field + area = data.area + isSameTemplate = data.templateId === this.template.id } else { - const newField = { - ...JSON.parse(JSON.stringify(field)), - uuid: v4(), - areas: [area] - } + localStorage.removeItem('docuseal_clipboard') + } + } + + if (!field || !area) return + + if (!isSameTemplate) { + delete field.conditions + delete field.preferences?.formula + } + + const currentArea = this.selectedAreaRef.value + const defaultAttachmentUuid = this.template.schema[0]?.attachment_uuid - this.insertField(newField) + if (field && currentArea) { + const attachmentUuid = targetPosition?.attachment_uuid || + (this.template.documents.find((d) => d.uuid === currentArea.attachment_uuid) ? currentArea.attachment_uuid : null) || + defaultAttachmentUuid + + const newArea = { + ...JSON.parse(JSON.stringify(area)), + attachment_uuid: attachmentUuid, + page: targetPosition?.page ?? (attachmentUuid === currentArea.attachment_uuid ? currentArea.page : 0), + x: targetPosition ? (targetPosition.x - area.w / 2) : currentArea.x, + y: targetPosition ? (targetPosition.y - area.h / 2) : (currentArea.y + currentArea.h * 1.3) } - this.selectedAreaRef.value = area + const newField = { + ...JSON.parse(JSON.stringify(field)), + uuid: v4(), + submitter_uuid: this.selectedSubmitter.uuid, + areas: [newArea] + } + + if (['radio', 'multiple'].includes(field.type) && field.options?.length) { + const oldOptionUuid = area.option_uuid + const optionsMap = {} + + newField.options = field.options.map((opt) => { + const newUuid = v4() + optionsMap[opt.uuid] = newUuid + return { ...opt, uuid: newUuid } + }) + + newArea.option_uuid = optionsMap[oldOptionUuid] || newField.options[0].uuid + } + + this.insertField(newField) + + this.selectedAreaRef.value = newArea this.save() } }, + hasClipboardData () { + try { + const clipboard = localStorage.getItem('docuseal_clipboard') + + if (clipboard) { + const data = JSON.parse(clipboard) + + return Date.now() - data.timestamp < 3600000 + } + + return false + } catch { + return false + } + }, pushUndo () { const stringData = JSON.stringify(this.template) diff --git a/app/javascript/template_builder/context_menu.vue b/app/javascript/template_builder/context_menu.vue new file mode 100644 index 00000000..5323b44a --- /dev/null +++ b/app/javascript/template_builder/context_menu.vue @@ -0,0 +1,357 @@ + + + diff --git a/app/javascript/template_builder/document.vue b/app/javascript/template_builder/document.vue index e512fb2c..8cd267da 100644 --- a/app/javascript/template_builder/document.vue +++ b/app/javascript/template_builder/document.vue @@ -22,8 +22,10 @@ :selected-submitter="selectedSubmitter" :total-pages="sortedPreviewImages.length" :image="image" - @drop-field="$emit('drop-field', {...$event, attachment_uuid: document.uuid })" + @drop-field="$emit('drop-field', { ...$event, attachment_uuid: document.uuid })" @remove-area="$emit('remove-area', $event)" + @copy-field="$emit('copy-field', $event)" + @paste-field="$emit('paste-field', { ...$event, attachment_uuid: document.uuid })" @scroll-to="scrollToArea" @draw="$emit('draw', { area: {...$event.area, attachment_uuid: document.uuid }, isTooSmall: $event.isTooSmall })" /> @@ -118,7 +120,7 @@ export default { default: false } }, - emits: ['draw', 'drop-field', 'remove-area'], + emits: ['draw', 'drop-field', 'remove-area', 'paste-field', 'copy-field'], data () { return { pageRefs: [] diff --git a/app/javascript/template_builder/i18n.js b/app/javascript/template_builder/i18n.js index 5cbc6a19..7f4bfade 100644 --- a/app/javascript/template_builder/i18n.js +++ b/app/javascript/template_builder/i18n.js @@ -185,7 +185,9 @@ const en = { start_tour: 'Start Tour', or_add_from: 'Or add from', sync: 'Sync', - syncing: 'Syncing...' + syncing: 'Syncing...', + copy: 'Copy', + paste: 'Paste' } const es = { @@ -375,7 +377,9 @@ const es = { start_tour: 'Iniciar guía', or_add_from: 'O agregar desde', sync: 'Sincronizar', - syncing: 'Sincronizando...' + syncing: 'Sincronizando...', + copy: 'Copiar', + paste: 'Pegar' } const it = { @@ -565,7 +569,9 @@ const it = { start_tour: 'Inizia il tour', or_add_from: 'O aggiungi da', sync: 'Sincronizza', - syncing: 'Sincronizzazione...' + syncing: 'Sincronizzazione...', + copy: 'Copia', + paste: 'Incolla' } const pt = { @@ -755,7 +761,9 @@ const pt = { start_tour: 'Iniciar tour', or_add_from: 'Ou adicionar de', sync: 'Sincronizar', - syncing: 'Sincronizando...' + syncing: 'Sincronizando...', + copy: 'Copiar', + paste: 'Colar' } const fr = { @@ -945,7 +953,9 @@ const fr = { start_tour: 'Démarrer', or_add_from: 'Ou ajouter depuis', sync: 'Synchroniser', - syncing: 'Synchronisation...' + syncing: 'Synchronisation...', + copy: 'Copier', + paste: 'Coller' } const de = { @@ -1135,7 +1145,9 @@ const de = { start_tour: 'Tour starten', or_add_from: 'Oder hinzufügen aus', sync: 'Synchronisieren', - syncing: 'Synchronisiere...' + syncing: 'Synchronisiere...', + copy: 'Kopieren', + paste: 'Einfügen' } const nl = { @@ -1325,7 +1337,9 @@ const nl = { start_tour: 'Rondleiding starten', or_add_from: 'Of toevoegen van', sync: 'Synchroniseren', - syncing: 'Synchroniseren...' + syncing: 'Synchroniseren...', + copy: 'Kopiëren', + paste: 'Plakken' } export { en, es, it, pt, fr, de, nl } diff --git a/app/javascript/template_builder/page.vue b/app/javascript/template_builder/page.vue index 75ddf023..da3f7614 100644 --- a/app/javascript/template_builder/page.vue +++ b/app/javascript/template_builder/page.vue @@ -17,6 +17,7 @@
+
import FieldArea from './area' +import ContextMenu from './context_menu' export default { name: 'TemplatePage', components: { - FieldArea + FieldArea, + ContextMenu }, - inject: ['fieldTypes', 'defaultDrawFieldType', 'fieldsDragFieldRef', 'assignDropAreaSize'], + inject: ['fieldTypes', 'defaultDrawFieldType', 'fieldsDragFieldRef', 'assignDropAreaSize', 'selectedAreaRef'], props: { image: { type: Object, @@ -157,13 +172,14 @@ export default { required: true } }, - emits: ['draw', 'drop-field', 'remove-area', 'scroll-to'], + emits: ['draw', 'drop-field', 'remove-area', 'copy-field', 'paste-field', 'scroll-to'], data () { return { areaRefs: [], showMask: false, resizeDirection: null, - newArea: null + newArea: null, + contextMenu: null } }, computed: { @@ -211,6 +227,81 @@ export default { this.image.metadata.width = e.target.naturalWidth this.image.metadata.height = e.target.naturalHeight }, + openContextMenu (event) { + if (!this.editable) { + return + } + + event.preventDefault() + event.stopPropagation() + + const rect = this.$refs.image.getBoundingClientRect() + + this.newArea = null + this.showMask = false + + this.contextMenu = { + x: event.clientX, + y: event.clientY, + relativeX: (event.clientX - rect.left) / rect.width, + relativeY: (event.clientY - rect.top) / rect.height + } + }, + openAreaContextMenu (event, area, field) { + if (!this.editable) { + return + } + + event.preventDefault() + event.stopPropagation() + + const rect = this.$refs.image.getBoundingClientRect() + + this.newArea = null + this.showMask = false + + this.contextMenu = { + x: event.clientX, + y: event.clientY, + relativeX: (event.clientX - rect.left) / rect.width, + relativeY: (event.clientY - rect.top) / rect.height, + area, + field + } + }, + closeContextMenu () { + this.contextMenu = null + this.newArea = null + this.showMask = false + }, + handleCopy () { + if (this.contextMenu.area) { + this.selectedAreaRef.value = this.contextMenu.area + + this.$emit('copy-field') + } + + this.closeContextMenu() + }, + handleDelete () { + if (this.contextMenu.area) { + this.$emit('remove-area', this.contextMenu.area) + } + + this.closeContextMenu() + }, + handlePaste () { + this.newArea = null + this.showMask = false + + this.$emit('paste-field', { + page: this.number, + x: this.contextMenu.relativeX, + y: this.contextMenu.relativeY + }) + + this.closeContextMenu() + }, setAreaRefs (el) { if (el) { this.areaRefs.push(el) @@ -243,6 +334,10 @@ export default { }) }, onStartDraw (e) { + if (e.button === 2) { + return + } + if (!this.allowDraw) { return } From 5212773d0a36d5af9b06c55dd47e6bc2d1ce6c83 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Fri, 16 Jan 2026 12:18:17 +0200 Subject: [PATCH 10/19] hide area name menu on desktop --- app/javascript/template_builder/area.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/template_builder/area.vue b/app/javascript/template_builder/area.vue index d88475b3..33e2fc6f 100644 --- a/app/javascript/template_builder/area.vue +++ b/app/javascript/template_builder/area.vue @@ -108,7 +108,7 @@ >{{ t('editable') }} From bf02cf4058e540a144b738872cc81e81a4f93d6b Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Fri, 16 Jan 2026 12:25:50 +0200 Subject: [PATCH 11/19] fix paste --- app/javascript/template_builder/builder.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/template_builder/builder.vue b/app/javascript/template_builder/builder.vue index 2eb1d0a8..16ed503e 100644 --- a/app/javascript/template_builder/builder.vue +++ b/app/javascript/template_builder/builder.vue @@ -1553,7 +1553,7 @@ export default { const currentArea = this.selectedAreaRef.value const defaultAttachmentUuid = this.template.schema[0]?.attachment_uuid - if (field && currentArea) { + if (field && (currentArea || targetPosition)) { const attachmentUuid = targetPosition?.attachment_uuid || (this.template.documents.find((d) => d.uuid === currentArea.attachment_uuid) ? currentArea.attachment_uuid : null) || defaultAttachmentUuid From d1cebc92f76ed513b3d35e2eb9fb994ea203d540 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Fri, 16 Jan 2026 12:38:29 +0200 Subject: [PATCH 12/19] fix eslint --- app/javascript/template_builder/builder.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/app/javascript/template_builder/builder.vue b/app/javascript/template_builder/builder.vue index 16ed503e..306efd9a 100644 --- a/app/javascript/template_builder/builder.vue +++ b/app/javascript/template_builder/builder.vue @@ -2027,6 +2027,7 @@ export default { }, onDocumentReplace (data) { const { replaceSchemaItem, schema, documents } = data + // eslint-disable-next-line camelcase const { google_drive_file_id, ...cleanedReplaceSchemaItem } = replaceSchemaItem this.template.schema.splice(this.template.schema.indexOf(replaceSchemaItem), 1, { ...cleanedReplaceSchemaItem, ...schema[0] }) From bc8eb33f050112aeba446896245004c8550a0f70 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Fri, 16 Jan 2026 23:24:35 +0200 Subject: [PATCH 13/19] Revert "hide area name menu on desktop" This reverts commit 5212773d0a36d5af9b06c55dd47e6bc2d1ce6c83. --- app/javascript/template_builder/area.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/template_builder/area.vue b/app/javascript/template_builder/area.vue index 33e2fc6f..d88475b3 100644 --- a/app/javascript/template_builder/area.vue +++ b/app/javascript/template_builder/area.vue @@ -108,7 +108,7 @@ >{{ t('editable') }} From 43c41e25577ef51790e3d2a6849f80e8acf150d2 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Sat, 17 Jan 2026 19:04:43 +0200 Subject: [PATCH 14/19] add select fields mode --- app/javascript/template_builder/area.vue | 75 +++- app/javascript/template_builder/builder.vue | 359 +++++++++++++++--- .../template_builder/conditions_modal.vue | 23 +- .../template_builder/context_menu.vue | 190 ++++++++- app/javascript/template_builder/document.vue | 6 +- app/javascript/template_builder/field.vue | 4 +- app/javascript/template_builder/fields.vue | 6 +- .../template_builder/font_modal.vue | 13 +- app/javascript/template_builder/i18n.js | 63 ++- app/javascript/template_builder/page.vue | 223 ++++++++++- .../template_builder/selection_box.vue | 100 +++++ 11 files changed, 956 insertions(+), 106 deletions(-) create mode 100644 app/javascript/template_builder/selection_box.vue diff --git a/app/javascript/template_builder/area.vue b/app/javascript/template_builder/area.vue index d88475b3..664acf96 100644 --- a/app/javascript/template_builder/area.vue +++ b/app/javascript/template_builder/area.vue @@ -8,7 +8,7 @@ @touchstart="startTouchDrag" >
@@ -24,7 +24,7 @@ :style="{ left: (cellW / area.w * 100) + '%' }" >
@@ -266,7 +266,7 @@ ref="defaultValueSelect" class="bg-transparent outline-none focus:outline-none w-full" @change="[field.default_value = $event.target.value, field.readonly = !!field.default_value?.length, save()]" - @focus="selectedAreaRef.value = area" + @focus="selectedAreasRef.value = [area]" @keydown.enter="onDefaultValueEnter" >
@@ -140,8 +216,11 @@ to="#docuseal_modal_container" > @@ -160,7 +239,7 @@ From b1a0afea66ae13cd7cba25fb9ee1356f4ee34ce5 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Sun, 18 Jan 2026 10:29:15 +0200 Subject: [PATCH 15/19] fix menu --- app/javascript/template_builder/context_menu.vue | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/javascript/template_builder/context_menu.vue b/app/javascript/template_builder/context_menu.vue index d312fb42..9de39eb9 100644 --- a/app/javascript/template_builder/context_menu.vue +++ b/app/javascript/template_builder/context_menu.vue @@ -188,7 +188,7 @@
Date: Sun, 18 Jan 2026 17:22:55 +0200 Subject: [PATCH 16/19] detect fields in context menu --- .../templates_detect_fields_controller.rb | 5 +- app/javascript/template_builder/builder.vue | 230 ++++++++++++++++++ .../template_builder/context_menu.vue | 24 +- app/javascript/template_builder/document.vue | 9 +- app/javascript/template_builder/i18n.js | 28 ++- app/javascript/template_builder/page.vue | 17 +- lib/templates/detect_fields.rb | 25 +- 7 files changed, 317 insertions(+), 21 deletions(-) diff --git a/app/controllers/templates_detect_fields_controller.rb b/app/controllers/templates_detect_fields_controller.rb index 8355dcb2..337e8008 100644 --- a/app/controllers/templates_detect_fields_controller.rb +++ b/app/controllers/templates_detect_fields_controller.rb @@ -11,11 +11,14 @@ class TemplatesDetectFieldsController < ApplicationController sse = SSE.new(response.stream) documents = @template.schema_documents.preload(:blob) + documents = documents.where(uuid: params[:attachment_uuid]) if params[:attachment_uuid].present? + + page_number = params[:page].present? ? params[:page].to_i : nil documents.each do |document| io = StringIO.new(document.download) - Templates::DetectFields.call(io, attachment: document) do |(attachment_uuid, page, fields)| + Templates::DetectFields.call(io, attachment: document, page_number:) do |(attachment_uuid, page, fields)| sse.write({ attachment_uuid:, page:, fields: }) end end diff --git a/app/javascript/template_builder/builder.vue b/app/javascript/template_builder/builder.vue index 34340fda..bb43cdfb 100644 --- a/app/javascript/template_builder/builder.vue +++ b/app/javascript/template_builder/builder.vue @@ -373,6 +373,7 @@ :draw-field-type="drawFieldType" :editable="editable" :base-url="baseUrl" + :with-fields-detection="withFieldsDetection" @draw="[onDraw($event), withSelectedFieldType ? '' : drawFieldType = '', showDrawField = false]" @drop-field="onDropfield" @remove-area="removeArea" @@ -381,6 +382,7 @@ @copy-selected-areas="copySelectedAreas" @delete-selected-areas="deleteSelectedAreas" @align-selected-areas="alignSelectedAreas" + @autodetect-fields="detectFieldsForPage" />
+ +
+ +
+