From ea3dec443fbae5ad6d8249168136c5b694e35e2d Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Mon, 7 Jul 2025 15:40:23 +0300 Subject: [PATCH 01/14] refactor --- app/controllers/webhook_events_controller.rb | 2 +- app/views/webhook_events/_drawer_events.html.erb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/webhook_events_controller.rb b/app/controllers/webhook_events_controller.rb index fbb81b9a..55bcb4ea 100644 --- a/app/controllers/webhook_events_controller.rb +++ b/app/controllers/webhook_events_controller.rb @@ -52,7 +52,7 @@ class WebhookEventsController < ApplicationController turbo_stream.replace(helpers.dom_id(@webhook_event), partial: 'event_row', locals: { with_status: true, webhook_url: @webhook_url, webhook_event: @webhook_event }), - turbo_stream.replace("drawer_events_#{helpers.dom_id(@webhook_event)}", + turbo_stream.replace(helpers.dom_id(@webhook_event, :drawer_events), partial: 'drawer_events', locals: { webhook_url: @webhook_url, webhook_event: @webhook_event }) ] diff --git a/app/views/webhook_events/_drawer_events.html.erb b/app/views/webhook_events/_drawer_events.html.erb index c75f54c8..275259c6 100644 --- a/app/views/webhook_events/_drawer_events.html.erb +++ b/app/views/webhook_events/_drawer_events.html.erb @@ -1,4 +1,4 @@ -
+
    <% webhook_attempts = webhook_event.webhook_attempts.sort_by { |e| -e.id } %> <% if webhook_event.status == 'error' %> From 0ff87383d023f8a1c7c56009f00be5bc9ba0322c Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Tue, 8 Jul 2025 16:48:57 +0300 Subject: [PATCH 02/14] fix selfsign --- app/controllers/start_form_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/start_form_controller.rb b/app/controllers/start_form_controller.rb index 1cecfba6..4a237bf7 100644 --- a/app/controllers/start_form_controller.rb +++ b/app/controllers/start_form_controller.rb @@ -173,7 +173,7 @@ class StartFormController < ApplicationController end def submitter_params - return current_user.slice(:email) if params[:selfsign] + return { 'email' => current_user.email, 'name' => current_user.full_name } if params[:selfsign] return @resubmit_submitter.slice(:name, :phone, :email) if @resubmit_submitter.present? params.require(:submitter).permit(:email, :phone, :name).tap do |attrs| From 82a7df5e3fc3acde117930def1e0b3c4a868197d Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Thu, 10 Jul 2025 12:13:27 +0300 Subject: [PATCH 03/14] add pdf/a-3b --- lib/docuseal.rb | 4 ++++ lib/submissions/generate_audit_trail.rb | 7 ++++++- .../generate_combined_attachment.rb | 5 +++++ .../generate_result_attachments.rb | 19 +++++++++++++++++++ 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/lib/docuseal.rb b/lib/docuseal.rb index 23c02271..e4fee8f7 100644 --- a/lib/docuseal.rb +++ b/lib/docuseal.rb @@ -88,6 +88,10 @@ module Docuseal true end + def pdf_format + @pdf_format ||= ENV['PDF_FORMAT'].to_s.downcase + end + def trusted_certs @trusted_certs ||= ENV['TRUSTED_CERTS'].to_s.gsub('\\n', "\n").split("\n\n").map do |base64| diff --git a/lib/submissions/generate_audit_trail.rb b/lib/submissions/generate_audit_trail.rb index 51d3e190..3b74244d 100644 --- a/lib/submissions/generate_audit_trail.rb +++ b/lib/submissions/generate_audit_trail.rb @@ -81,7 +81,12 @@ module Submissions end composer = HexaPDF::Composer.new(skip_page_creation: true) - composer.document.task(:pdfa) if FONT_NAME == 'GoNotoKurrent' + + if Docuseal.pdf_format == 'pdf/a-3b' + composer.document.task(:pdfa, level: '3b') + elsif FONT_NAME == 'GoNotoKurrent' + composer.document.task(:pdfa) + end composer.document.config['font.map'] = { 'Helvetica' => { diff --git a/lib/submissions/generate_combined_attachment.rb b/lib/submissions/generate_combined_attachment.rb index 4caa2104..715e7a18 100644 --- a/lib/submissions/generate_combined_attachment.rb +++ b/lib/submissions/generate_combined_attachment.rb @@ -17,6 +17,11 @@ module Submissions pdf.trailer.info[:Creator] = "#{Docuseal.product_name} (#{Docuseal::PRODUCT_URL})" + if Docuseal.pdf_format == 'pdf/a-3b' + pdf.task(:pdfa, level: '3b') + pdf.config['font.map'] = GenerateResultAttachments::PDFA_FONT_MAP + end + if pkcs sign_params = { reason: Submissions::GenerateResultAttachments.single_sign_reason(submitter), diff --git a/lib/submissions/generate_result_attachments.rb b/lib/submissions/generate_result_attachments.rb index 2891a424..2bf879e9 100644 --- a/lib/submissions/generate_result_attachments.rb +++ b/lib/submissions/generate_result_attachments.rb @@ -30,6 +30,13 @@ module Submissions bold_italic: FONT_BOLD_ITALIC_NAME }.freeze + PDFA_FONT_VARIANS = { + none: FONT_NAME, + bold: FONT_BOLD_NAME, + italic: FONT_NAME, + bold_italic: FONT_BOLD_NAME + }.freeze + SIGN_REASON = 'Signed by %s with DocuSeal.com' RTL_REGEXP = TextUtils::RTL_REGEXP @@ -48,6 +55,13 @@ module Submissions 'Courier' => 1.6 }.freeze + PDFA_FONT_MAP = { + FONT_NAME => PDFA_FONT_VARIANS, + 'Helvetica' => PDFA_FONT_VARIANS, + 'Times' => PDFA_FONT_VARIANS, + 'Courier' => PDFA_FONT_VARIANS + }.freeze + MISSING_GLYPH_REPLACE = { '▪' => '-', '✔️' => 'V', @@ -599,6 +613,11 @@ module Submissions pdf.trailer.info[:Creator] = info_creator + if Docuseal.pdf_format == 'pdf/a-3b' + pdf.task(:pdfa, level: '3b') + pdf.config['font.map'] = PDFA_FONT_MAP + end + sign_reason = fetch_sign_reason(submitter) if sign_reason && pkcs From 9aa4a547cf09c8ec68b5e8a61dd6843fa09956fc Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Thu, 10 Jul 2025 20:47:52 +0300 Subject: [PATCH 04/14] with submitter timezone --- app/models/account_config.rb | 2 +- lib/submissions/generate_audit_trail.rb | 7 +++---- lib/submissions/generate_preview_attachments.rb | 7 +++++-- lib/submissions/generate_result_attachments.rb | 17 ++++++++++++----- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/app/models/account_config.rb b/app/models/account_config.rb index df46e547..60dcbf51 100644 --- a/app/models/account_config.rb +++ b/app/models/account_config.rb @@ -42,7 +42,7 @@ class AccountConfig < ApplicationRecord FLATTEN_RESULT_PDF_KEY = 'flatten_result_pdf' WITH_SIGNATURE_ID = 'with_signature_id' WITH_AUDIT_VALUES_KEY = 'with_audit_values' - WITH_AUDIT_SUBMITTER_TIMEZONE_KEY = 'with_audit_submitter_timezone' + WITH_SUBMITTER_TIMEZONE_KEY = 'with_submitter_timezone' REQUIRE_SIGNING_REASON_KEY = 'require_signing_reason' REUSE_SIGNATURE_KEY = 'reuse_signature' COMBINE_PDF_RESULT_KEY = 'combine_pdf_result_key' diff --git a/lib/submissions/generate_audit_trail.rb b/lib/submissions/generate_audit_trail.rb index 3b74244d..cce40fb9 100644 --- a/lib/submissions/generate_audit_trail.rb +++ b/lib/submissions/generate_audit_trail.rb @@ -113,17 +113,16 @@ module Submissions configs = submission.account.account_configs.where(key: [AccountConfig::WITH_AUDIT_VALUES_KEY, AccountConfig::WITH_SIGNATURE_ID, - AccountConfig::WITH_AUDIT_SUBMITTER_TIMEZONE_KEY]) + AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY]) last_submitter = submission.submitters.select(&:completed_at).max_by(&:completed_at) with_signature_id = configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID }&.value == true with_audit_values = configs.find { |c| c.key == AccountConfig::WITH_AUDIT_VALUES_KEY }&.value != false - with_audit_submitter_timezone = - configs.find { |c| c.key == AccountConfig::WITH_AUDIT_SUBMITTER_TIMEZONE_KEY }&.value == true + with_submitter_timezone = configs.find { |c| c.key == AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY }&.value == true timezone = account.timezone - timezone = last_submitter.timezone || account.timezone if with_audit_submitter_timezone + timezone = last_submitter.timezone || account.timezone if with_submitter_timezone composer.page_style(:default, page_size:) do |canvas, style| box = canvas.context.box(:media) diff --git a/lib/submissions/generate_preview_attachments.rb b/lib/submissions/generate_preview_attachments.rb index 39fb2dd3..e55c14f6 100644 --- a/lib/submissions/generate_preview_attachments.rb +++ b/lib/submissions/generate_preview_attachments.rb @@ -13,10 +13,12 @@ module Submissions end configs = submission.account.account_configs.where(key: [AccountConfig::FLATTEN_RESULT_PDF_KEY, - AccountConfig::WITH_SIGNATURE_ID]) + AccountConfig::WITH_SIGNATURE_ID, + AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY]) with_signature_id = configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID }&.value == true is_flatten = configs.find { |c| c.key == AccountConfig::FLATTEN_RESULT_PDF_KEY }&.value != false + with_submitter_timezone = configs.find { |c| c.key == AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY }&.value == true pdfs_index = GenerateResultAttachments.build_pdfs_index(submission, flatten: is_flatten) @@ -28,7 +30,8 @@ module Submissions submitters.preload(attachments_attachments: :blob).each_with_index do |s, index| GenerateResultAttachments.fill_submitter_fields(s, submission.account, pdfs_index, - with_signature_id:, is_flatten:, with_headings: index.zero?) + with_signature_id:, is_flatten:, with_headings: index.zero?, + with_submitter_timezone:) end template = submission.template diff --git a/lib/submissions/generate_result_attachments.rb b/lib/submissions/generate_result_attachments.rb index 2bf879e9..999145f2 100644 --- a/lib/submissions/generate_result_attachments.rb +++ b/lib/submissions/generate_result_attachments.rb @@ -138,10 +138,12 @@ module Submissions def generate_pdfs(submitter) configs = submitter.account.account_configs.where(key: [AccountConfig::FLATTEN_RESULT_PDF_KEY, - AccountConfig::WITH_SIGNATURE_ID]) + AccountConfig::WITH_SIGNATURE_ID, + AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY]) with_signature_id = configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID }&.value == true is_flatten = configs.find { |c| c.key == AccountConfig::FLATTEN_RESULT_PDF_KEY }&.value != false + with_submitter_timezone = configs.find { |c| c.key == AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY }&.value == true pdfs_index = build_pdfs_index(submitter.submission, submitter:, flatten: is_flatten) @@ -185,10 +187,12 @@ module Submissions end end - fill_submitter_fields(submitter, submitter.account, pdfs_index, with_signature_id:, is_flatten:) + fill_submitter_fields(submitter, submitter.account, pdfs_index, with_signature_id:, is_flatten:, + with_submitter_timezone:) end - def fill_submitter_fields(submitter, account, pdfs_index, with_signature_id:, is_flatten:, with_headings: nil) + def fill_submitter_fields(submitter, account, pdfs_index, with_signature_id:, is_flatten:, with_headings: nil, + with_submitter_timezone: false) cell_layouter = HexaPDF::Layout::TextLayouter.new(text_valign: :center, text_align: :center) attachments_data_cache = {} @@ -288,10 +292,13 @@ module Submissions reason_string = I18n.with_locale(locale) do + timezone = submitter.account.timezone + timezone = submitter.timezone || submitter.account.timezone if with_submitter_timezone + "#{reason_value ? "#{I18n.t('reason')}: " : ''}#{reason_value || I18n.t('digitally_signed_by')} " \ "#{submitter.name}#{submitter.email.present? ? " <#{submitter.email}>" : ''}\n" \ - "#{I18n.l(attachment.created_at.in_time_zone(submitter.account.timezone), format: :long)} " \ - "#{TimeUtils.timezone_abbr(submitter.account.timezone, attachment.created_at)}" + "#{I18n.l(attachment.created_at.in_time_zone(timezone), format: :long)} " \ + "#{TimeUtils.timezone_abbr(timezone, attachment.created_at)}" end reason_text = HexaPDF::Layout::TextFragment.create(reason_string, From 931addedbe560fc8824df10c9a579ea3c0fee66d Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Thu, 10 Jul 2025 23:10:40 +0300 Subject: [PATCH 05/14] adjust submitter index --- lib/search_entries.rb | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/lib/search_entries.rb b/lib/search_entries.rb index 9f8a605b..9ebf5897 100644 --- a/lib/search_entries.rb +++ b/lib/search_entries.rb @@ -132,12 +132,14 @@ module SearchEntries return if submitter.email.blank? && submitter.phone.blank? && submitter.name.blank? email_phone_name = [ - [submitter.email.to_s, submitter.email.to_s.split('@').last].join(' ').delete("\0"), - [submitter.phone.to_s.gsub(/\D/, ''), - submitter.phone.to_s.gsub(PhoneCodes::REGEXP, '').gsub(/\D/, '')].uniq.join(' ').delete("\0"), + submitter.email.to_s.then { |e| [e, e.split('@').last] }.join(' ').delete("\0"), + submitter.phone.to_s.then { |e| [e.gsub(/\D/, ''), e.gsub(PhoneCodes::REGEXP, '').gsub(/\D/, '')] } + .uniq.join(' ').delete("\0"), TextUtils.transliterate(submitter.name).delete("\0") ] + values_string = build_submitter_values_string(submitter) + sql = SearchEntry.sanitize_sql_array( [ "SELECT setweight(to_tsvector(?), 'A') || setweight(to_tsvector(?), 'B') || @@ -145,9 +147,7 @@ module SearchEntries setweight(to_tsvector('simple', ?), 'A') || setweight(to_tsvector('simple', ?), 'B') || setweight(to_tsvector('simple', ?), 'C') as ngram".squish, - *email_phone_name, - build_submitter_values_string(submitter), - *email_phone_name + *email_phone_name, values_string, *email_phone_name ] ) @@ -155,6 +155,9 @@ module SearchEntries entry.account_id = submitter.account_id entry.tsvector, ngram = SearchEntry.connection.select_rows(sql).first + + add_hyphens(entry, values_string) + entry.ngram = build_ngram(ngram) return if entry.tsvector.blank? @@ -189,11 +192,7 @@ module SearchEntries entry.account_id = template.account_id entry.tsvector, ngram = SearchEntry.connection.select_rows(sql).first - hyphens = text.scan(/\b[^\s]*?\d-[^\s]+?\b/) + text.scan(/\b[^\s]+-\d[^\s]*?\b/) - - hyphens.uniq.each_with_index do |item, index| - entry.tsvector += " '#{item.delete("'")}':#{index + 1}" unless entry.tsvector.include?(item) - end + add_hyphens(entry, text) entry.ngram = build_ngram(ngram) @@ -220,11 +219,7 @@ module SearchEntries entry.account_id = submission.account_id entry.tsvector, ngram = SearchEntry.connection.select_rows(sql).first - hyphens = text.scan(/\b[^\s]*?\d-[^\s]+?\b/) + text.scan(/\b[^\s]+-\d[^\s]*?\b/) - - hyphens.uniq.each_with_index do |item, index| - entry.tsvector += " '#{item.delete("'")}':#{index + 1}" unless entry.tsvector.include?(item) - end + add_hyphens(entry, text) entry.ngram = build_ngram(ngram) @@ -239,6 +234,16 @@ module SearchEntries retry end + def add_hyphens(entry, text) + hyphens = text.scan(/\b[^\s]*?\d-[^\s]+?\b/) + text.scan(/\b[^\s]+-\d[^\s]*?\b/) + + hyphens.uniq.each_with_index do |item, index| + entry.tsvector += " '#{item.delete("'")}':#{index + 1}" unless entry.tsvector.include?(item) + end + + entry + end + def build_ngram(ngram) ngrams = ngram.split(/\s(?=')/).each_with_object([]) do |item, acc| From a61c3e798d070283f7b8970abc8b656481a51db5 Mon Sep 17 00:00:00 2001 From: Alex Turchyn Date: Fri, 11 Jul 2025 09:02:18 +0300 Subject: [PATCH 06/14] shared link 2fa --- .rubocop.yml | 2 +- app/controllers/start_form_controller.rb | 68 +++++++-- .../start_form_email_2fa_send_controller.rb | 31 ++++ .../submission_events_controller.rb | 1 + app/controllers/submit_form_controller.rb | 12 +- .../templates_preferences_controller.rb | 2 +- app/mailers/template_mailer.rb | 13 ++ app/models/submission_event.rb | 1 + app/views/icons/_email_check.html.erb | 6 + .../start_form/email_verification.html.erb | 53 +++++++ .../otp_verification_email.html.erb | 3 + app/views/templates_share_link/show.html.erb | 10 ++ config/locales/i18n.yml | 134 ++++++++++++++++++ config/routes.rb | 1 + lib/email_verification_codes.rb | 27 ++++ lib/submissions/generate_audit_trail.rb | 11 +- lib/submitters.rb | 26 ++++ .../previews/template_mailer_preview.rb | 9 ++ spec/system/signing_form_spec.rb | 89 +++++++++++- 19 files changed, 479 insertions(+), 20 deletions(-) create mode 100644 app/controllers/start_form_email_2fa_send_controller.rb create mode 100644 app/mailers/template_mailer.rb create mode 100644 app/views/icons/_email_check.html.erb create mode 100644 app/views/start_form/email_verification.html.erb create mode 100644 app/views/template_mailer/otp_verification_email.html.erb create mode 100644 lib/email_verification_codes.rb create mode 100644 spec/mailers/previews/template_mailer_preview.rb diff --git a/.rubocop.yml b/.rubocop.yml index e3315548..cfcd7b3e 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -63,7 +63,7 @@ RSpec/MultipleExpectations: Max: 25 RSpec/ExampleLength: - Max: 50 + Max: 500 RSpec/MultipleMemoizedHelpers: Max: 15 diff --git a/app/controllers/start_form_controller.rb b/app/controllers/start_form_controller.rb index 4a237bf7..d71fba64 100644 --- a/app/controllers/start_form_controller.rb +++ b/app/controllers/start_form_controller.rb @@ -6,12 +6,15 @@ class StartFormController < ApplicationController skip_before_action :authenticate_user! skip_authorization_check - around_action :with_browser_locale, only: %i[show completed] + around_action :with_browser_locale, only: %i[show update completed] before_action :maybe_redirect_com, only: %i[show completed] before_action :load_resubmit_submitter, only: :update before_action :load_template before_action :authorize_start!, only: :update + COOKIES_TTL = 12.hours + COOKIES_DEFAULTS = { httponly: true, secure: Rails.env.production? }.freeze + def show raise ActionController::RoutingError, I18n.t('not_found') if @template.preferences['require_phone_2fa'] @@ -20,6 +23,7 @@ class StartFormController < ApplicationController .submitters.new(account_id: @template.account_id, uuid: (filter_undefined_submitters(@template).first || @template.submitters.first)['uuid']) + render :email_verification if params[:email_verification] else Rollbar.warning("Not shared template: #{@template.id}") if defined?(Rollbar) @@ -49,17 +53,10 @@ class StartFormController < ApplicationController @submitter.assign_attributes(ip: request.remote_ip, ua: request.user_agent) end - if @submitter.errors.blank? && @submitter.save - if is_new_record - WebhookUrls.enqueue_events(@submitter.submission, 'submission.created') - - SearchEntries.enqueue_reindex(@submitter) - - if @submitter.submission.expire_at? - ProcessSubmissionExpiredJob.perform_at(@submitter.submission.expire_at, - 'submission_id' => @submitter.submission_id) - end - end + if @template.preferences['shared_link_2fa'] == true + handle_require_2fa(@submitter, is_new_record:) + elsif @submitter.errors.blank? && @submitter.save + enqueue_new_submitter_jobs(@submitter) if is_new_record redirect_to submit_form_path(@submitter.slug) else @@ -89,6 +86,16 @@ class StartFormController < ApplicationController private + def enqueue_new_submitter_jobs(submitter) + WebhookUrls.enqueue_events(submitter.submission, 'submission.created') + + SearchEntries.enqueue_reindex(submitter) + + return unless submitter.submission.expire_at? + + ProcessSubmissionExpiredJob.perform_at(submitter.submission.expire_at, 'submission_id' => submitter.submission_id) + end + def load_resubmit_submitter @resubmit_submitter = if params[:resubmit].present? && !params[:resubmit].in?([true, 'true']) @@ -123,7 +130,7 @@ class StartFormController < ApplicationController .order(id: :desc) .where(declined_at: nil) .where(external_id: nil) - .where(ip: [nil, request.remote_ip]) + .where(template.preferences['shared_link_2fa'] == true ? {} : { ip: [nil, request.remote_ip] }) .then { |rel| params[:resubmit].present? || params[:selfsign].present? ? rel.where(completed_at: nil) : rel } .find_or_initialize_by(find_params) @@ -197,4 +204,39 @@ class StartFormController < ApplicationController I18n.t('not_found') end end + + def handle_require_2fa(submitter, is_new_record:) + return render :show, status: :unprocessable_entity if submitter.errors.present? + + is_otp_verified = Submitters.verify_link_otp!(params[:one_time_code], submitter) + + if cookies.encrypted[:email_2fa_slug] == submitter.slug || is_otp_verified + if submitter.save + enqueue_new_submitter_jobs(submitter) if is_new_record + + if is_otp_verified + SubmissionEvents.create_with_tracking_data(submitter, 'email_verified', request) + + cookies.encrypted[:email_2fa_slug] = + { value: submitter.slug, expires: COOKIES_TTL.from_now, **COOKIES_DEFAULTS } + end + + redirect_to submit_form_path(submitter.slug) + else + render :show, status: :unprocessable_entity + end + else + Submitters.send_shared_link_email_verification_code(submitter, request:) + + render :email_verification + end + rescue Submitters::UnableToSendCode, Submitters::InvalidOtp => e + redirect_to start_form_path(submitter.submission.template.slug, + params: submitter_params.merge(email_verification: true)), + alert: e.message + rescue RateLimit::LimitApproached + redirect_to start_form_path(submitter.submission.template.slug, + params: submitter_params.merge(email_verification: true)), + alert: I18n.t(:too_many_attempts) + end end diff --git a/app/controllers/start_form_email_2fa_send_controller.rb b/app/controllers/start_form_email_2fa_send_controller.rb new file mode 100644 index 00000000..6359debd --- /dev/null +++ b/app/controllers/start_form_email_2fa_send_controller.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class StartFormEmail2faSendController < ApplicationController + around_action :with_browser_locale + + skip_before_action :authenticate_user! + skip_authorization_check + + def create + @template = Template.find_by!(slug: params[:slug]) + + @submitter = @template.submissions.new(account_id: @template.account_id) + .submitters.new(**submitter_params, account_id: @template.account_id) + + Submitters.send_shared_link_email_verification_code(@submitter, request:) + + redir_params = { notice: I18n.t(:code_has_been_resent) } if params[:resend] + + redirect_to start_form_path(@template.slug, params: submitter_params.merge(email_verification: true)), + **redir_params + rescue Submitters::UnableToSendCode => e + redirect_to start_form_path(@template.slug, params: submitter_params.merge(email_verification: true)), + alert: e.message + end + + private + + def submitter_params + params.require(:submitter).permit(:name, :email, :phone) + end +end diff --git a/app/controllers/submission_events_controller.rb b/app/controllers/submission_events_controller.rb index 9dbd57ed..14cf5321 100644 --- a/app/controllers/submission_events_controller.rb +++ b/app/controllers/submission_events_controller.rb @@ -12,6 +12,7 @@ class SubmissionEventsController < ApplicationController 'send_2fa_sms' => '2fa', 'send_sms' => 'send', 'phone_verified' => 'phone_check', + 'email_verified' => 'email_check', 'click_sms' => 'hand_click', 'decline_form' => 'x', 'start_verification' => 'player_play', diff --git a/app/controllers/submit_form_controller.rb b/app/controllers/submit_form_controller.rb index 46b28128..44d10445 100644 --- a/app/controllers/submit_form_controller.rb +++ b/app/controllers/submit_form_controller.rb @@ -9,6 +9,7 @@ class SubmitFormController < ApplicationController before_action :load_submitter, only: %i[show update completed] before_action :maybe_render_locked_page, only: :show + before_action :maybe_require_link_2fa, only: %i[show update] CONFIG_KEYS = [].freeze @@ -50,7 +51,7 @@ class SubmitFormController < ApplicationController return render json: { error: I18n.t('form_has_been_completed_already') }, status: :unprocessable_entity end - if @submitter.template&.archived_at? || @submitter.submission.archived_at? + if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at? return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_entity end @@ -80,6 +81,15 @@ class SubmitFormController < ApplicationController private + def maybe_require_link_2fa + return if @submitter.submission.source != 'link' + return unless @submitter.submission.template&.preferences&.dig('shared_link_2fa') == true + return if cookies.encrypted[:email_2fa_slug] == @submitter.slug + return if @submitter.email == current_user&.email && current_user&.account_id == @submitter.account_id + + redirect_to start_form_path(@submitter.submission.template.slug) + end + def maybe_render_locked_page return render :archived if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at? || diff --git a/app/controllers/templates_preferences_controller.rb b/app/controllers/templates_preferences_controller.rb index eae2fa9f..31ac7190 100644 --- a/app/controllers/templates_preferences_controller.rb +++ b/app/controllers/templates_preferences_controller.rb @@ -26,7 +26,7 @@ class TemplatesPreferencesController < ApplicationController completed_notification_email_attach_documents completed_redirect_url validate_unique_submitters require_all_submitters submitters_order require_phone_2fa - default_expire_at_duration + default_expire_at_duration shared_link_2fa default_expire_at completed_notification_email_subject completed_notification_email_body completed_notification_email_enabled completed_notification_email_attach_audit] + diff --git a/app/mailers/template_mailer.rb b/app/mailers/template_mailer.rb new file mode 100644 index 00000000..d662af9c --- /dev/null +++ b/app/mailers/template_mailer.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class TemplateMailer < ApplicationMailer + def otp_verification_email(template, email:) + @template = template + + @otp_code = EmailVerificationCodes.generate([email.downcase.strip, template.slug].join(':')) + + assign_message_metadata('otp_verification_email', template) + + mail(to: email, subject: I18n.t('email_verification')) + end +end diff --git a/app/models/submission_event.rb b/app/models/submission_event.rb index 1ae47e79..d85536b3 100644 --- a/app/models/submission_event.rb +++ b/app/models/submission_event.rb @@ -47,6 +47,7 @@ class SubmissionEvent < ApplicationRecord click_email: 'click_email', click_sms: 'click_sms', phone_verified: 'phone_verified', + email_verified: 'email_verified', start_form: 'start_form', start_verification: 'start_verification', complete_verification: 'complete_verification', diff --git a/app/views/icons/_email_check.html.erb b/app/views/icons/_email_check.html.erb new file mode 100644 index 00000000..5fbd2151 --- /dev/null +++ b/app/views/icons/_email_check.html.erb @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/views/start_form/email_verification.html.erb b/app/views/start_form/email_verification.html.erb new file mode 100644 index 00000000..445613c0 --- /dev/null +++ b/app/views/start_form/email_verification.html.erb @@ -0,0 +1,53 @@ +<% content_for(:html_title, "#{@template.name} | DocuSeal") %> +<% I18n.with_locale(@template.account.locale) do %> + <% content_for(:html_description, t('account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution', account_name: @template.account.name)) %> +<% end %> +
    +
    +
    +
    + <%= render 'start_form/banner' %> +
    +
    +
    +
    + <%= svg_icon('writing_sign', class: 'w-10 h-10') %> +
    +
    +

    <%= @template.name %>

    +

    <%= t('invited_by_html', name: @template.account.name) %>

    +
    +
    +
    +
    +
    + <%= t('we_sent_a_one_time_verification_code_to_your_email_address_please_enter_the_code_below_to_continue') %> +
    + <%= form_for '', url: start_form_path(@template.slug), method: :put, html: { class: 'space-y-4', id: 'code_form' } do |f| %> +
    + <%= f.hidden_field 'submitter[name]', value: params[:name] || @submitter&.name %> + <%= f.hidden_field 'submitter[email]', value: params[:email] || @submitter&.email %> + <%= f.hidden_field 'submitter[phone]', value: params[:phone] || @submitter&.phone %> + <%= f.text_field :one_time_code, required: true, class: 'base-input text-center', placeholder: 'XXX-XXX' %> +
    + + <% if flash[:alert] %> + + <%= flash[:alert] %> + + <% elsif flash[:notice] %> + <%= flash[:notice] %> + <% end %> + + + + +
    +
    + + <%= f.button button_title(title: t('submit')), class: 'base-button' %> + + <% end %> + <%= button_to t(:re_send_email), start_form_email_2fa_send_index_path, params: { slug: @template.slug, resend: true, submitter: { name: params[:name] || @submitter&.name, email: params[:email] || @submitter&.email, phone: params[:phone] || @submitter&.phone } }.compact, method: :post, id: 'resend_code', class: 'hidden' %> +
    +
    diff --git a/app/views/template_mailer/otp_verification_email.html.erb b/app/views/template_mailer/otp_verification_email.html.erb new file mode 100644 index 00000000..4aeae39c --- /dev/null +++ b/app/views/template_mailer/otp_verification_email.html.erb @@ -0,0 +1,3 @@ +

    <%= t('your_verification_code_to_access_the_name', name: @template.name) %>

    +

    <%= @otp_code %>

    +

    <%= t('please_reply_to_this_email_if_you_didnt_request_this') %>

    diff --git a/app/views/templates_share_link/show.html.erb b/app/views/templates_share_link/show.html.erb index f6abafc4..c6ec4cf7 100644 --- a/app/views/templates_share_link/show.html.erb +++ b/app/views/templates_share_link/show.html.erb @@ -41,5 +41,15 @@
<% end %> + <% if Docuseal.multitenant? || Accounts.can_send_emails?(current_account) %> + <%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-3' }, data: { close_on_submit: false } do |f| %> + <%= f.fields_for :preferences, Struct.new(:shared_link_2fa).new(@template.preferences['shared_link_2fa'] == true) do |ff| %> + + <% end %> + <% end %> + <% end %> <% end %> diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml index 49f0ba0c..eda060e5 100644 --- a/config/locales/i18n.yml +++ b/config/locales/i18n.yml @@ -769,6 +769,15 @@ en: &en there_are_no_events: There are no events resend: Resend next_attempt_in_time_in_words: Next attempt in %{time_in_words} + request_email_otp_verification_with_shared_link: Request email OTP verification with shared link + sms_rate_limit_exceeded: SMS rate limit exceeded + invalid_phone_number: Invalid phone number + please_contact_the_requester_to_specify_your_phone_number_for_two_factor_authentication: Please contact the requester to specify your phone number for two-factor authentication. + we_sent_a_one_time_verification_code_to_your_email_address_please_enter_the_code_below_to_continue: We've sent a one-time verification code to your email address. Please enter the code below to continue. + re_send_code: Re-send Code + email_verification: Email verification + your_verification_code_to_access_the_name: 'Your verification code to access the "%{name}":' + please_reply_to_this_email_if_you_didnt_request_this: Please reply to this email if you didn't request this. submission_sources: api: API bulk: Bulk Send @@ -786,6 +795,7 @@ en: &en click_email_by_html: 'Email link clicked by %{submitter_name}' click_sms_by_html: 'SMS link clicked by %{submitter_name}' phone_verified_by_html: 'Phone verified by %{submitter_name}' + email_verified_by_html: 'Email verified by %{submitter_name}' start_form_by_html: 'Submission started by %{submitter_name}' view_form_by_html: 'Form viewed by %{submitter_name}' invite_party_by_html: 'Invited %{invited_submitter_name} by %{submitter_name}' @@ -1624,6 +1634,15 @@ es: &es there_are_no_events: No hay eventos resend: Reenviar next_attempt_in_time_in_words: Próximo intento en %{time_in_words} + request_email_otp_verification_with_shared_link: Solicitar verificación OTP por correo electrónico con enlace compartido + sms_rate_limit_exceeded: Límite de SMS excedido + invalid_phone_number: Número de teléfono inválido + please_contact_the_requester_to_specify_your_phone_number_for_two_factor_authentication: Contacte al solicitante para especificar su número para la autenticación de dos factores. + we_sent_a_one_time_verification_code_to_your_email_address_please_enter_the_code_below_to_continue: Enviamos un código de verificación único a su correo electrónico. Ingréselo a continuación para continuar. + re_send_code: Reenviar código + email_verification: Verificación por correo electrónico + your_verification_code_to_access_the_name: 'Su código de verificación para acceder a "%{name}":' + please_reply_to_this_email_if_you_didnt_request_this: Por favor, responda este correo si no solicitó esto. submission_sources: api: API bulk: Envío masivo @@ -1641,6 +1660,7 @@ es: &es click_email_by_html: 'Enlace del correo electrónico clicado por %{submitter_name}' click_sms_by_html: 'Enlace del SMS clicado por %{submitter_name}' phone_verified_by_html: 'Teléfono verificado por %{submitter_name}' + email_verified_by_html: 'Correo electrónico verificado por %{submitter_name}' start_form_by_html: 'Envío iniciado por %{submitter_name}' view_form_by_html: 'Formulario visto por %{submitter_name}' invite_party_by_html: 'Invitado %{invited_submitter_name} por %{submitter_name}' @@ -2477,6 +2497,16 @@ it: &it there_are_no_events: Nessun evento resend: Invia di nuovo next_attempt_in_time_in_words: Prossimo tentativo tra %{time_in_words} + this_template_has_multiple_parties_which_prevents_the_use_of_a_sharing_link: "Questo modello ha più parti, il che impedisce l'uso di un link di condivisione perché non è chiaro quale parte sia responsabile di campi specifici. Per risolvere, definisci i dettagli predefiniti della parte." + request_email_otp_verification_with_shared_link: "Richiedi la verifica OTP tramite email con link condiviso" + sms_rate_limit_exceeded: Limite SMS superato + invalid_phone_number: Numero di telefono non valido + please_contact_the_requester_to_specify_your_phone_number_for_two_factor_authentication: Contatta il richiedente per specificare il tuo numero per l'autenticazione a due fattori. + we_sent_a_one_time_verification_code_to_your_email_address_please_enter_the_code_below_to_continue: Abbiamo inviato un codice di verifica una tantum alla tua email. Inseriscilo qui sotto per continuare. + re_send_code: Invia di nuovo il codice + email_verification: Verifica email + your_verification_code_to_access_the_name: 'Il tuo codice per accedere a "%{name}":' + please_reply_to_this_email_if_you_didnt_request_this: Rispondi a questa email se non hai richiesto questo. submission_sources: api: API bulk: Invio massivo @@ -2494,6 +2524,7 @@ it: &it click_email_by_html: "Link dell'e-mail cliccato da %{submitter_name}" click_sms_by_html: "Link dell'SMS cliccato da %{submitter_name}" phone_verified_by_html: 'Telefono verificato da %{submitter_name}' + email_verified_by_html: 'Email verificata da %{submitter_name}' start_form_by_html: 'Invio iniziato da %{submitter_name}' view_form_by_html: 'Modulo visualizzato da %{submitter_name}' invite_party_by_html: 'Invitato %{invited_submitter_name} da %{submitter_name}' @@ -3333,6 +3364,16 @@ fr: &fr there_are_no_events: Aucun événement resend: Renvoyer next_attempt_in_time_in_words: Nouvelle tentative dans %{time_in_words} + this_template_has_multiple_parties_which_prevents_the_use_of_a_sharing_link: "Ce modèle contient plusieurs parties, ce qui empêche l'utilisation d'un lien de partage car il n'est pas clair quelle partie est responsable de certains champs. Pour résoudre cela, définissez les détails de la partie par défaut." + request_email_otp_verification_with_shared_link: "Demander une vérification OTP par e-mail avec un lien de partage" + sms_rate_limit_exceeded: Limite de SMS dépassée + invalid_phone_number: Numéro de téléphone invalide + please_contact_the_requester_to_specify_your_phone_number_for_two_factor_authentication: Veuillez contacter l'expéditeur pour spécifier votre numéro pour l'authentification à deux facteurs. + we_sent_a_one_time_verification_code_to_your_email_address_please_enter_the_code_below_to_continue: Un code de vérification unique a été envoyé à votre adresse email. Veuillez le saisir ci-dessous pour continuer. + re_send_code: Renvoyer le code + email_verification: Vérification de l'email + your_verification_code_to_access_the_name: 'Votre code pour accéder à "%{name}" :' + please_reply_to_this_email_if_you_didnt_request_this: Veuillez répondre à cet email si vous n'avez pas fait cette demande. submission_sources: api: API bulk: Envoi en masse @@ -3350,6 +3391,7 @@ fr: &fr click_email_by_html: "Lien de l'e-mail cliqué par %{submitter_name}" click_sms_by_html: 'Lien du SMS cliqué par %{submitter_name}' phone_verified_by_html: 'Téléphone vérifié par %{submitter_name}' + email_verified_by_html: 'Email vérifié par %{submitter_name}' start_form_by_html: 'Soumission commencée par %{submitter_name}' view_form_by_html: 'Formulaire consulté par %{submitter_name}' invite_party_by_html: 'Invité %{invited_submitter_name} par %{submitter_name}' @@ -4188,6 +4230,15 @@ pt: &pt there_are_no_events: Nenhum evento resend: Reenviar next_attempt_in_time_in_words: Próxima tentativa em %{time_in_words} + request_email_otp_verification_with_shared_link: Solicitar verificação de OTP por e-mail com link compartilhado + sms_rate_limit_exceeded: Limite de SMS excedido + invalid_phone_number: Número de telefone inválido + please_contact_the_requester_to_specify_your_phone_number_for_two_factor_authentication: Entre em contato com o solicitante para especificar seu número para autenticação de dois fatores. + we_sent_a_one_time_verification_code_to_your_email_address_please_enter_the_code_below_to_continue: Enviamos um código de verificação único para seu e-mail. Insira-o abaixo para continuar. + re_send_code: Reenviar código + email_verification: Verificação de e-mail + your_verification_code_to_access_the_name: 'Seu código de verificação para acessar "%{name}":' + please_reply_to_this_email_if_you_didnt_request_this: Responda a este e-mail se você não solicitou isso. submission_sources: api: API bulk: Envio em massa @@ -4205,6 +4256,7 @@ pt: &pt click_email_by_html: 'Link do e-mail clicado por %{submitter_name}' click_sms_by_html: 'Link do SMS clicado por %{submitter_name}' phone_verified_by_html: 'Telefone verificado por %{submitter_name}' + email_verified_by_html: 'Email verificado por %{submitter_name}' start_form_by_html: 'Submissão iniciada por %{submitter_name}' view_form_by_html: 'Formulário visualizado por %{submitter_name}' invite_party_by_html: 'Convidado %{invited_submitter_name} por %{submitter_name}' @@ -5044,6 +5096,15 @@ de: &de there_are_no_events: Keine Ereignisse vorhanden resend: Erneut senden next_attempt_in_time_in_words: Nächster Versuch in %{time_in_words} + request_email_otp_verification_with_shared_link: Fordern Sie die E-Mail-OTP-Verifizierung mit Freigabelink an + sms_rate_limit_exceeded: SMS-Limit überschritten + invalid_phone_number: Ungültige Telefonnummer + please_contact_the_requester_to_specify_your_phone_number_for_two_factor_authentication: Kontaktieren Sie den Absender, um Ihre Telefonnummer für die Zwei-Faktor-Authentifizierung anzugeben. + we_sent_a_one_time_verification_code_to_your_email_address_please_enter_the_code_below_to_continue: Wir haben einen einmaligen Verifizierungscode an Ihre E-Mail-Adresse gesendet. Bitte geben Sie ihn unten ein, um fortzufahren. + re_send_code: Code erneut senden + email_verification: E-Mail-Verifizierung + your_verification_code_to_access_the_name: 'Ihr Verifizierungscode für den Zugriff auf "%{name}":' + please_reply_to_this_email_if_you_didnt_request_this: Antworten Sie auf diese E-Mail, wenn Sie dies nicht angefordert haben. submission_sources: api: API bulk: Massenversand @@ -5061,6 +5122,7 @@ de: &de click_email_by_html: 'E-Mail-Link angeklickt von %{submitter_name}' click_sms_by_html: 'SMS-Link angeklickt von %{submitter_name}' phone_verified_by_html: 'Telefon verifiziert von %{submitter_name}' + email_verified_by_html: 'Email verifiziert von %{submitter_name}' start_form_by_html: 'Einreichung gestartet von %{submitter_name}' view_form_by_html: 'Formular angesehen von %{submitter_name}' invite_party_by_html: 'Eingeladen %{invited_submitter_name} von %{submitter_name}' @@ -5227,6 +5289,15 @@ pl: select_data_residency: Wybierz lokalizację danych company_name: Nazwa firmy optional: opcjonalne + submit: Prześlij + sms_rate_limit_exceeded: Przekroczono limit wiadomości SMS + invalid_phone_number: Nieprawidłowy numer telefonu + please_contact_the_requester_to_specify_your_phone_number_for_two_factor_authentication: Skontaktuj się z nadawcą, aby podać numer telefonu do uwierzytelniania dwuskładnikowego. + we_sent_a_one_time_verification_code_to_your_email_address_please_enter_the_code_below_to_continue: Wysłaliśmy jednorazowy kod weryfikacyjny na Twój adres e-mail. Wprowadź go poniżej, aby kontynuować. + re_send_code: Wyślij ponownie kod + email_verification: Weryfikacja e-mail + your_verification_code_to_access_the_name: 'Twój kod weryfikacyjny do uzyskania dostępu do "%{name}":' + please_reply_to_this_email_if_you_didnt_request_this: Odpowiedz na ten e-mail, jeśli nie prosiłeś o to. uk: require_phone_2fa_to_open: Вимагати двофакторну автентифікацію через телефон для відкриття @@ -5307,6 +5378,15 @@ uk: select_data_residency: Виберіть місце зберігання даних company_name: Назва компанії optional: необов’язково + submit: Надіслати + sms_rate_limit_exceeded: Перевищено ліміт SMS + invalid_phone_number: Невірний номер телефону + please_contact_the_requester_to_specify_your_phone_number_for_two_factor_authentication: Зв’яжіться з відправником, щоб вказати номер телефону для двофакторної автентифікації. + we_sent_a_one_time_verification_code_to_your_email_address_please_enter_the_code_below_to_continue: Ми надіслали одноразовий код підтвердження на вашу електронну пошту. Введіть його нижче, щоб продовжити. + re_send_code: Надіслати код повторно + email_verification: Підтвердження електронної пошти + your_verification_code_to_access_the_name: 'Ваш код доступу до "%{name}":' + please_reply_to_this_email_if_you_didnt_request_this: Відповідайте на цей лист, якщо ви цього не запитували. cs: require_phone_2fa_to_open: Vyžadovat otevření pomocí telefonního 2FA @@ -5387,6 +5467,15 @@ cs: select_data_residency: Vyberte umístění dat company_name: Název společnosti optional: volitelné + submit: Odeslat + sms_rate_limit_exceeded: Překročena hranice SMS + invalid_phone_number: Neplatné telefonní číslo + please_contact_the_requester_to_specify_your_phone_number_for_two_factor_authentication: Kontaktujte odesílatele kvůli zadání vašeho čísla pro dvoufázové ověření. + we_sent_a_one_time_verification_code_to_your_email_address_please_enter_the_code_below_to_continue: Poslali jsme jednorázový ověřovací kód na váš e-mail. Zadejte ho níže pro pokračování. + re_send_code: Znovu odeslat kód + email_verification: Ověření e-mailu + your_verification_code_to_access_the_name: 'Váš ověřovací kód pro přístup k "%{name}":' + please_reply_to_this_email_if_you_didnt_request_this: Odpovězte na tento e-mail, pokud jste o to nežádali. he: require_phone_2fa_to_open: דרוש אימות דו-שלבי באמצעות טלפון לפתיחה @@ -5467,6 +5556,15 @@ he: select_data_residency: בחר מיקום נתונים company_name: שם החברה optional: אופציונלי + submit: 'שלח' + sms_rate_limit_exceeded: 'חריגה ממגבלת SMS' + invalid_phone_number: 'מספר טלפון לא תקין' + please_contact_the_requester_to_specify_your_phone_number_for_two_factor_authentication: 'פנה לשולח כדי לציין את מספרך לאימות דו-שלבי.' + we_sent_a_one_time_verification_code_to_your_email_address_please_enter_the_code_below_to_continue: 'שלחנו קוד אימות חד-פעמי לדוא"ל שלך. הזן את הקוד למטה כדי להמשיך.' + re_send_code: 'שלח קוד מחדש' + email_verification: 'אימות דוא"ל' + your_verification_code_to_access_the_name: 'קוד האימות שלך לגישה ל-%{name}:' + please_reply_to_this_email_if_you_didnt_request_this: 'השב למייל זה אם לא ביקשת זאת.' nl: require_phone_2fa_to_open: Vereis telefoon 2FA om te openen @@ -5547,6 +5645,15 @@ nl: select_data_residency: Selecteer gegevenslocatie company_name: Bedrijfsnaam optional: optioneel + submit: Verzenden + sms_rate_limit_exceeded: SMS-limiet overschreden + invalid_phone_number: Ongeldig telefoonnummer + please_contact_the_requester_to_specify_your_phone_number_for_two_factor_authentication: Neem contact op met de aanvrager om uw nummer voor twee-factor-authenticatie op te geven. + we_sent_a_one_time_verification_code_to_your_email_address_please_enter_the_code_below_to_continue: We hebben een eenmalige verificatiecode naar je e-mailadres gestuurd. Voer de code hieronder in om verder te gaan. + re_send_code: Code opnieuw verzenden + email_verification: E-mailverificatie + your_verification_code_to_access_the_name: 'Je verificatiecode voor toegang tot "%{name}":' + please_reply_to_this_email_if_you_didnt_request_this: Reageer op deze e-mail als je dit niet hebt aangevraagd. ar: require_phone_2fa_to_open: "تطلب فتح عبر تحقق الهاتف ذو العاملين" @@ -5627,6 +5734,15 @@ ar: select_data_residency: اختر موقع تخزين البيانات company_name: اسم الشركة optional: اختياري + submit: 'إرسال' + sms_rate_limit_exceeded: 'تم تجاوز حد رسائل SMS' + invalid_phone_number: 'رقم الهاتف غير صالح' + please_contact_the_requester_to_specify_your_phone_number_for_two_factor_authentication: 'يرجى الاتصال بالمرسل لتحديد رقم هاتفك للمصادقة الثنائية.' + we_sent_a_one_time_verification_code_to_your_email_address_please_enter_the_code_below_to_continue: 'أرسلنا رمز تحقق لمرة واحدة إلى بريدك الإلكتروني. الرجاء إدخاله أدناه للمتابعة.' + re_send_code: 'إعادة إرسال الرمز' + email_verification: 'التحقق من البريد الإلكتروني' + your_verification_code_to_access_the_name: 'رمز التحقق الخاص بك للوصول إلى "%{name}":' + please_reply_to_this_email_if_you_didnt_request_this: 'يرجى الرد على هذا البريد إذا لم تطلب ذلك.' ko: require_phone_2fa_to_open: 휴대폰 2FA를 열 때 요구함 @@ -5707,6 +5823,15 @@ ko: select_data_residency: 데이터 저장 위치 선택 company_name: 회사 이름 optional: 선택 사항 + submit: 제출 + sms_rate_limit_exceeded: SMS 제한 초과 + invalid_phone_number: 잘못된 전화번호입니다 + please_contact_the_requester_to_specify_your_phone_number_for_two_factor_authentication: 이중 인증을 위해 전화번호를 지정하려면 요청자에게 문의하세요. + we_sent_a_one_time_verification_code_to_your_email_address_please_enter_the_code_below_to_continue: 일회용 인증 코드를 이메일로 보냈습니다. 계속하려면 아래에 입력하세요. + re_send_code: 코드 재전송 + email_verification: 이메일 인증 + your_verification_code_to_access_the_name: '"%{name}"에 액세스하기 위한 인증 코드:' + please_reply_to_this_email_if_you_didnt_request_this: 요청하지 않았다면 이 이메일에 회신하세요. ja: require_phone_2fa_to_open: 電話による2段階認証が必要です @@ -5787,6 +5912,15 @@ ja: select_data_residency: データ保存場所を選択 company_name: 会社名 optional: 任意 + submit: 送信 + sms_rate_limit_exceeded: SMSの送信制限を超えました + invalid_phone_number: 無効な電話番号です + please_contact_the_requester_to_specify_your_phone_number_for_two_factor_authentication: 二要素認証のため、リクエスターに電話番号を指定してください。 + we_sent_a_one_time_verification_code_to_your_email_address_please_enter_the_code_below_to_continue: ワンタイム認証コードをメールアドレスに送信しました。続行するには、以下にコードを入力してください。 + re_send_code: コードを再送信 + email_verification: メール認証 + your_verification_code_to_access_the_name: '"%{name}"へのアクセスコード:' + please_reply_to_this_email_if_you_didnt_request_this: このリクエストを行っていない場合は、このメールに返信してください。 en-US: <<: *en diff --git a/config/routes.rb b/config/routes.rb index dd962782..20015262 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -133,6 +133,7 @@ Rails.application.routes.draw do end resource :resubmit_form, controller: 'start_form', only: :update + resources :start_form_email_2fa_send, only: :create resources :submit_form, only: %i[], path: '' do get :success, on: :collection diff --git a/lib/email_verification_codes.rb b/lib/email_verification_codes.rb new file mode 100644 index 00000000..a65da175 --- /dev/null +++ b/lib/email_verification_codes.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module EmailVerificationCodes + DRIFT_BEHIND = 5.minutes + + module_function + + def generate(value) + totp = ROTP::TOTP.new(build_totp_secret(value)) + + totp.at(Time.current) + end + + def verify(code, value) + totp = ROTP::TOTP.new(build_totp_secret(value)) + + totp.verify(code, drift_behind: DRIFT_BEHIND) + end + + def build_totp_secret(value) + ROTP::Base32.encode( + Digest::SHA1.digest( + [Rails.application.secret_key_base, value].join(':') + ) + ) + end +end diff --git a/lib/submissions/generate_audit_trail.rb b/lib/submissions/generate_audit_trail.rb index cce40fb9..2e5567bd 100644 --- a/lib/submissions/generate_audit_trail.rb +++ b/lib/submissions/generate_audit_trail.rb @@ -244,11 +244,18 @@ module Submissions click_email_event = submission.submission_events.find { |e| e.submitter_id == submitter.id && e.click_email? } + + verify_email_event = + submission.submission_events.find { |e| e.submitter_id == submitter.id && e.phone_verified? } + is_phone_verified = submission.template_fields.any? do |e| e['type'] == 'phone' && e['submitter_uuid'] == submitter.uuid && submitter.values[e['uuid']].present? end + verify_phone_event = + submission.submission_events.find { |e| e.submitter_id == submitter.id && e.phone_verified? } + is_id_verified = submission.template_fields.any? do |e| e['type'] == 'verification' && e['submitter_uuid'] == submitter.uuid && submitter.values[e['uuid']].present? @@ -270,10 +277,10 @@ module Submissions [ composer.document.layout.formatted_text_box( [ - submitter.email && click_email_event && { + submitter.email && (click_email_event || verify_email_event) && { text: "#{I18n.t('email_verification')}: #{I18n.t('verified')}\n" }, - submitter.phone && is_phone_verified && { + submitter.phone && (is_phone_verified || verify_phone_event) && { text: "#{I18n.t('phone_verification')}: #{I18n.t('verified')}\n" }, is_id_verified && { diff --git a/lib/submitters.rb b/lib/submitters.rb index f1fcc814..8e7fd137 100644 --- a/lib/submitters.rb +++ b/lib/submitters.rb @@ -11,6 +11,9 @@ module Submitters 'values' => 'D' }.freeze + UnableToSendCode = Class.new(StandardError) + InvalidOtp = Class.new(StandardError) + module_function def search(current_user, submitters, keyword) @@ -194,4 +197,27 @@ module Submitters "#{filename}.#{blob.filename.extension}" end + + def send_shared_link_email_verification_code(submitter, request:) + RateLimit.call("send-otp-code-#{request.remote_ip}", limit: 2, ttl: 45.seconds, enabled: true) + + TemplateMailer.otp_verification_email(submitter.submission.template, email: submitter.email).deliver_later! + rescue RateLimit::LimitApproached + Rollbar.warning("Limit verification code for template: #{submitter.submission.template.id}") if defined?(Rollbar) + + raise UnableToSendCode, I18n.t('too_many_attempts') + end + + def verify_link_otp!(otp, submitter) + return false if otp.blank? + + RateLimit.call("verify-2fa-code-#{Digest::MD5.base64digest(submitter.email)}", + limit: 2, ttl: 45.seconds, enabled: true) + + link_2fa_key = [submitter.email.downcase.squish, submitter.submission.template.slug].join(':') + + raise InvalidOtp, I18n.t(:invalid_code) unless EmailVerificationCodes.verify(otp, link_2fa_key) + + true + end end diff --git a/spec/mailers/previews/template_mailer_preview.rb b/spec/mailers/previews/template_mailer_preview.rb new file mode 100644 index 00000000..f6d8326b --- /dev/null +++ b/spec/mailers/previews/template_mailer_preview.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class TemplateMailerPreview < ActionMailer::Preview + def otp_verification_email + template = Template.active.last + + TemplateMailer.otp_verification_email(template, email: 'john.doe@example.com') + end +end diff --git a/spec/system/signing_form_spec.rb b/spec/system/signing_form_spec.rb index 0302a981..92730508 100644 --- a/spec/system/signing_form_spec.rb +++ b/spec/system/signing_form_spec.rb @@ -161,7 +161,6 @@ RSpec.describe 'Signing Form' do expect(field_value(submitter, 'Cell code')).to eq '123' end - # rubocop:disable RSpec/ExampleLength it 'completes the form when name, email, and phone are required' do template.update(preferences: { link_form_fields: %w[email name phone] }) @@ -250,7 +249,93 @@ RSpec.describe 'Signing Form' do expect(field_value(submitter, 'Attachment')).to be_present expect(field_value(submitter, 'Cell code')).to eq '123' end - # rubocop:enable RSpec/ExampleLength + + it 'completes the form when identity verification with a 2FA code is enabled', sidekiq: :inline do + create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY, + value: GenerateCertificate.call.transform_values(&:to_pem)) + + template.update(preferences: { link_form_fields: %w[email name], shared_link_2fa: true }) + + visit start_form_path(slug: template.slug) + + fill_in 'Email', with: 'john.dou@example.com' + fill_in 'Name', with: 'John Doe' + + expect do + click_button 'Start' + end.to change { ActionMailer::Base.deliveries.count }.by(1) + + email = ActionMailer::Base.deliveries.last + code = email.body.encoded[%r{(.*?)}, 1] + + fill_in 'one_time_code', with: code + + click_button 'Submit' + + fill_in 'First Name', with: 'John' + click_button 'next' + + fill_in 'Birthday', with: I18n.l(20.years.ago, format: '%Y-%m-%d') + click_button 'next' + + check 'Do you agree?' + click_button 'next' + + choose 'Boy' + click_button 'next' + + draw_canvas + click_button 'next' + + fill_in 'House number', with: '123' + click_button 'next' + + %w[Red Blue].each { |color| check color } + click_button 'next' + + select 'Male', from: 'Gender' + click_button 'next' + + draw_canvas + click_button 'next' + + find('#dropzone').click + find('input[type="file"]', visible: false).attach_file(Rails.root.join('spec/fixtures/sample-image.png')) + click_button 'next' + + find('#dropzone').click + find('input[type="file"]', visible: false).attach_file(Rails.root.join('spec/fixtures/sample-document.pdf')) + click_button 'next' + + fill_in 'Cell code', with: '123' + click_on 'Complete' + + expect(page).to have_button('Download') + expect(page).to have_content('Document has been signed!') + + submitter = template.submissions.last.submitters.last + + expect(submitter.email).to eq('john.dou@example.com') + expect(submitter.name).to eq('John Doe') + expect(submitter.ip).to eq('127.0.0.1') + expect(submitter.ua).to be_present + expect(submitter.opened_at).to be_present + expect(submitter.completed_at).to be_present + expect(submitter.declined_at).to be_nil + + expect(field_value(submitter, 'First Name')).to eq 'John' + expect(field_value(submitter, 'Birthday')).to eq 20.years.ago.strftime('%Y-%m-%d') + expect(field_value(submitter, 'Do you agree?')).to be_truthy + expect(field_value(submitter, 'First child')).to eq 'Boy' + expect(field_value(submitter, 'Signature')).to be_present + expect(field_value(submitter, 'House number')).to eq 123 + expect(field_value(submitter, 'Colors')).to contain_exactly('Red', 'Blue') + expect(field_value(submitter, 'Gender')).to eq 'Male' + expect(field_value(submitter, 'Initials')).to be_present + expect(field_value(submitter, 'Avatar')).to be_present + expect(field_value(submitter, 'Attachment')).to be_present + expect(field_value(submitter, 'Cell code')).to eq '123' + end end context 'when the submitter form link is opened' do From 1ae635963c7fcda1b3186dee306049f5af1079be Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Fri, 11 Jul 2025 19:54:03 +0300 Subject: [PATCH 07/14] update eid --- package.json | 2 +- yarn.lock | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index a65611f2..37978020 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "@babel/plugin-transform-runtime": "7.21.4", "@babel/preset-env": "7.21.5", "@babel/runtime": "7.21.5", - "@eid-easy/eideasy-widget": "^2.159.0", + "@eid-easy/eideasy-widget": "^2.163.4", "@github/catalyst": "^2.0.0-beta", "@hotwired/turbo": "https://github.com/docusealco/turbo#main", "@hotwired/turbo-rails": "^7.3.0", diff --git a/yarn.lock b/yarn.lock index 75a1d1b2..6ba5e8e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1035,22 +1035,22 @@ resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== -"@eid-easy/eideasy-browser-client@2.124.0": - version "2.124.0" - resolved "https://registry.yarnpkg.com/@eid-easy/eideasy-browser-client/-/eideasy-browser-client-2.124.0.tgz#940ccb9d4d853f0bd32e49cc455f2c7b13f3bbd1" - integrity sha512-hZuUeg0CcyvgJSseRBQixRStAIr58bulmNcikcASBo6z8wv+/R8nAnUoE7qdNW1l4ZyYxLmVOwDs6+W+FHy6hQ== +"@eid-easy/eideasy-browser-client@2.127.0": + version "2.127.0" + resolved "https://registry.yarnpkg.com/@eid-easy/eideasy-browser-client/-/eideasy-browser-client-2.127.0.tgz#d1b09e3634e94d7e32f593209a63f962fd326731" + integrity sha512-2iosVqkF1C0hQWc6TVBe1SK7b/z8ZnvHnulGXfQf6VrrpEJMgnzK95h6LFDqDQyetfIwEGGoeOiUij2hYA1ZPA== dependencies: axios "1.8.2" jsencrypt "3.2.1" lodash "^4.17.21" serialize-error "^9.1.1" -"@eid-easy/eideasy-widget@^2.159.0": - version "2.159.0" - resolved "https://registry.yarnpkg.com/@eid-easy/eideasy-widget/-/eideasy-widget-2.159.0.tgz#f2291dad292bd5f7941496f0db0aa0a180226b6b" - integrity sha512-527uCNrN5MVY/PaOUoZ3J2XZ0C+xt6057sJ4xSO0/FPYj2cxOn+qyCJODQjOhjBL7GcnpShWPpYAfv1OIStYwQ== +"@eid-easy/eideasy-widget@^2.163.4": + version "2.163.4" + resolved "https://registry.yarnpkg.com/@eid-easy/eideasy-widget/-/eideasy-widget-2.163.4.tgz#4a8ada2c61f032527a08f9f8d9d1adf3fa7b5334" + integrity sha512-7OQ1bm+KSG99wUI6szXT25Icq67CEQRK38VGj8fynW0ctqJTiFXRop7dqgR9JXLlJ1s1Z++El7igYxph7Dq5Aw== dependencies: - "@eid-easy/eideasy-browser-client" "2.124.0" + "@eid-easy/eideasy-browser-client" "2.127.0" core-js "^3.8.3" i18n-iso-countries "^6.7.0" lodash.defaultsdeep "^4.6.1" From bd3d6433187ea557b223b3370c848b04a34e9064 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Fri, 11 Jul 2025 21:19:03 +0300 Subject: [PATCH 08/14] simplify nav --- app/views/icons/_start.html.erb | 4 ++++ app/views/shared/_github.html.erb | 7 +++++-- app/views/shared/_navbar.html.erb | 2 +- app/views/shared/_navbar_buttons.html.erb | 10 ++++++---- app/views/shared/_settings_nav.html.erb | 2 +- config/locales/i18n.yml | 6 ++++++ 6 files changed, 23 insertions(+), 8 deletions(-) create mode 100644 app/views/icons/_start.html.erb diff --git a/app/views/icons/_start.html.erb b/app/views/icons/_start.html.erb new file mode 100644 index 00000000..1ba5362d --- /dev/null +++ b/app/views/icons/_start.html.erb @@ -0,0 +1,4 @@ + + + + diff --git a/app/views/shared/_github.html.erb b/app/views/shared/_github.html.erb index 5df071f2..41f05307 100644 --- a/app/views/shared/_github.html.erb +++ b/app/views/shared/_github.html.erb @@ -1,3 +1,6 @@ - - GitHub Repo stars + + + <%= svg_icon('start', class: 'h-3 w-3') %> + 9k + diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb index f23bcd0f..2b3f1393 100644 --- a/app/views/shared/_navbar.html.erb +++ b/app/views/shared/_navbar.html.erb @@ -5,7 +5,7 @@ <%= render 'shared/title' %> <% if signed_in? %> diff --git a/app/views/shared/_navbar_buttons.html.erb b/app/views/shared/_navbar_buttons.html.erb index 1d5b8cff..d501624a 100644 --- a/app/views/shared/_navbar_buttons.html.erb +++ b/app/views/shared/_navbar_buttons.html.erb @@ -1,8 +1,10 @@ -<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premises" }.to_query}", class: 'hidden md:inline-flex btn btn-warning btn-sm', data: { prefetch: false } do %> - <%= t('upgrade') %> +<% if request.path.starts_with?('/settings') %> + <%= link_to "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premises" }.to_query}", class: 'hidden md:inline-flex btn btn-warning btn-sm', data: { prefetch: false } do %> + <%= t('upgrade') %> + <% end %> <% end %> <% if signed_in? && current_user != true_user %> - <%= render 'shared/test_alert' %> +<% elsif request.path.starts_with?('/settings') %> + <% end %> - diff --git a/app/views/shared/_settings_nav.html.erb b/app/views/shared/_settings_nav.html.erb index aa468171..8355f543 100644 --- a/app/views/shared/_settings_nav.html.erb +++ b/app/views/shared/_settings_nav.html.erb @@ -66,7 +66,7 @@
  • <%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premises" }.to_query}", class: 'text-base hover:bg-base-300', data: { prefetch: false } do %> <%= t('plans') %> - <%= t('new') %> + <%= t('pro') %> <% end %>
  • <% end %> diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml index eda060e5..71e846ab 100644 --- a/config/locales/i18n.yml +++ b/config/locales/i18n.yml @@ -20,6 +20,7 @@ en: &en language_ko: 한국어 language_ja: 日本語 hi_there: Hi there + pro: Pro thanks: Thanks private: Private default_parties: Default parties @@ -884,6 +885,7 @@ en: &en range_without_total: "%{from}-%{to} events" es: &es + pro: Pro default_parties: Partes predeterminadas authenticate_embedded_form_preview_with_token: Autenticar vista previa del formulario incrustado con token require_all_recipients: Requerir a todos los destinatarios @@ -1749,6 +1751,7 @@ es: &es range_without_total: "%{from}-%{to} eventos" it: &it + pro: Pro default_parties: Parti predefiniti authenticate_embedded_form_preview_with_token: "Autentica l'anteprima del modulo incorporato con il token" require_all_recipients: Richiedi tutti i destinatari @@ -2613,6 +2616,7 @@ it: &it range_without_total: "%{from}-%{to} eventi" fr: &fr + pro: Pro default_parties: Parties par défaut authenticate_embedded_form_preview_with_token: Authentifier l’aperçu du formulaire intégré avec un jeton require_all_recipients: Exiger tous les destinataires @@ -3480,6 +3484,7 @@ fr: &fr range_without_total: "%{from} à %{to} événements" pt: &pt + pro: Pro default_parties: Partes padrão authenticate_embedded_form_preview_with_token: Autenticar visualização incorporada do formulário com token require_all_recipients: Exigir todos os destinatários @@ -4346,6 +4351,7 @@ pt: &pt range_without_total: "%{from}-%{to} eventos" de: &de + pro: Pro default_parties: Standardparteien authenticate_embedded_form_preview_with_token: Authentifizieren Sie die eingebettete Formularvorschau mit Token require_all_recipients: Alle Empfänger erforderlich From c5ec7a4b3729177f1207959716401ba95a49d298 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Fri, 11 Jul 2025 21:52:55 +0300 Subject: [PATCH 09/14] image step validation --- app/javascript/submission_form/dropzone.vue | 14 +++++++++++++- app/javascript/submission_form/i18n.js | 14 ++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/app/javascript/submission_form/dropzone.vue b/app/javascript/submission_form/dropzone.vue index 6bb256ed..29ec6798 100644 --- a/app/javascript/submission_form/dropzone.vue +++ b/app/javascript/submission_form/dropzone.vue @@ -95,7 +95,19 @@ export default { }, methods: { onDropFiles (e) { - this.uploadFiles(e.dataTransfer.files) + const files = Array.from(e.dataTransfer.files).filter((f) => { + if (this.accept === 'image/*') { + return f.type.startsWith('image') + } else { + return true + } + }) + + if (this.accept === 'image/*' && !files.length) { + alert(this.t('please_upload_an_image_file')) + } else { + this.uploadFiles(files) + } }, onSelectFiles (e) { e.preventDefault() diff --git a/app/javascript/submission_form/i18n.js b/app/javascript/submission_form/i18n.js index c734bd9d..41af4c7a 100644 --- a/app/javascript/submission_form/i18n.js +++ b/app/javascript/submission_form/i18n.js @@ -1,4 +1,5 @@ const en = { + please_upload_an_image_file: 'Please upload an image file', must_be_characters_length: 'Must be {number} characters length', complete_all_required_fields_to_proceed_with_identity_verification: 'Complete all required fields to proceed with identity verification.', verify_id: 'Verify ID', @@ -99,6 +100,7 @@ const en = { } const es = { + please_upload_an_image_file: 'Por favor, sube un archivo de imagen', must_be_characters_length: 'Debe tener {number} caracteres de longitud', complete_all_required_fields_to_proceed_with_identity_verification: 'Complete todos los campos requeridos para continuar con la verificación de identidad.', verify_id: 'Verificar ID', @@ -199,6 +201,7 @@ const es = { } const it = { + please_upload_an_image_file: 'Per favore carica un file immagine', must_be_characters_length: 'Deve essere lungo {number} caratteri', complete_all_required_fields_to_proceed_with_identity_verification: "Compila tutti i campi obbligatori per procedere con la verifica dell'identità.", verify_id: 'Verifica ID', @@ -299,6 +302,7 @@ const it = { } const de = { + please_upload_an_image_file: 'Bitte laden Sie eine Bilddatei hoch', must_be_characters_length: 'Muss {number} Zeichen lang sein', complete_all_required_fields_to_proceed_with_identity_verification: 'Vervollständigen Sie alle erforderlichen Felder, um mit der Identitätsverifizierung fortzufahren.', verify_id: 'ID überprüfen', @@ -399,6 +403,7 @@ const de = { } const fr = { + please_upload_an_image_file: 'Veuillez télécharger un fichier image', must_be_characters_length: 'Doit contenir {number} caractères', complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour continuer la vérification de l'identité.", verify_id: "Vérification de l'ID", @@ -499,6 +504,7 @@ const fr = { } const pl = { + please_upload_an_image_file: 'Proszę przesłać plik obrazu', must_be_characters_length: 'Musi mieć długość {number} znaków', complete_all_required_fields_to_proceed_with_identity_verification: 'Uzupełnij wszystkie wymagane pola, aby kontynuować weryfikację tożsamości.', verify_id: 'Zweryfikuj ID', @@ -599,6 +605,7 @@ const pl = { } const uk = { + please_upload_an_image_file: 'Будь ласка, завантажте файл зображення', must_be_characters_length: 'Має містити {number} символів', complete_all_required_fields_to_proceed_with_identity_verification: "Заповніть всі обов'язкові поля, щоб продовжити перевірку особи.", verify_id: 'Підтвердження ідентичності', @@ -699,6 +706,7 @@ const uk = { } const cs = { + please_upload_an_image_file: 'Nahrajte prosím obrázkový soubor', must_be_characters_length: 'Musí mít délku {number} znaků', complete_all_required_fields_to_proceed_with_identity_verification: 'Vyplňte všechna povinná pole, abyste mohli pokračovat v ověření identity.', verify_id: 'Ověřit ID', @@ -799,6 +807,7 @@ const cs = { } const pt = { + please_upload_an_image_file: 'Por favor, envie um arquivo de imagem', must_be_characters_length: 'Deve ter {number} caracteres', complete_all_required_fields_to_proceed_with_identity_verification: 'Preencha todos os campos obrigatórios para prosseguir com a verificação de identidade.', verify_id: 'Verificar ID', @@ -899,6 +908,7 @@ const pt = { } const he = { + please_upload_an_image_file: 'אנא העלה קובץ תמונה', must_be_characters_length: 'חייב להיות באורך של {number} תווים', complete_all_required_fields_to_proceed_with_identity_verification: 'מלא את כל השדות הנדרשים כדי להמשיך עם אימות זהות.', verify_id: 'אמת מזהה', @@ -999,6 +1009,7 @@ const he = { } const nl = { + please_upload_an_image_file: 'Upload alstublieft een afbeeldingsbestand', must_be_characters_length: 'Moet {number} tekens lang zijn', complete_all_required_fields_to_proceed_with_identity_verification: 'Vul alle verplichte velden in om door te gaan met de identiteitsverificatie.', verify_id: 'Verifiëren ID', @@ -1099,6 +1110,7 @@ const nl = { } const ar = { + please_upload_an_image_file: 'يرجى تحميل ملف صورة', must_be_characters_length: 'يجب أن يكون الطول {number} حرفًا', complete_all_required_fields_to_proceed_with_identity_verification: 'أكمل جميع الحقول المطلوبة للمتابعة في التحقق من الهوية.', verify_id: 'تحقق من الهوية', @@ -1199,6 +1211,7 @@ const ar = { } const ko = { + please_upload_an_image_file: '이미지 파일을 업로드해 주세요', must_be_characters_length: '{number}자여야 합니다', complete_all_required_fields_to_proceed_with_identity_verification: '신원 확인을 진행하려면 모든 필수 필드를 작성하십시오.', verify_id: '아이디 확인', @@ -1299,6 +1312,7 @@ const ko = { } const ja = { + please_upload_an_image_file: '画像ファイルをアップロードしてください', must_be_characters_length: '{number}文字でなければなりません', complete_all_required_fields_to_proceed_with_identity_verification: '本人確認を進めるには、すべての必須項目を入力してください。', verify_id: '本人確認', From fa6dc04ef1328b97e5d6b3d68034d140c23fcbc3 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Fri, 11 Jul 2025 22:40:44 +0300 Subject: [PATCH 10/14] add gh tooltip --- app/views/shared/_github.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/shared/_github.html.erb b/app/views/shared/_github.html.erb index 41f05307..49e1f380 100644 --- a/app/views/shared/_github.html.erb +++ b/app/views/shared/_github.html.erb @@ -1,4 +1,4 @@ - + <%= svg_icon('start', class: 'h-3 w-3') %> 9k From d7142c2935bb612f255abe0d5670f367842027bf Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Sat, 12 Jul 2025 11:16:14 +0300 Subject: [PATCH 11/14] adjust setup nav --- app/views/shared/_navbar.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb index 2b3f1393..dd124e46 100644 --- a/app/views/shared/_navbar.html.erb +++ b/app/views/shared/_navbar.html.erb @@ -79,7 +79,7 @@ <% else %>
    - <% if request.path != new_user_session_path %> + <% if request.path != new_user_session_path && request.path != setup_index_path %> <%= link_to new_user_session_path({ lang: params[:lang] }.compact_blank), class: 'font-medium text-lg' do %> <%= svg_icon('login', class: 'w-6 h-6') %> From e43570d4c128a70bf62059e0350d6d124ea9e700 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Sat, 12 Jul 2025 11:51:10 +0300 Subject: [PATCH 12/14] adjust nav --- app/views/shared/_navbar_buttons.html.erb | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/views/shared/_navbar_buttons.html.erb b/app/views/shared/_navbar_buttons.html.erb index d501624a..f8692699 100644 --- a/app/views/shared/_navbar_buttons.html.erb +++ b/app/views/shared/_navbar_buttons.html.erb @@ -1,10 +1,8 @@ -<% if request.path.starts_with?('/settings') %> - <%= link_to "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premises" }.to_query}", class: 'hidden md:inline-flex btn btn-warning btn-sm', data: { prefetch: false } do %> - <%= t('upgrade') %> - <% end %> -<% end %> <% if signed_in? && current_user != true_user %> <%= render 'shared/test_alert' %> <% elsif request.path.starts_with?('/settings') %> + <%= link_to "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premises" }.to_query}", class: 'hidden md:inline-flex btn btn-warning btn-sm', data: { prefetch: false } do %> + <%= t('upgrade') %> + <% end %> <% end %> From 0310c26edd176f4c593adf69185f74226337895b Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Sat, 12 Jul 2025 12:35:19 +0300 Subject: [PATCH 13/14] fix safari template folder drag&drop --- app/javascript/elements/dashboard_dropzone.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/javascript/elements/dashboard_dropzone.js b/app/javascript/elements/dashboard_dropzone.js index 60cd0b91..888c99ee 100644 --- a/app/javascript/elements/dashboard_dropzone.js +++ b/app/javascript/elements/dashboard_dropzone.js @@ -98,13 +98,13 @@ export default targetable(class extends HTMLElement { el.classList.add('opacity-50') if (e.dataTransfer.files.length) { - const params = new URLSearchParams({ folder_name: el.innerText }).toString() + const params = new URLSearchParams({ folder_name: el.innerText.trim() }).toString() this.uploadFiles(e.dataTransfer.files, `/templates_upload?${params}`) } else { const formData = new FormData() - formData.append('name', el.innerText) + formData.append('name', el.innerText.trim()) fetch(`/templates/${templateId}/folder`, { method: 'PUT', From 1f338dfd2b6336e249d2d689c848f79b97692709 Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Mon, 14 Jul 2025 12:53:15 +0300 Subject: [PATCH 14/14] adjust font size --- app/javascript/application.js | 2 + app/javascript/application.scss | 8 +++ app/javascript/elements/page_container.js | 14 +++++ app/javascript/form.js | 2 + app/javascript/form.scss | 8 +++ app/javascript/submission_form/area.vue | 53 +++++++++++++++---- app/javascript/submission_form/areas.vue | 4 ++ app/javascript/submission_form/form.vue | 2 +- .../submission_form/formula_areas.vue | 6 +++ app/javascript/template_builder/area.vue | 44 ++++++++++++--- app/javascript/template_builder/builder.vue | 4 ++ app/javascript/template_builder/document.vue | 9 ++-- .../template_builder/font_modal.vue | 8 +-- app/javascript/template_builder/page.vue | 13 ++--- app/views/scripts/_autosize_field.html.erb | 13 +++-- app/views/submissions/_value.html.erb | 3 +- app/views/submissions/show.html.erb | 15 +++--- app/views/submit_form/show.html.erb | 11 ++-- lib/pdf_utils.rb | 3 ++ .../generate_result_attachments.rb | 4 +- lib/templates/process_document.rb | 1 + 21 files changed, 173 insertions(+), 54 deletions(-) create mode 100644 app/javascript/elements/page_container.js diff --git a/app/javascript/application.js b/app/javascript/application.js index 6ff4b401..af363801 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -36,6 +36,7 @@ import IndeterminateCheckbox from './elements/indeterminate_checkbox' import AppTour from './elements/app_tour' import DashboardDropzone from './elements/dashboard_dropzone' import RequiredCheckboxGroup from './elements/required_checkbox_group' +import PageContainer from './elements/page_container' import * as TurboInstantClick from './lib/turbo_instant_click' @@ -107,6 +108,7 @@ safeRegisterElement('app-tour', AppTour) safeRegisterElement('dashboard-dropzone', DashboardDropzone) safeRegisterElement('check-on-click', CheckOnClick) safeRegisterElement('required-checkbox-group', RequiredCheckboxGroup) +safeRegisterElement('page-container', PageContainer) safeRegisterElement('template-builder', class extends HTMLElement { connectedCallback () { diff --git a/app/javascript/application.scss b/app/javascript/application.scss index 61d3f645..1ab92498 100644 --- a/app/javascript/application.scss +++ b/app/javascript/application.scss @@ -147,3 +147,11 @@ button[disabled] .enabled { outline-offset: 3px; outline-color: hsl(var(--bc) / 0.2); } + +.font-times { + font-family: "Times New Roman", Times, ui-serif, serif, Cambria, Georgia; +} + +.font-courier { + font-family: "Courier New", Consolas, "Liberation Mono", monospace, ui-monospace, SFMono-Regular, Menlo, Monaco; +} diff --git a/app/javascript/elements/page_container.js b/app/javascript/elements/page_container.js new file mode 100644 index 00000000..d909ae7f --- /dev/null +++ b/app/javascript/elements/page_container.js @@ -0,0 +1,14 @@ +export default class extends HTMLElement { + connectedCallback () { + this.image.addEventListener('load', (e) => { + this.image.setAttribute('width', e.target.naturalWidth) + this.image.setAttribute('height', e.target.naturalHeight) + + this.style.aspectRatio = `${e.target.naturalWidth} / ${e.target.naturalHeight}` + }) + } + + get image () { + return this.querySelector('img') + } +} diff --git a/app/javascript/form.js b/app/javascript/form.js index 45b3b3d2..ac49174d 100644 --- a/app/javascript/form.js +++ b/app/javascript/form.js @@ -5,6 +5,7 @@ import DownloadButton from './elements/download_button' import ToggleSubmit from './elements/toggle_submit' import FetchForm from './elements/fetch_form' import ScrollButtons from './elements/scroll_buttons' +import PageContainer from './elements/page_container' const safeRegisterElement = (name, element, options = {}) => !window.customElements.get(name) && window.customElements.define(name, element, options) @@ -12,6 +13,7 @@ safeRegisterElement('download-button', DownloadButton) safeRegisterElement('toggle-submit', ToggleSubmit) safeRegisterElement('fetch-form', FetchForm) safeRegisterElement('scroll-buttons', ScrollButtons) +safeRegisterElement('page-container', PageContainer) safeRegisterElement('submission-form', class extends HTMLElement { connectedCallback () { this.appElem = document.createElement('div') diff --git a/app/javascript/form.scss b/app/javascript/form.scss index fb651155..f0d26baf 100644 --- a/app/javascript/form.scss +++ b/app/javascript/form.scss @@ -70,3 +70,11 @@ button[disabled] .enabled { .base-radio { @apply radio bg-white radio-sm; } + +.font-times { + font-family: "Times New Roman", Times, ui-serif, serif, Cambria, Georgia; +} + +.font-courier { + font-family: "Courier New", Consolas, "Liberation Mono", monospace, ui-monospace, SFMono-Regular, Menlo, Monaco; +} diff --git a/app/javascript/submission_form/area.vue b/app/javascript/submission_form/area.vue index 5aed2a7a..bc5c255b 100644 --- a/app/javascript/submission_form/area.vue +++ b/app/javascript/submission_form/area.vue @@ -2,8 +2,8 @@
    { if (['date', 'text', 'number'].includes(this.field.type) && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > `${this.modelValue}`.length)) { - this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? `${this.modelValue || (this.withFieldPlaceholder ? this.field.name : '')}`.length : 0 + this.textOverflowChars = this.$refs.textContainer.scrollHeight > (this.$refs.textContainer.clientHeight + 1) ? `${this.modelValue || (this.withFieldPlaceholder ? this.field.name : '')}`.length : 0 } }) } @@ -464,7 +495,7 @@ export default { mounted () { this.$nextTick(() => { if (['date', 'text', 'number'].includes(this.field.type) && this.$refs.textContainer) { - this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? `${this.modelValue || (this.withFieldPlaceholder ? this.field.name : '')}`.length : 0 + this.textOverflowChars = this.$refs.textContainer.scrollHeight > (this.$refs.textContainer.clientHeight + 1) ? `${this.modelValue || (this.withFieldPlaceholder ? this.field.name : '')}`.length : 0 } }) }, diff --git a/app/javascript/submission_form/areas.vue b/app/javascript/submission_form/areas.vue index 67ac0bdd..e14087ec 100644 --- a/app/javascript/submission_form/areas.vue +++ b/app/javascript/submission_form/areas.vue @@ -23,6 +23,7 @@ :area="area" :submittable="submittable" :field-index="fieldIndex" + :is-inline-size="isInlineSize" :scroll-padding="scrollPadding" :submitter="submitter" :with-field-placeholder="withFieldPlaceholder" @@ -110,6 +111,9 @@ export default { } }, computed: { + isInlineSize () { + return CSS.supports('container-type: size') + }, isMobileContainer () { const root = this.$root.$el.parentNode.getRootNode() const container = root.body || root.querySelector('div') diff --git a/app/javascript/submission_form/form.vue b/app/javascript/submission_form/form.vue index 358c0396..d9d9e176 100644 --- a/app/javascript/submission_form/form.vue +++ b/app/javascript/submission_form/form.vue @@ -67,7 +67,7 @@