diff --git a/Dockerfile b/Dockerfile
index e0c2b4b8..dc4ee0dc 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -24,7 +24,7 @@ COPY ./app/views ./app/views
RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker
-FROM ruby:3.2.2-alpine as app
+FROM ruby:3.2.2-alpine3.18 as app
ENV RAILS_ENV=production
ENV BUNDLE_WITHOUT="development:test"
diff --git a/Gemfile b/Gemfile
index c967bd8f..da61a3a7 100644
--- a/Gemfile
+++ b/Gemfile
@@ -21,8 +21,6 @@ gem 'jwt'
gem 'lograge'
gem 'mysql2', require: false
gem 'oj'
-gem 'omniauth-google-oauth2'
-gem 'omniauth-rails_csrf_protection'
gem 'pagy'
gem 'pdf-reader'
gem 'pg', require: false
diff --git a/Gemfile.lock b/Gemfile.lock
index 92fb8dcf..4654547c 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -245,8 +245,7 @@ GEM
signet (>= 0.16, < 2.a)
hashdiff (1.0.1)
hashery (2.1.2)
- hashie (5.0.0)
- hexapdf (0.33.0)
+ hexapdf (0.34.1)
cmdparse (~> 3.0, >= 3.0.3)
geom2d (~> 0.4, >= 0.4.1)
openssl (>= 2.2.1)
@@ -296,7 +295,6 @@ GEM
minitest (5.20.0)
msgpack (1.7.2)
multi_json (1.15.0)
- multi_xml (0.6.0)
multipart-post (2.3.0)
mysql2 (0.5.5)
net-http-persistent (4.0.2)
@@ -316,30 +314,8 @@ GEM
racc (~> 1.4)
nokogiri (1.15.4-arm64-darwin)
racc (~> 1.4)
- oauth2 (2.0.9)
- faraday (>= 0.17.3, < 3.0)
- jwt (>= 1.0, < 3.0)
- multi_xml (~> 0.5)
- rack (>= 1.2, < 4)
- snaky_hash (~> 2.0)
- version_gem (~> 1.1)
oj (3.16.0)
- omniauth (2.1.1)
- hashie (>= 3.4.6)
- rack (>= 2.2.3)
- rack-protection
- omniauth-google-oauth2 (1.1.1)
- jwt (>= 2.0)
- oauth2 (~> 2.0.6)
- omniauth (~> 2.0)
- omniauth-oauth2 (~> 1.8.0)
- omniauth-oauth2 (1.8.0)
- oauth2 (>= 1.4, < 3)
- omniauth (~> 2.0)
- omniauth-rails_csrf_protection (1.0.1)
- actionpack (>= 4.2)
- omniauth (~> 2.0)
- openssl (3.1.0)
+ openssl (3.2.0)
orm_adapter (0.5.0)
os (1.1.4)
pagy (6.0.4)
@@ -372,8 +348,6 @@ GEM
nio4r (~> 2.0)
racc (1.7.1)
rack (2.2.8)
- rack-protection (3.1.0)
- rack (~> 2.2, >= 2.2.4)
rack-proxy (0.7.6)
rack
rack-test (2.1.0)
@@ -514,9 +488,6 @@ GEM
simplecov-html (0.12.3)
simplecov_json_formatter (0.1.4)
smart_properties (1.17.0)
- snaky_hash (2.0.1)
- hashie
- version_gem (~> 1.1, >= 1.1.1)
sqlite3 (1.6.3)
mini_portile2 (~> 2.8.0)
sqlite3 (1.6.3-arm64-darwin)
@@ -537,7 +508,6 @@ GEM
uber (0.1.0)
unicode-display_width (2.5.0)
uniform_notifier (1.16.0)
- version_gem (1.1.3)
warden (1.2.9)
rack (>= 2.0.9)
web-console (4.2.0)
@@ -589,8 +559,6 @@ DEPENDENCIES
lograge
mysql2
oj
- omniauth-google-oauth2
- omniauth-rails_csrf_protection
pagy
pdf-reader
pg
diff --git a/README.md b/README.md
index 6ec713e5..8b021c4e 100644
--- a/README.md
+++ b/README.md
@@ -34,10 +34,10 @@ DocuSeal is an open source platform that provides secure and efficient digital d
## Features
- [x] PDF form fields builder (WYSIWYG)
-- [x] 10 field types available (Signature, Date, File, Checkbox etc.)
+- [x] 11 field types available (Signature, Date, File, Checkbox etc.)
- [x] Multiple submitters per document
- [x] Automated emails via SMTP
-- [x] Files storage on AWS S3, Google Storage, or Azure
+- [x] Files storage on disk or AWS S3, Google Storage, Azure Cloud
- [x] Automatic PDF eSignature
- [x] PDF signature verification
- [x] Users management
@@ -76,7 +76,7 @@ Run the app under a custom domain over https using docker compose (make sure you
HOST=your-domain-name.com docker-compose up
```
-## For Companies
+## For Businesses
### Integrate seamless document signing into your web or mobile apps with DocuSeal!
At DocuSeal we have expertise and technologies to make documents creation, filling, signing and processing seamlessly integrated with your product. We specialize in working with various industries, including **Banking, Healthcare, Transport, Real Estate, eCommerce, KYC, CRM, and other software products** that require bulk document signing. By leveraging DocuSeal, we can assist in reducing the overall cost of developing and processing electronic documents while ensuring security and compliance with local electronic document laws.
diff --git a/app/controllers/account_configs_controller.rb b/app/controllers/account_configs_controller.rb
new file mode 100644
index 00000000..cfa46a14
--- /dev/null
+++ b/app/controllers/account_configs_controller.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+class AccountConfigsController < ApplicationController
+ before_action :load_account_config
+ authorize_resource :account_config
+
+ ALLOWED_KEYS = [
+ AccountConfig::ALLOW_TYPED_SIGNATURE,
+ AccountConfig::FORCE_MFA
+ ].freeze
+
+ def create
+ @account_config.update!(account_config_params)
+
+ head :ok
+ end
+
+ private
+
+ def load_account_config
+ return head :not_found unless ALLOWED_KEYS.include?(account_config_params[:key])
+
+ @account_config =
+ AccountConfig.find_or_initialize_by(account: current_account, key: account_config_params[:key])
+ end
+
+ def account_config_params
+ params.required(:account_config).permit!.tap do |attrs|
+ attrs[:value] = attrs[:value] == '1' if attrs[:value].in?(%w[1 0])
+ end
+ end
+end
diff --git a/app/controllers/api/submissions_controller.rb b/app/controllers/api/submissions_controller.rb
index f2778ab4..3bb3ab88 100644
--- a/app/controllers/api/submissions_controller.rb
+++ b/app/controllers/api/submissions_controller.rb
@@ -13,6 +13,10 @@ module Api
submissions = Submissions.search(@submissions, params[:q])
submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present?
+ if params[:template_folder].present?
+ submissions = submissions.joins(template: :folder).where(folder: { name: params[:template_folder] })
+ end
+
submissions = paginate(submissions.preload(:created_by_user, :template, :submitters,
audit_trail_attachment: :blob))
@@ -50,35 +54,14 @@ module Api
end
def create
- is_send_email = !params[:send_email].in?(['false', false])
-
- submissions =
- if (emails = (params[:emails] || params[:email]).presence) && params[:submission].blank?
- Submissions.create_from_emails(template: @template,
- user: current_user,
- source: :api,
- mark_as_sent: is_send_email,
- emails:)
- else
- submissions_attrs, attachments = normalize_submissions_params!(submissions_params[:submission], @template)
-
- Submissions.create_from_submitters(
- template: @template,
- user: current_user,
- source: :api,
- mark_as_sent: is_send_email,
- submitters_order: params[:submitters_order] || 'preserved',
- submissions_attrs:
- )
- end
+ params[:send_email] = true unless params.key?(:send_email)
+ params[:send_sms] = false unless params.key?(:send_sms)
- Submissions.send_signature_requests(submissions, send_email: is_send_email)
+ submissions = create_submissions(@template, params)
- submitters = submissions.flat_map(&:submitters)
+ Submissions.send_signature_requests(submissions)
- save_default_value_attachments!(attachments, submitters)
-
- render json: submitters
+ render json: submissions.flat_map(&:submitters)
rescue Submitters::NormalizeValues::UnknownFieldName, Submitters::NormalizeValues::UnknownSubmitterName => e
render json: { error: e.message }, status: :unprocessable_entity
end
@@ -91,6 +74,37 @@ module Api
private
+ def create_submissions(template, params)
+ is_send_email = !params[:send_email].in?(['false', false])
+
+ if (emails = (params[:emails] || params[:email]).presence) && params[:submission].blank?
+ Submissions.create_from_emails(template:,
+ user: current_user,
+ source: :api,
+ mark_as_sent: is_send_email,
+ emails:,
+ params:)
+ else
+ submissions_attrs, attachments =
+ Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_params, template)
+
+ submissions = Submissions.create_from_submitters(
+ template:,
+ user: current_user,
+ source: :api,
+ mark_as_sent: is_send_email,
+ submitters_order: params[:submitters_order] || params[:order] || 'preserved',
+ submissions_attrs:,
+ params:
+ )
+
+ Submissions::NormalizeParamUtils.save_default_value_attachments!(attachments,
+ submissions.flat_map(&:submitters))
+
+ submissions
+ end
+ end
+
def serialize_params
{
only: %i[id source submitters_order created_at updated_at],
@@ -107,54 +121,19 @@ module Api
end
def submissions_params
- params.permit(submission: [{
- submitters: [[:uuid, :name, :email, :role, :completed, :phone, :application_key,
- { values: {}, readonly_fields: [],
- fields: [%i[name default_value readonly validation_pattern invalid_message]] }]]
- }])
- end
-
- def normalize_submissions_params!(submissions_params, template)
- attachments = []
-
- Array.wrap(submissions_params).each do |submission|
- submission[:submitters].each_with_index do |submitter, index|
- default_values = submitter[:values] || {}
-
- submitter[:fields]&.each { |f| default_values[f[:name]] = f[:default_value] if f[:default_value].present? }
-
- next if default_values.blank?
-
- values, new_attachments =
- Submitters::NormalizeValues.call(template,
- default_values,
- submitter[:role] || template.submitters[index]['name'])
-
- attachments.push(*new_attachments)
-
- submitter[:values] = values
- end
- end
-
- [submissions_params, attachments]
- end
-
- def save_default_value_attachments!(attachments, submitters)
- return if attachments.blank?
-
- attachments_index = attachments.index_by(&:uuid)
-
- submitters.each do |submitter|
- submitter.values.to_a.each do |_, value|
- attachment = attachments_index[value]
-
- next unless attachment
-
- attachment.record = submitter
-
- attachment.save!
- end
- end
+ key = params.key?(:submission) ? :submission : :submissions
+
+ params.permit(
+ key => [
+ [:send_email, :send_sms, {
+ message: %i[subject body],
+ submitters: [[:send_email, :send_sms, :uuid, :name, :email, :role,
+ :completed, :phone, :application_key,
+ { values: {}, readonly_fields: [], message: %i[subject body],
+ fields: [%i[name default_value readonly validation_pattern invalid_message]] }]]
+ }]
+ ]
+ ).fetch(key, [])
end
end
end
diff --git a/app/controllers/api/submitters_controller.rb b/app/controllers/api/submitters_controller.rb
index a0abb9c8..dec241ba 100644
--- a/app/controllers/api/submitters_controller.rb
+++ b/app/controllers/api/submitters_controller.rb
@@ -30,5 +30,79 @@ module Api
render json: Submitters::SerializeForApi.call(@submitter, with_template: true, with_events: true)
end
+
+ def update
+ if @submitter.completed_at?
+ return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_entity
+ end
+
+ role = @submitter.submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name']
+
+ normalized_params, new_attachments =
+ Submissions::NormalizeParamUtils.normalize_submitter_params!(submitter_params.merge(role:), @submitter.template,
+ for_submitter: @submitter)
+
+ Submissions::CreateFromSubmitters.maybe_set_template_fields(@submitter.submission,
+ [normalized_params],
+ submitter_uuid: @submitter.uuid)
+
+ assign_submitter_attrs(@submitter, normalized_params)
+
+ ApplicationRecord.transaction do
+ Submissions::NormalizeParamUtils.save_default_value_attachments!(new_attachments, [@submitter])
+
+ @submitter.save!
+
+ @submitter.submission.save!
+ end
+
+ if @submitter.completed_at?
+ ProcessSubmitterCompletionJob.perform_later(@submitter)
+ elsif normalized_params[:send_email] || normalized_params[:send_sms]
+ Submitters.send_signature_requests([@submitter])
+ end
+
+ render json: Submitters::SerializeForApi.call(@submitter, with_template: false, with_events: false)
+ end
+
+ def submitter_params
+ submitter_params = params.key?(:submitter) ? params.require(:submitter) : params
+
+ submitter_params.permit(
+ :send_email, :send_sms, :uuid, :name, :email, :role, :completed, :phone, :application_key,
+ { values: {}, readonly_fields: [], message: %i[subject body],
+ fields: [%i[name default_value readonly validation_pattern invalid_message]] }
+ )
+ end
+
+ private
+
+ def assign_submitter_attrs(submitter, attrs)
+ submitter.email = Submissions.normalize_email(attrs[:email]) if attrs.key?(:email)
+ submitter.phone = attrs[:phone].to_s.gsub(/[^0-9+]/, '') if attrs.key?(:phone)
+ submitter.values = submitter.values.merge(attrs[:values].to_unsafe_h) if attrs[:values]
+ submitter.completed_at = attrs[:completed] ? Time.current : submitter.completed_at
+ submitter.application_key = attrs[:application_key] if attrs.key?(:application_key)
+
+ assign_preferences(submitter, attrs)
+
+ submitter
+ end
+
+ def assign_preferences(submitter, attrs)
+ submitter_preferences = Submitters.normalize_preferences(submitter.account, current_user, attrs)
+
+ if submitter_preferences.key?('send_email')
+ submitter.preferences['send_email'] = submitter_preferences['send_email']
+ end
+
+ submitter.preferences['send_sms'] = submitter_preferences['send_sms'] if submitter_preferences.key?('send_sms')
+
+ return unless submitter_preferences.key?('email_message_uuid')
+
+ submitter.preferences['email_message_uuid'] = submitter_preferences['email_message_uuid']
+
+ submitter
+ end
end
end
diff --git a/app/controllers/api/templates_clone_controller.rb b/app/controllers/api/templates_clone_controller.rb
new file mode 100644
index 00000000..45c76076
--- /dev/null
+++ b/app/controllers/api/templates_clone_controller.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Api
+ class TemplatesCloneController < ApiBaseController
+ load_and_authorize_resource :template
+
+ def create
+ authorize!(:manage, @template)
+
+ template = current_account.templates.new(source: :api)
+
+ template.application_key = params[:application_key]
+ template.name = params[:name] || "#{@template.name} (Clone)"
+ template.account = @template.account
+ template.author = current_user
+ template.assign_attributes(@template.slice(:folder_id, :fields, :schema, :submitters))
+
+ if params[:folder_name].present?
+ template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
+ end
+
+ template.save!
+
+ Templates::CloneAttachments.call(template:, original_template: @template)
+
+ render json: template.as_json(serialize_params)
+ end
+
+ private
+
+ def serialize_params
+ {
+ include: { author: { only: %i[id email first_name last_name] },
+ documents: { only: %i[id uuid], methods: %i[url filename] } }
+ }
+ end
+ end
+end
diff --git a/app/controllers/api/templates_controller.rb b/app/controllers/api/templates_controller.rb
index 7772afba..fa79b605 100644
--- a/app/controllers/api/templates_controller.rb
+++ b/app/controllers/api/templates_controller.rb
@@ -9,6 +9,7 @@ module Api
templates = params[:archived] ? templates.archived : templates.active
templates = templates.where(application_key: params[:application_key]) if params[:application_key].present?
+ templates = templates.joins(:folder).where(folder: { name: params[:folder] }) if params[:folder].present?
templates = paginate(templates.preload(:author, documents_attachments: :blob))
@@ -57,7 +58,9 @@ module Api
schema: [%i[attachment_uuid name]],
submitters: [%i[name uuid]],
fields: [[:uuid, :submitter_uuid, :name, :type, :required, :readonly, :default_value,
- { options: [%i[value uuid]], areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]]
+ { preferences: {},
+ options: [%i[value uuid]],
+ areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]]
)
end
end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 38b541aa..16aa80fa 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -44,8 +44,9 @@ class ApplicationController < ActionController::Base
redirect_to setup_index_path unless User.exists?
end
- def button_title(title: 'Submit', disabled_with: 'Submitting', icon: nil, icon_disabled: nil)
- render_to_string(partial: 'shared/button_title', locals: { title:, disabled_with:, icon:, icon_disabled: })
+ def button_title(title: 'Submit', disabled_with: 'Submitting', title_class: '', icon: nil, icon_disabled: nil)
+ render_to_string(partial: 'shared/button_title',
+ locals: { title:, disabled_with:, title_class:, icon:, icon_disabled: })
end
def svg_icon(icon_name, class: '')
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index aa08ae4d..312f71cd 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -5,6 +5,7 @@ class DashboardController < ApplicationController
before_action :maybe_redirect_product_url
before_action :maybe_render_landing
+ before_action :maybe_redirect_mfa_setup
load_and_authorize_resource :template_folder, parent: false
load_and_authorize_resource :template, parent: false
@@ -62,6 +63,17 @@ class DashboardController < ApplicationController
redirect_to Docuseal::PRODUCT_URL, allow_other_host: true
end
+ def maybe_redirect_mfa_setup
+ return unless signed_in?
+ return if current_user.otp_required_for_login
+
+ return if !current_user.otp_required_for_login && !AccountConfig.exists?(value: true,
+ account_id: current_user.account_id,
+ key: AccountConfig::FORCE_MFA)
+
+ redirect_to mfa_setup_path, notice: 'Setup 2FA to continue'
+ end
+
def maybe_render_landing
return if signed_in?
diff --git a/app/controllers/errors_controller.rb b/app/controllers/errors_controller.rb
new file mode 100644
index 00000000..19c47507
--- /dev/null
+++ b/app/controllers/errors_controller.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+class ErrorsController < ActionController::Base
+ ENTERPRISE_FEATURE_MESSAGE =
+ 'This feature is available in Enterprise Edition: https://www.docuseal.co/pricing'
+
+ ENTERPRISE_PATHS = [
+ '/templates/html',
+ '/api/templates/html',
+ '/templates/pdf',
+ '/api/templates/pdf'
+ ].freeze
+
+ def show
+ if request.original_fullpath.in?(ENTERPRISE_PATHS) && error_status_code == 404
+ return render json: { status: 404, message: ENTERPRISE_FEATURE_MESSAGE }, status: :not_found
+ end
+
+ respond_to do |f|
+ f.json do
+ render json: { status: error_status_code }, status: error_status_code
+ end
+
+ f.html { render error_status_code.to_s, status: error_status_code }
+ end
+ end
+
+ private
+
+ def error_status_code
+ @error_status_code ||=
+ ActionDispatch::ExceptionWrapper.new(request.env,
+ request.env['action_dispatch.exception']).status_code
+ end
+end
diff --git a/app/controllers/mfa_setup_controller.rb b/app/controllers/mfa_setup_controller.rb
index ce690643..c65b2006 100644
--- a/app/controllers/mfa_setup_controller.rb
+++ b/app/controllers/mfa_setup_controller.rb
@@ -5,13 +5,11 @@ class MfaSetupController < ApplicationController
authorize!(:update, current_user)
end
- def new
- current_user.otp_secret ||= User.generate_otp_secret
+ before_action :set_provision_url, only: %i[show new]
- current_user.save!
+ def show; end
- @provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name)
- end
+ def new; end
def edit; end
@@ -26,7 +24,7 @@ class MfaSetupController < ApplicationController
@error_message = 'Code is invalid'
- render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/new'), status: :unprocessable_entity
+ render turbo_stream: turbo_stream.replace(:mfa_form, partial: 'mfa_setup/form'), status: :unprocessable_entity
end
end
@@ -41,4 +39,16 @@ class MfaSetupController < ApplicationController
render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/edit'), status: :unprocessable_entity
end
end
+
+ private
+
+ def set_provision_url
+ return redirect_to root_path, alert: '2FA has been set up already' if current_user.otp_required_for_login
+
+ current_user.otp_secret ||= User.generate_otp_secret
+
+ current_user.save!
+
+ @provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name)
+ end
end
diff --git a/app/controllers/notifications_settings_controller.rb b/app/controllers/notifications_settings_controller.rb
index 2189ccda..14139c55 100644
--- a/app/controllers/notifications_settings_controller.rb
+++ b/app/controllers/notifications_settings_controller.rb
@@ -12,7 +12,7 @@ class NotificationsSettingsController < ApplicationController
def index; end
def create
- if @account_config.save
+ if @account_config.value.present? ? @account_config.save : @account_config.delete
redirect_back fallback_location: settings_notifications_path, notice: 'Changes have been saved'
else
redirect_back fallback_location: settings_notifications_path, alert: 'Unable to save'
diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb
deleted file mode 100644
index de6d1910..00000000
--- a/app/controllers/omniauth_callbacks_controller.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-# frozen_string_literal: true
-
-class OmniauthCallbacksController < Devise::OmniauthCallbacksController
- def google_oauth2
- @user = Users.from_omniauth(request.env['omniauth.auth'])
-
- if @user.persisted?
- flash[:notice] = I18n.t('devise.omniauth_callbacks.success', kind: 'Google')
-
- sign_in_and_redirect @user, event: :authentication
- else
- redirect_to new_registration_path(oauth_callback: true, user: @user.slice(:email, :first_name, :last_name)),
- notice: 'Please complete registration with Google auth'
- end
- end
-end
diff --git a/app/controllers/start_form_controller.rb b/app/controllers/start_form_controller.rb
index 75e7c386..e1732ab1 100644
--- a/app/controllers/start_form_controller.rb
+++ b/app/controllers/start_form_controller.rb
@@ -14,21 +14,20 @@ class StartFormController < ApplicationController
def update
@submitter = Submitter.where(submission: @template.submissions.where(deleted_at: nil))
+ .order(id: :desc)
.then { |rel| params[:resubmit].present? ? rel.where(completed_at: nil) : rel }
.find_or_initialize_by(**submitter_params.compact_blank)
if @submitter.completed_at?
redirect_to start_form_completed_path(@template.slug, email: submitter_params[:email])
else
- @submitter.assign_attributes(
- uuid: @template.submitters.second.nil? ? @template.submitters.first['uuid'] : @template.submitters.second['uuid'],
- ip: request.remote_ip,
- ua: request.user_agent
- )
+ if @template.submitters.to_a.size > 1 && @submitter.new_record?
+ @error_message = 'Not found'
- @submitter.submission ||= Submission.new(template: @template,
- template_submitters: @template.submitters,
- source: :link)
+ return render :show
+ end
+
+ assign_submission_attributes(@submitter, @template) if @submitter.new_record?
if @submitter.save
redirect_to submit_form_path(@submitter.slug)
@@ -46,6 +45,21 @@ class StartFormController < ApplicationController
private
+ def assign_submission_attributes(submitter, template)
+ submitter.assign_attributes(
+ uuid: template.submitters.first['uuid'],
+ ip: request.remote_ip,
+ ua: request.user_agent,
+ preferences: { 'send_email' => true }
+ )
+
+ submitter.submission ||= Submission.new(template:,
+ template_submitters: template.submitters,
+ source: :link)
+
+ submitter
+ end
+
def submitter_params
params.require(:submitter).permit(:email, :phone, :name).tap do |attrs|
attrs[:email] = Submissions.normalize_email(attrs[:email])
diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb
index a327d56e..dda8a829 100644
--- a/app/controllers/submissions_controller.rb
+++ b/app/controllers/submissions_controller.rb
@@ -34,23 +34,30 @@ class SubmissionsController < ApplicationController
def create
authorize!(:create, Submission)
+ if params[:is_custom_message] != '1'
+ params.delete(:subject)
+ params.delete(:body)
+ end
+
submissions =
if params[:emails].present?
Submissions.create_from_emails(template: @template,
user: current_user,
source: :invite,
mark_as_sent: params[:send_email] == '1',
- emails: params[:emails])
+ emails: params[:emails],
+ params: params.merge('send_completed_email' => true))
else
Submissions.create_from_submitters(template: @template,
user: current_user,
source: :invite,
submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random',
mark_as_sent: params[:send_email] == '1',
- submissions_attrs: submissions_params[:submission].to_h.values)
+ submissions_attrs: submissions_params[:submission].to_h.values,
+ params: params.merge('send_completed_email' => true))
end
- Submissions.send_signature_requests(submissions, params)
+ Submissions.send_signature_requests(submissions)
redirect_to template_path(@template), notice: 'New recipients have been added'
end
diff --git a/app/controllers/submit_form_controller.rb b/app/controllers/submit_form_controller.rb
index 01fa5adc..63134478 100644
--- a/app/controllers/submit_form_controller.rb
+++ b/app/controllers/submit_form_controller.rb
@@ -38,6 +38,16 @@ class SubmitFormController < ApplicationController
def update
submitter = Submitter.find_by!(slug: params[:slug])
+ if submitter.completed_at?
+ return render json: { error: 'Form has been completed already.' }, status: :unprocessable_entity
+ end
+
+ if submitter.template.deleted_at? || submitter.submission.deleted_at?
+ Rollbar.info("Archived template: #{submitter.template.id}") if defined?(Rollbar)
+
+ return render json: { error: 'Form has been archived.' }, status: :unprocessable_entity
+ end
+
Submitters::SubmitValues.call(submitter, params, request)
head :ok
diff --git a/app/controllers/submitters_send_email_controller.rb b/app/controllers/submitters_send_email_controller.rb
index daeb777e..0e6c5d3b 100644
--- a/app/controllers/submitters_send_email_controller.rb
+++ b/app/controllers/submitters_send_email_controller.rb
@@ -4,6 +4,13 @@ class SubmittersSendEmailController < ApplicationController
load_and_authorize_resource :submitter, id_param: :submitter_slug, find_by: :slug
def create
+ if Docuseal.multitenant? && SubmissionEvent.exists?(submitter: @submitter,
+ event_type: 'send_email',
+ created_at: 24.hours.ago..Time.current)
+ return redirect_back(fallback_location: submission_path(@submitter.submission),
+ alert: 'Email has been sent already.')
+ end
+
SubmitterMailer.invitation_email(@submitter).deliver_later!
SubmissionEvent.create!(submitter: @submitter, event_type: 'send_email')
diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb
index 971a6ba2..e8861725 100644
--- a/app/controllers/templates_controller.rb
+++ b/app/controllers/templates_controller.rb
@@ -58,9 +58,11 @@ class TemplatesController < ApplicationController
def destroy
notice =
- if !Docuseal.multitenant? && params[:permanently].present?
+ if params[:permanently].present?
@template.destroy!
+ Rollbar.info("Remove template: #{@template.id}") if defined?(Rollbar)
+
'Template has been removed.'
else
@template.update!(deleted_at: Time.current)
diff --git a/app/controllers/timestamp_server_controller.rb b/app/controllers/timestamp_server_controller.rb
new file mode 100644
index 00000000..619a13b7
--- /dev/null
+++ b/app/controllers/timestamp_server_controller.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+class TimestampServerController < ApplicationController
+ before_action :build_encrypted_config
+ authorize_resource :encrypted_config
+
+ def create
+ return head :not_found if Docuseal.multitenant?
+
+ test_timeserver_url(@encrypted_config.value) if @encrypted_config.value.present?
+
+ if @encrypted_config.value.present? ? @encrypted_config.save : @encrypted_config.delete
+ redirect_back fallback_location: settings_notifications_path, notice: 'Changes have been saved'
+ else
+ redirect_back fallback_location: settings_notifications_path, alert: 'Unable to save'
+ end
+ rescue HexaPDF::Error, SocketError, Submissions::TimestampHandler::TimestampError, OpenSSL::Timestamp::TimestampError
+ redirect_back fallback_location: settings_notifications_path, alert: 'Invalid Timeserver'
+ end
+
+ private
+
+ def test_timeserver_url(url)
+ pdf = HexaPDF::Document.new
+ pdf.pages.add
+
+ pkcs = Accounts.load_signing_pkcs(current_account)
+
+ pdf.sign(StringIO.new,
+ reason: 'Test',
+ certificate: pkcs.certificate,
+ key: pkcs.key,
+ certificate_chain: pkcs.ca_certs || [],
+ timestamp_handler: Submissions::TimestampHandler.new(tsa_url: url))
+ end
+
+ def load_encrypted_config
+ @encrypted_config
+ end
+
+ def build_encrypted_config
+ @encrypted_config =
+ EncryptedConfig.find_or_initialize_by(account: current_account,
+ key: EncryptedConfig::TIMESTAMP_SERVER_URL_KEY)
+
+ @encrypted_config.assign_attributes(encrypted_config_params)
+ end
+
+ def encrypted_config_params
+ params.require(:encrypted_config).permit(:value)
+ end
+end
diff --git a/app/controllers/webhook_settings_controller.rb b/app/controllers/webhook_settings_controller.rb
index 2382dad3..1748ef4d 100644
--- a/app/controllers/webhook_settings_controller.rb
+++ b/app/controllers/webhook_settings_controller.rb
@@ -7,7 +7,9 @@ class WebhookSettingsController < ApplicationController
def show; end
def create
- @encrypted_config.update!(encrypted_config_params)
+ @encrypted_config.assign_attributes(encrypted_config_params)
+
+ @encrypted_config.value.present? ? @encrypted_config.save! : @encrypted_config.delete
redirect_back(fallback_location: settings_webhooks_path, notice: 'Webhook URL has been saved.')
end
diff --git a/app/javascript/application.js b/app/javascript/application.js
index a0998864..a65a679d 100644
--- a/app/javascript/application.js
+++ b/app/javascript/application.js
@@ -79,12 +79,15 @@ window.customElements.define('template-builder', class extends HTMLElement {
connectedCallback () {
this.appElem = document.createElement('div')
+ this.appElem.classList.add('md:h-screen')
+
this.app = createApp(TemplateBuilder, {
template: reactive(JSON.parse(this.dataset.template)),
templateAttachments: reactive(JSON.parse(this.dataset.templateAttachmentsIndex)),
backgroundColor: '#faf7f5',
withPhone: this.dataset.withPhone === 'true',
withLogo: this.dataset.withLogo !== 'false',
+ withPayment: this.dataset.withPayment !== 'false',
acceptFileTypes: this.dataset.acceptFileTypes,
isDirectUpload: this.dataset.isDirectUpload === 'true'
})
diff --git a/app/javascript/form.js b/app/javascript/form.js
index 6c7eec85..29038984 100644
--- a/app/javascript/form.js
+++ b/app/javascript/form.js
@@ -19,6 +19,7 @@ window.customElements.define('submission-form', class extends HTMLElement {
isDemo: this.dataset.isDemo === 'true',
attribution: this.dataset.attribution !== 'false',
withConfetti: true,
+ withTypedSignature: this.dataset.withTypedSignature !== 'false',
values: reactive(JSON.parse(this.dataset.values)),
completedButton: JSON.parse(this.dataset.completedButton),
attachments: reactive(JSON.parse(this.dataset.attachments)),
diff --git a/app/javascript/submission_form/area.vue b/app/javascript/submission_form/area.vue
index 8162a28b..bfe46326 100644
--- a/app/javascript/submission_form/area.vue
+++ b/app/javascript/submission_form/area.vue
@@ -98,7 +98,7 @@
v-else
class="flex absolute lg:text-base"
:style="computedStyle"
- :class="{ 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars, 'cursor-default': !submittable, 'bg-red-100 border cursor-pointer ': submittable, 'border-red-100': !isActive && submittable, 'bg-opacity-70': !isActive && !isValueSet && submittable, 'border-red-500 border-dashed z-10': isActive && submittable, 'bg-opacity-30': (isActive || isValueSet) && submittable }"
+ :class="{ 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars, 'cursor-default': !submittable, 'bg-red-100 border cursor-pointer ': submittable, 'border-red-100': !isActive && submittable, 'bg-opacity-80': !isActive && !isValueSet && submittable, 'border-red-500 border-dashed z-10': isActive && submittable, 'bg-opacity-40': (isActive || isValueSet) && submittable }"
>
diff --git a/app/javascript/submission_form/signature_step.vue b/app/javascript/submission_form/signature_step.vue
index 4157133d..a4c90958 100644
--- a/app/javascript/submission_form/signature_step.vue
+++ b/app/javascript/submission_form/signature_step.vue
@@ -23,7 +23,7 @@
@@ -149,6 +149,11 @@ export default {
required: true,
default: false
},
+ withTypedSignature: {
+ type: Boolean,
+ required: false,
+ default: true
+ },
attachmentsIndex: {
type: Object,
required: false,
diff --git a/app/javascript/template_builder/area.vue b/app/javascript/template_builder/area.vue
index 3f5ff0e9..35a8f901 100644
--- a/app/javascript/template_builder/area.vue
+++ b/app/javascript/template_builder/area.vue
@@ -45,7 +45,7 @@
:me-fields="['my_text', 'my_signature', 'my_initials', 'my_date', 'my_check'].includes(field.type)"
:hide-select-me="true"
:compact="true"
- :editable="editable"
+ :editable="editable && !defaultField"
:menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px]'"
:submitters="template.submitters"
@update:model-value="save"
@@ -55,7 +55,7 @@
v-if="!['my_text', 'my_signature', 'my_initials', 'my_date', 'my_check'].includes(field.type)"
v-model="field.type"
:button-width="27"
- :editable="editable"
+ :editable="editable && !defaultField"
:button-classes="'px-1'"
:menu-classes="'bg-white rounded-t-none'"
@update:model-value="[maybeUpdateOptions(), save()]"
@@ -64,7 +64,7 @@
-
-
+
@@ -227,6 +249,7 @@
:fields="template.fields"
:submitters="template.submitters"
:selected-submitter="selectedSubmitter"
+ :default-submitters="defaultSubmitters"
:default-fields="defaultFields"
:with-sticky-submitters="withStickySubmitters"
:editable="editable"
@@ -254,7 +277,7 @@ import DocumentPreview from './preview'
import DocumentControls from './controls'
import FieldType from './field_type'
import { t } from './i18n'
-import { IconUsersPlus, IconDeviceFloppy, IconInnerShadowTop, IconPlus, IconX } from '@tabler/icons-vue'
+import { IconUsersPlus, IconDeviceFloppy, IconWritingSign, IconInnerShadowTop, IconPlus, IconX } from '@tabler/icons-vue'
import { v4 } from 'uuid'
import { ref, computed } from 'vue'
@@ -268,6 +291,7 @@ export default {
IconPlus,
FieldType,
IconX,
+ IconWritingSign,
Logo,
Dropzone,
DocumentPreview,
@@ -286,6 +310,7 @@ export default {
baseFetch: this.baseFetch,
backgroundColor: this.backgroundColor,
withPhone: this.withPhone,
+ withPayment: this.withPayment,
selectedAreaRef: computed(() => this.selectedAreaRef),
baseUrl: this.baseUrl,
t: this.t
@@ -341,6 +366,13 @@ export default {
required: false,
default: true
},
+ onUpload: {
+ type: Function,
+ required: false,
+ default () {
+ return () => {}
+ }
+ },
withStickySubmitters: {
type: Boolean,
required: false,
@@ -351,11 +383,21 @@ export default {
required: false,
default: true
},
+ withTitle: {
+ type: Boolean,
+ required: false,
+ default: true
+ },
withPhone: {
type: Boolean,
required: false,
default: false
},
+ withPayment: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
fetchOptions: {
type: Object,
required: false,
@@ -433,6 +475,12 @@ export default {
window.addEventListener('keydown', this.onKeyDown)
window.addEventListener('resize', this.onWindowResize)
+
+ this.$nextTick(() => {
+ if (document.location.search?.includes('stripe_connect_success')) {
+ document.querySelector('form[action="/auth/stripe_connect"]')?.closest('.dropdown')?.querySelector('label')?.focus()
+ }
+ })
},
unmounted () {
document.removeEventListener('keyup', this.onKeyUp)
@@ -466,6 +514,12 @@ export default {
field.options = [{ value: '', uuid: v4() }]
}
+ if (type === 'date') {
+ field.preferences = {
+ format: Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'
+ }
+ }
+
this.drawField = field
this.drawOption = null
},
@@ -520,7 +574,7 @@ export default {
this.selectedAreaRef.value = null
}
- if (['Backspace', 'Delete'].includes(e.key) && this.selectedAreaRef.value && document.activeElement === document.body) {
+ if (this.editable && ['Backspace', 'Delete'].includes(e.key) && this.selectedAreaRef.value && document.activeElement === document.body) {
this.removeArea(this.selectedAreaRef.value)
this.selectedAreaRef.value = null
@@ -681,6 +735,12 @@ export default {
field.options = [{ value: '', uuid: v4() }]
}
+ if (field.type === 'date') {
+ field.preferences = {
+ format: Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'
+ }
+ }
+
const fieldArea = {
x: (area.x - 6) / area.maskW,
y: area.y / area.maskH,
@@ -751,6 +811,10 @@ export default {
this.scrollIntoDocument(schema[0])
})
+ if (this.onUpload) {
+ this.onUpload(this.template)
+ }
+
this.save()
},
updateName (value) {
@@ -784,6 +848,10 @@ export default {
})
})
+ if (this.onUpload) {
+ this.onUpload(this.template)
+ }
+
this.save()
},
moveDocument (item, direction) {
diff --git a/app/javascript/template_builder/contenteditable.vue b/app/javascript/template_builder/contenteditable.vue
index 88db4ba4..31ae0cf1 100644
--- a/app/javascript/template_builder/contenteditable.vue
+++ b/app/javascript/template_builder/contenteditable.vue
@@ -24,11 +24,10 @@
*
({})
},
+ defaultFields: {
+ type: Array,
+ required: false,
+ default: () => []
+ },
selectedSubmitter: {
type: Object,
required: true
diff --git a/app/javascript/template_builder/field.vue b/app/javascript/template_builder/field.vue
index 2490e348..12dc0d54 100644
--- a/app/javascript/template_builder/field.vue
+++ b/app/javascript/template_builder/field.vue
@@ -14,7 +14,7 @@
+
+
+
+
import Contenteditable from './contenteditable'
import FieldType from './field_type'
+import PaymentSettings from './payment_settings'
import { IconShape, IconNewSection, IconTrashX, IconCopy, IconSettings } from '@tabler/icons-vue'
import { v4 } from 'uuid'
@@ -270,6 +303,7 @@ export default {
Contenteditable,
IconSettings,
IconShape,
+ PaymentSettings,
IconNewSection,
IconTrashX,
IconCopy,
@@ -281,6 +315,11 @@ export default {
type: Object,
required: true
},
+ defaultField: {
+ type: Object,
+ required: false,
+ default: null
+ },
editable: {
type: Boolean,
required: false,
@@ -295,23 +334,85 @@ export default {
emits: ['set-draw', 'remove', 'scroll-to'],
data () {
return {
- isNameFocus: false
+ isNameFocus: false,
+ showPaymentModal: false
}
},
computed: {
fieldNames: FieldType.computed.fieldNames,
+ dateFormats () {
+ return [
+ 'MM/DD/YYYY',
+ 'DD/MM/YYYY',
+ 'YYYY-MM-DD',
+ 'DD-MM-YYYY',
+ 'DD.MM.YYYY',
+ 'MMM D, YYYY',
+ 'MMMM D, YYYY',
+ 'D MMM YYYY',
+ 'D MMMM YYYY'
+ ]
+ },
defaultName () {
- const typeIndex = this.template.fields.filter((f) => f.type === this.field.type).indexOf(this.field)
+ if (this.field.type === 'payment' && this.field.preferences?.price) {
+ const { price, currency } = this.field.preferences || {}
+
+ const formattedPrice = new Intl.NumberFormat([], {
+ style: 'currency',
+ currency
+ }).format(price)
+
+ return `${this.fieldNames[this.field.type]} ${formattedPrice}`
+ } else {
+ const typeIndex = this.template.fields.filter((f) => f.type === this.field.type).indexOf(this.field)
- const suffix = { multiple: 'Select', radio: 'Group' }[this.field.type] || 'Field'
+ const suffix = { multiple: 'Select', radio: 'Group' }[this.field.type] || 'Field'
- return `${this.fieldNames[this.field.type]} ${suffix} ${typeIndex + 1}`
+ return `${this.fieldNames[this.field.type]} ${suffix} ${typeIndex + 1}`
+ }
},
areas () {
return this.field.areas || []
}
},
+ created () {
+ this.field.preferences ||= {}
+
+ if (this.field.type === 'date') {
+ this.field.preferences.format ||=
+ (Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY')
+ }
+ },
methods: {
+ formatDate (date, format) {
+ const monthFormats = {
+ M: 'numeric',
+ MM: '2-digit',
+ MMM: 'short',
+ MMMM: 'long'
+ }
+
+ const dayFormats = {
+ D: 'numeric',
+ DD: '2-digit'
+ }
+
+ const yearFormats = {
+ YYYY: 'numeric',
+ YY: '2-digit'
+ }
+
+ const parts = new Intl.DateTimeFormat([], {
+ day: dayFormats[format.match(/D+/)],
+ month: monthFormats[format.match(/M+/)],
+ year: yearFormats[format.match(/Y+/)]
+ }).formatToParts(date)
+
+ return format
+ .replace(/D+/, parts.find((p) => p.type === 'day').value)
+ .replace(/M+/, parts.find((p) => p.type === 'month').value)
+ .replace(/Y+/, parts.find((p) => p.type === 'year').value)
+ },
copyToAllPages (field) {
const areaString = JSON.stringify(field.areas[0])
diff --git a/app/javascript/template_builder/field_submitter.vue b/app/javascript/template_builder/field_submitter.vue
index 8259a9e7..c829ebfa 100644
--- a/app/javascript/template_builder/field_submitter.vue
+++ b/app/javascript/template_builder/field_submitter.vue
@@ -76,7 +76,7 @@
:stroke-width="1.6"
/>
- Add Submitter
+ Add {{ names[submitters.length] }}
@@ -100,37 +100,35 @@
:style="{backgroundColor: selectedSubmitter.name === 'Me'? colors[submitters.indexOf(selectedSubmitter)] : ''}"
/>
-
+
+
+
+
+
+
+
+
+
- Add Submitter
+ Add {{ names[submitters.length] }}
@@ -265,16 +263,16 @@ export default {
names () {
return [
'Me',
- 'First Submitter',
- 'Second Submitter',
- 'Third Submitter',
- 'Fourth Submitter',
- 'Fifth Submitter',
- 'Sixth Submitter',
- 'Seventh Submitter',
- 'Eighth Submitter',
- 'Ninth Submitter',
- 'Tenth Submitter'
+ 'First Party',
+ 'Second Party',
+ 'Third Party',
+ 'Fourth Party',
+ 'Fifth Party',
+ 'Sixth Party',
+ 'Seventh Party',
+ 'Eighth Party',
+ 'Ninth Party',
+ 'Tenth Party'
]
},
selectedSubmitter () {
diff --git a/app/javascript/template_builder/field_type.vue b/app/javascript/template_builder/field_type.vue
index 6817f995..e79c80c9 100644
--- a/app/javascript/template_builder/field_type.vue
+++ b/app/javascript/template_builder/field_type.vue
@@ -25,7 +25,7 @@
v-for="(icon, type) in fieldIcons"
:key="type"
>
-
+
diff --git a/app/jobs/process_submitter_completion_job.rb b/app/jobs/process_submitter_completion_job.rb
index e481a74e..cf133b47 100644
--- a/app/jobs/process_submitter_completion_job.rb
+++ b/app/jobs/process_submitter_completion_job.rb
@@ -24,14 +24,16 @@ class ProcessSubmitterCompletionJob < ApplicationJob
def enqueue_completed_emails(submitter)
user = submitter.submission.created_by_user || submitter.template.author
- if submitter.template.account.users.exists?(id: user.id)
+ if submitter.template.account.users.exists?(id: user.id) &&
+ submitter.submission.preferences['send_email'] != false
bcc = submitter.submission.template.account.account_configs
.find_by(key: AccountConfig::BCC_EMAILS)&.value
SubmitterMailer.completed_email(submitter, user, bcc:).deliver_later!
end
- to = submitter.submission.submitters.sort_by(&:completed_at).select(&:email?).map(&:friendly_name).join(', ')
+ to = submitter.submission.submitters.reject { |e| e.preferences['send_email'] == false }
+ .sort_by(&:completed_at).select(&:email?).map(&:friendly_name).join(', ')
SubmitterMailer.documents_copy_email(submitter, to:).deliver_later! if to.present?
end
@@ -48,6 +50,6 @@ class ProcessSubmitterCompletionJob < ApplicationJob
next_submitter = submitter.submission.submitters.find { |s| s.uuid == next_submitter_item['uuid'] }
- Submitters.send_signature_requests([next_submitter], send_email: true)
+ Submitters.send_signature_requests([next_submitter])
end
end
diff --git a/app/jobs/send_submitter_invitation_email_job.rb b/app/jobs/send_submitter_invitation_email_job.rb
index 4036df16..b57f54db 100644
--- a/app/jobs/send_submitter_invitation_email_job.rb
+++ b/app/jobs/send_submitter_invitation_email_job.rb
@@ -4,7 +4,7 @@ class SendSubmitterInvitationEmailJob < ApplicationJob
def perform(params = {})
submitter = Submitter.find(params['submitter_id'])
- SubmitterMailer.invitation_email(submitter, subject: params['subject'], body: params['body']).deliver_now!
+ SubmitterMailer.invitation_email(submitter).deliver_now!
SubmissionEvent.create!(submitter:, event_type: 'send_email')
diff --git a/app/mailers/submitter_mailer.rb b/app/mailers/submitter_mailer.rb
index 80e43313..6941d607 100644
--- a/app/mailers/submitter_mailer.rb
+++ b/app/mailers/submitter_mailer.rb
@@ -3,24 +3,34 @@
class SubmitterMailer < ApplicationMailer
MAX_ATTACHMENTS_SIZE = 10.megabytes
- def invitation_email(submitter, body: nil, subject: nil)
+ DEFAULT_INVITATION_SUBJECT = 'You are invited to submit a form'
+
+ def invitation_email(submitter)
@current_account = submitter.submission.template.account
@submitter = submitter
- @body = body.presence
+
+ if submitter.preferences['email_message_uuid']
+ @email_message = submitter.account.email_messages.find_by(uuid: submitter.preferences['email_message_uuid'])
+ end
+
+ @body = @email_message&.body.presence
+ @subject = @email_message&.subject.presence
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY)
subject =
- if @email_config || subject.present?
- ReplaceEmailVariables.call(subject.presence || @email_config.value['subject'], submitter:)
+ if @email_config || @subject
+ ReplaceEmailVariables.call(@subject || @email_config.value['subject'], submitter:)
else
- 'You are invited to submit a form'
+ DEFAULT_INVITATION_SUBJECT
end
- mail(to: @submitter.friendly_name,
- from: from_address_for_submitter(submitter),
- subject:,
- reply_to: (submitter.submission.created_by_user || submitter.template.author)&.friendly_name)
+ mail(
+ to: @submitter.friendly_name,
+ from: from_address_for_submitter(submitter),
+ subject:,
+ reply_to: (submitter.submission.created_by_user || submitter.template.author)&.friendly_name&.sub(/\+\w+@/, '@')
+ )
end
def completed_email(submitter, user, bcc: nil)
@@ -45,7 +55,7 @@ class SubmitterMailer < ApplicationMailer
end
mail(from: from_address_for_submitter(submitter),
- to: user.friendly_name,
+ to: user.role == 'integration' ? user.friendly_name.sub(/\+\w+@/, '@') : user.friendly_name,
bcc:,
subject:)
end
@@ -86,7 +96,26 @@ class SubmitterMailer < ApplicationMailer
total_size = audit_trail_data.size
end
- documents.each do |attachment|
+ total_size = add_attachments_with_size_limit(documents, total_size)
+
+ attachments[submitter.submission.audit_trail.filename.to_s] = audit_trail_data if audit_trail_data
+
+ file_fields = submitter.submission.template_fields.select { |e| e['type'].in?(%w[file payment]) }
+
+ if file_fields.pluck('submitter_uuid').uniq.size == 1
+ storage_attachments =
+ submitter.attachments.where(uuid: submitter.values.values_at(*file_fields.pluck('uuid')).flatten)
+
+ add_attachments_with_size_limit(storage_attachments, total_size)
+ end
+
+ documents
+ end
+
+ def add_attachments_with_size_limit(storage_attachments, current_size)
+ total_size = current_size
+
+ storage_attachments.each do |attachment|
total_size += attachment.byte_size
break if total_size >= MAX_ATTACHMENTS_SIZE
@@ -94,12 +123,15 @@ class SubmitterMailer < ApplicationMailer
attachments[attachment.filename.to_s] = attachment.download
end
- attachments[submitter.submission.audit_trail.filename.to_s] = audit_trail_data if audit_trail_data
-
- documents
+ total_size
end
def from_address_for_submitter(submitter)
- submitter.submission.created_by_user&.friendly_name || submitter.submission.template.author.friendly_name
+ if submitter.submission.source.in?(%w[api embed]) &&
+ (from_email = AccountConfig.find_by(account: submitter.account, key: 'integration_from_email')&.value.presence)
+ from_email
+ else
+ (submitter.submission.created_by_user || submitter.submission.template.author).friendly_name
+ end
end
end
diff --git a/app/models/account.rb b/app/models/account.rb
index a9659b21..8c2792e9 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -15,6 +15,7 @@ class Account < ApplicationRecord
has_many :users, dependent: :destroy
has_many :encrypted_configs, dependent: :destroy
has_many :account_configs, dependent: :destroy
+ has_many :email_messages, dependent: :destroy
has_many :templates, dependent: :destroy
has_many :template_folders, dependent: :destroy
has_one :default_template_folder, -> { where(name: TemplateFolder::DEFAULT_NAME) },
diff --git a/app/models/account_config.rb b/app/models/account_config.rb
index b592cbca..0c435e0e 100644
--- a/app/models/account_config.rb
+++ b/app/models/account_config.rb
@@ -25,6 +25,8 @@ class AccountConfig < ApplicationRecord
SUBMITTER_COMPLETED_EMAIL_KEY = 'submitter_completed_email'
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY = 'submitter_documents_copy_email'
BCC_EMAILS = 'bcc_emails'
+ FORCE_MFA = 'force_mfa'
+ ALLOW_TYPED_SIGNATURE = 'allow_typed_signature'
SUBMITTER_REMAILERS = 'submitter_reminders'
FORM_COMPLETED_BUTTON_KEY = 'form_completed_button'
diff --git a/app/models/email_message.rb b/app/models/email_message.rb
new file mode 100644
index 00000000..25156d6f
--- /dev/null
+++ b/app/models/email_message.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: email_messages
+#
+# id :bigint not null, primary key
+# body :text not null
+# sha1 :string not null
+# subject :text not null
+# uuid :string not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# account_id :bigint not null
+# author_id :bigint not null
+#
+# Indexes
+#
+# index_email_messages_on_account_id (account_id)
+# index_email_messages_on_sha1 (sha1)
+# index_email_messages_on_uuid (uuid)
+#
+# Foreign Keys
+#
+# fk_rails_... (account_id => accounts.id)
+# fk_rails_... (author_id => users.id)
+#
+class EmailMessage < ApplicationRecord
+ belongs_to :author, class_name: 'User'
+ belongs_to :account
+
+ attribute :uuid, :string, default: -> { SecureRandom.uuid }
+
+ before_validation :set_sha1, on: :create
+
+ def set_sha1
+ self.sha1 = Digest::SHA1.hexdigest({ subject:, body: }.to_json)
+ end
+end
diff --git a/app/models/encrypted_config.rb b/app/models/encrypted_config.rb
index 17e3269a..21d51baa 100644
--- a/app/models/encrypted_config.rb
+++ b/app/models/encrypted_config.rb
@@ -25,6 +25,7 @@ class EncryptedConfig < ApplicationRecord
FILES_STORAGE_KEY = 'active_storage',
EMAIL_SMTP_KEY = 'action_mailer_smtp',
ESIGN_CERTS_KEY = 'esign_certs',
+ TIMESTAMP_SERVER_URL_KEY = 'timestamp_server_url',
APP_URL_KEY = 'app_url',
WEBHOOK_URL_KEY = 'webhook_url'
].freeze
diff --git a/app/models/submission.rb b/app/models/submission.rb
index e240c774..33f01c4b 100644
--- a/app/models/submission.rb
+++ b/app/models/submission.rb
@@ -6,6 +6,7 @@
#
# id :bigint not null, primary key
# deleted_at :datetime
+# preferences :text not null
# slug :string not null
# source :text not null
# submitters_order :string not null
@@ -36,9 +37,12 @@ class Submission < ApplicationRecord
has_many :submitters, dependent: :destroy
has_many :submission_events, dependent: :destroy
+ attribute :preferences, :string, default: -> { {} }
+
serialize :template_fields, JSON
serialize :template_schema, JSON
serialize :template_submitters, JSON
+ serialize :preferences, JSON
attribute :source, :string, default: 'link'
attribute :submitters_order, :string, default: 'random'
@@ -68,7 +72,9 @@ class Submission < ApplicationRecord
}, scope: false, prefix: true
def audit_trail_url
- audit_trail&.url
+ return if audit_trail.blank?
+
+ Rails.application.routes.url_helpers.rails_storage_proxy_url(audit_trail, **Docuseal.default_url_options)
end
alias audit_log_url audit_trail_url
end
diff --git a/app/models/submission_event.rb b/app/models/submission_event.rb
index 4d73d3dd..e8770501 100644
--- a/app/models/submission_event.rb
+++ b/app/models/submission_event.rb
@@ -39,6 +39,7 @@ class SubmissionEvent < ApplicationRecord
send_email: 'send_email',
send_reminder_email: 'send_reminder_email',
send_sms: 'send_sms',
+ send_2fa_sms: 'send_2fa_sms',
open_email: 'open_email',
click_email: 'click_email',
click_sms: 'click_sms',
diff --git a/app/models/submitter.rb b/app/models/submitter.rb
index 36c21508..f5065d7e 100644
--- a/app/models/submitter.rb
+++ b/app/models/submitter.rb
@@ -12,6 +12,7 @@
# name :string
# opened_at :datetime
# phone :string
+# preferences :text not null
# sent_at :datetime
# slug :string not null
# ua :string
@@ -37,9 +38,11 @@ class Submitter < ApplicationRecord
has_one :account, through: :template
attribute :values, :string, default: -> { {} }
+ attribute :preferences, :string, default: -> { {} }
attribute :slug, :string, default: -> { SecureRandom.base58(14) }
serialize :values, JSON
+ serialize :preferences, JSON
has_many_attached :documents
has_many_attached :attachments
diff --git a/app/models/user.rb b/app/models/user.rb
index d8f23419..40d7b884 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -55,9 +55,9 @@ class User < ApplicationRecord
has_many :template_folders, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
has_many :user_configs, dependent: :destroy
has_many :encrypted_configs, dependent: :destroy, class_name: 'EncryptedUserConfig'
+ has_many :email_messages, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable
- devise :registerable, :omniauthable, omniauth_providers: [:google_oauth2] if Docuseal.multitenant?
attribute :role, :string, default: ADMIN_ROLE
attribute :uuid, :string, default: -> { SecureRandom.uuid }
diff --git a/app/views/accounts/show.html.erb b/app/views/accounts/show.html.erb
index 5734e240..4682fafd 100644
--- a/app/views/accounts/show.html.erb
+++ b/app/views/accounts/show.html.erb
@@ -36,6 +36,35 @@
<% end %>
<% end %>
+ <% if can?(:manage, AccountConfig) %>
+
+
Preferences
+ <% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::FORCE_MFA) %>
+ <% if can?(:manage, account_config) %>
+ <%= form_for account_config, url: account_configs_path, method: :post do |f| %>
+ <%= f.hidden_field :key %>
+
+
+ Force 2FA with Authenticator App
+
+ <%= f.check_box :value, class: 'toggle', checked: account_config.value, onchange: 'this.form.requestSubmit()' %>
+
+ <% end %>
+ <% end %>
+ <% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::ALLOW_TYPED_SIGNATURE) %>
+ <% if can?(:manage, account_config) %>
+ <%= form_for account_config, url: account_configs_path, method: :post do |f| %>
+ <%= f.hidden_field :key %>
+
+
+ Allow typed text signatures
+
+ <%= f.check_box :value, class: 'toggle', checked: account_config.value != false, onchange: 'this.form.requestSubmit()' %>
+
+ <% end %>
+ <% end %>
+
+ <% end %>
diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb
index c441dae3..86f400b1 100644
--- a/app/views/dashboard/index.html.erb
+++ b/app/views/dashboard/index.html.erb
@@ -8,7 +8,7 @@
<% end %>
<% if can?(:create, ::Template) %>
<%= render 'templates/upload_button' %>
- <%= link_to new_template_path, class: 'btn btn-primary text-base btn-md gap-2', data: { turbo_frame: :modal } do %>
+ <%= link_to new_template_path, class: 'white-button !border gap-2', data: { turbo_frame: :modal } do %>
<%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %>
Create
<% end %>
diff --git a/app/views/devise/registrations/show.html.erb b/app/views/devise/registrations/show.html.erb
index 0b139107..c421644f 100644
--- a/app/views/devise/registrations/show.html.erb
+++ b/app/views/devise/registrations/show.html.erb
@@ -8,7 +8,7 @@
<%= f.label :email, class: 'label' %>
- <%= f.email_field :email, autofocus: true, autocomplete: 'email', class: 'base-input' %>
+ <%= f.email_field :email, autofocus: true, autocomplete: 'email', class: 'base-input', placeholder: 'Enter email to continue' %>
@@ -16,11 +16,18 @@
<% end %>
<% if devise_mapping.omniauthable? %>
- <%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %>
-
- <%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %>
- <%= f.button button_title(title: 'Sign up with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), class: 'white-button w-full mt-4' %>
- <% end %>
+
+ <%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %>
+
+ <%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %>
+ <%= f.button button_title(title: 'Sign up with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), class: 'white-button w-full mt-4' %>
+ <% end %>
+ <%= form_for '', url: omniauth_authorize_path(resource_name, :microsoft_office365), data: { turbo: false }, method: :post do |f| %>
+
+ <%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query, id: 'state_microsoft' %>
+ <%= f.button button_title(title: 'Sign up with Microsoft', icon: svg_icon('brand_microsoft', class: 'w-6 h-6')), class: 'white-button w-full' %>
+ <% end %>
+
<% end %>
<%= render 'devise/shared/links' %>
diff --git a/app/views/devise/sessions/_extra_links.html.erb b/app/views/devise/sessions/_extra_links.html.erb
new file mode 100644
index 00000000..e69de29b
diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb
index ea6d7f55..7c117573 100644
--- a/app/views/devise/sessions/new.html.erb
+++ b/app/views/devise/sessions/new.html.erb
@@ -20,11 +20,19 @@
<% end %>
<% if devise_mapping.omniauthable? %>
- <%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %>
-
- <%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %>
- <%= f.button button_title(title: 'Sign in with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), class: 'white-button w-full mt-4' %>
- <% end %>
+
+ <%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %>
+
+ <%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %>
+ <%= f.button button_title(title: 'Sign in with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), class: 'white-button w-full mt-4' %>
+ <% end %>
+ <%= form_for '', url: omniauth_authorize_path(resource_name, :microsoft_office365), data: { turbo: false }, method: :post do |f| %>
+
+ <%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query, id: 'state_microsoft' %>
+ <%= f.button button_title(title: 'Sign in with Microsoft', icon: svg_icon('brand_microsoft', class: 'w-6 h-6')), class: 'white-button w-full' %>
+ <% end %>
+
<% end %>
+ <%= render 'extra_links' %>
<%= render 'devise/shared/links' %>
diff --git a/public/404.html b/app/views/errors/404.html
similarity index 100%
rename from public/404.html
rename to app/views/errors/404.html
diff --git a/public/422.html b/app/views/errors/422.html
similarity index 100%
rename from public/422.html
rename to app/views/errors/422.html
diff --git a/public/500.html b/app/views/errors/500.html
similarity index 100%
rename from public/500.html
rename to app/views/errors/500.html
diff --git a/app/views/esign_settings/show.html.erb b/app/views/esign_settings/show.html.erb
index 6987d74c..5f93bacd 100644
--- a/app/views/esign_settings/show.html.erb
+++ b/app/views/esign_settings/show.html.erb
@@ -97,5 +97,31 @@
+ <% encrypted_config = EncryptedConfig.find_or_initialize_by(account: current_account, key: EncryptedConfig::TIMESTAMP_SERVER_URL_KEY) %>
+ <% if !Docuseal.multitenant? && can?(:manage, encrypted_config) %>
+
+
+
Timestamp Server
+
+ <%= form_for encrypted_config, url: timestamp_server_index_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
+
+ <%= f.label :value, class: 'label' do %>
+
+
+ Timeserver URL
+
+
+ <%= svg_icon('info_circle', class: 'w-4 h-4') %>
+
+
+ <% end %>
+ <%= f.url_field :value, autocomplete: 'off', class: 'base-input', placeholder: 'URL (optional)' %>
+
+
+ <%= f.button button_title(title: 'Save', disabled_with: 'Updating'), class: 'base-button' %>
+
+ <% end %>
+ <% end %>
+
diff --git a/app/views/icons/_archive.html.erb b/app/views/icons/_archive.html.erb
new file mode 100644
index 00000000..51ac5900
--- /dev/null
+++ b/app/views/icons/_archive.html.erb
@@ -0,0 +1 @@
+
diff --git a/app/views/icons/_brand_microsoft.html.erb b/app/views/icons/_brand_microsoft.html.erb
new file mode 100644
index 00000000..cbcab3f9
--- /dev/null
+++ b/app/views/icons/_brand_microsoft.html.erb
@@ -0,0 +1,3 @@
+
diff --git a/app/views/mfa_setup/_form.html.erb b/app/views/mfa_setup/_form.html.erb
new file mode 100644
index 00000000..5b59d451
--- /dev/null
+++ b/app/views/mfa_setup/_form.html.erb
@@ -0,0 +1,17 @@
+<%= form_for '', url: mfa_setup_path, data: { turbo_frame: :_top }, html: { id: 'mfa_form' } do |f| %>
+
+ Use an authenticator mobile app like Google Authenticator or 1Password to scan the QR code below.
+
+
+ <%== RQRCode::QRCode.new(@provision_url).as_svg(viewbox: true, svg_attributes: { class: 'w-80 h-80 my-4 mx-auto' }) %>
+
+
+ <%= f.text_field :otp_attempt, required: true, placeholder: 'XXX-XXX', class: 'base-input text-center' %>
+
+ <%= @error_message %>
+
+
+
+ <%= f.button button_title(title: 'Save'), class: 'base-button' %>
+
+<% end %>
diff --git a/app/views/mfa_setup/new.html.erb b/app/views/mfa_setup/new.html.erb
index 14a20172..cece5f5e 100644
--- a/app/views/mfa_setup/new.html.erb
+++ b/app/views/mfa_setup/new.html.erb
@@ -1,19 +1,3 @@
<%= render 'shared/turbo_modal', title: 'Setup 2FA' do %>
- <%= form_for '', url: mfa_setup_path, data: { turbo_frame: :_top } do |f| %>
-
- Use an authenticator mobile app like Google Authenticator or 1Password to scan the QR code below.
-
-
- <%== RQRCode::QRCode.new(@provision_url).as_svg(viewbox: true, svg_attributes: { class: 'w-80 h-80 my-4 mx-auto' }) %>
-
-
- <%= f.text_field :otp_attempt, required: true, placeholder: 'XXX-XXX', class: 'base-input text-center' %>
-
- <%= @error_message %>
-
-
-
- <%= f.button button_title(title: 'Save'), class: 'base-button' %>
-
- <% end %>
+ <%= render 'mfa_setup/form' %>
<% end %>
diff --git a/app/views/mfa_setup/show.html.erb b/app/views/mfa_setup/show.html.erb
new file mode 100644
index 00000000..3aeb85a3
--- /dev/null
+++ b/app/views/mfa_setup/show.html.erb
@@ -0,0 +1,4 @@
+
+
Setup 2FA
+ <%= render 'mfa_setup/form' %>
+
diff --git a/app/views/notifications_settings/index.html.erb b/app/views/notifications_settings/index.html.erb
index a106337a..bd880f00 100644
--- a/app/views/notifications_settings/index.html.erb
+++ b/app/views/notifications_settings/index.html.erb
@@ -5,7 +5,7 @@
<%= render 'email_stats' %>
<%= render 'bcc_form', config: @bcc_config %>
-
Signature Email Reminders
+ Sign Request Email Reminders
<%= render 'reminder_banner' %>
<%= render 'reminder_form', config: @reminder_config %>
diff --git a/app/views/scripts/_autosize_field.html.erb b/app/views/scripts/_autosize_field.html.erb
new file mode 100644
index 00000000..89720177
--- /dev/null
+++ b/app/views/scripts/_autosize_field.html.erb
@@ -0,0 +1,15 @@
+
diff --git a/app/views/shared/_button_title.html.erb b/app/views/shared/_button_title.html.erb
index fea5841b..a48485a9 100644
--- a/app/views/shared/_button_title.html.erb
+++ b/app/views/shared/_button_title.html.erb
@@ -2,13 +2,13 @@
<%= icon %>
<% if title %>
- <%= title %>
+ <%= title %>
<% end %>
<%= local_assigns[:icon_disabled] || svg_icon('loader', class: 'w-5 h-5 animate-spin') %>
- <%= disabled_with %>...
+ <%= disabled_with %>...
diff --git a/app/views/start_form/completed.html.erb b/app/views/start_form/completed.html.erb
index 90537c20..7259521c 100644
--- a/app/views/start_form/completed.html.erb
+++ b/app/views/start_form/completed.html.erb
@@ -24,9 +24,11 @@
<%= button_to button_title(title: 'Send copy to Email', disabled_with: 'Sending', icon: svg_icon('mail_forward', class: 'w-6 h-6')), send_submission_email_index_path, params: { submitter_slug: @submitter.slug }, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'base-button w-full' %>
<% end %>
-
- <%= button_to button_title(title: 'Resubmit', disabled_with: 'Resubmit', icon: svg_icon('reload', class: 'w-6 h-6')), start_form_path(@template.slug), params: { submitter: { email: params[:email] }, resubmit: true }, method: :put, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'white-button w-full' %>
-
+ <% if @template.submitters.to_a.size == 1 %>
+
+ <%= button_to button_title(title: 'Resubmit', disabled_with: 'Resubmit', icon: svg_icon('reload', class: 'w-6 h-6')), start_form_path(@template.slug), params: { submitter: { email: params[:email] }, resubmit: true }, method: :put, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'white-button w-full' %>
+
+ <% end %>
<%= render 'shared/attribution', link_path: '/start' %>
diff --git a/app/views/start_form/show.html.erb b/app/views/start_form/show.html.erb
index c6b4037b..8bdbf226 100644
--- a/app/views/start_form/show.html.erb
+++ b/app/views/start_form/show.html.erb
@@ -27,7 +27,8 @@
<%= form_for @submitter, url: start_form_path(@template.slug), data: { turbo_frame: :_top }, method: :put, html: { class: 'space-y-4', onsubmit: 'event.submitter.disabled = true' } do |f| %>
<%= f.label :email, class: 'label' %>
- <%= f.email_field :email, value: current_user&.email || params[:email], required: true, class: 'base-input', placeholder: 'Provide your email to start' %>
+ <%= f.email_field :email, value: current_user&.email || params[:email] || @submitter.email, required: true, class: 'base-input', placeholder: 'Provide your email to start' %>
+ <%= @error_message %>
<%= f.button button_title(title: 'Start', disabled_with: 'Starting'), class: 'base-button' %>
diff --git a/app/views/submissions/_detailed_form.html.erb b/app/views/submissions/_detailed_form.html.erb
index 2ae0f229..f1a429ce 100644
--- a/app/views/submissions/_detailed_form.html.erb
+++ b/app/views/submissions/_detailed_form.html.erb
@@ -9,10 +9,10 @@
- <% template.submitters.each do |item| %>
- <% if item["name"] == 'Me' %>
- <% next %>
- <% end %>
+ <% template.submitters.each_with_index do |item, index| %>
+ <% if item["name"] == 'Me' %>
+ <% next %>
+ <% end %>
<% if template.submitters.size > 1 %>
-
- <%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
- Add New
-
+ <% if params[:selfsign].blank? %>
+
+ <%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
+ Add New
+
+ <% end %>
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
diff --git a/app/views/submissions/_email_form.html.erb b/app/views/submissions/_email_form.html.erb
index 39023be1..0273dc40 100644
--- a/app/views/submissions/_email_form.html.erb
+++ b/app/views/submissions/_email_form.html.erb
@@ -18,7 +18,7 @@
- <% template.submitters.each do |item| %>
+ <% template.submitters.each_with_index do |item, index| %>
<% if item["name"] == 'Me' %>
<% next %>
<% end %>
@@ -28,7 +28,7 @@
-
+ <%= tag.input type: 'email', multiple: true, name: 'submission[1][submitters][][email]', autocomplete: 'off', class: 'input input-sm input-bordered w-full', placeholder: 'Email', required: index.zero?, value: params[:selfsign] && index.zero? ? current_user.email : '' %>
<% end %>
@@ -36,10 +36,12 @@
-
- <%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
- Add New
-
+ <% if params[:selfsign].blank? %>
+
+ <%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
+ Add New
+
+ <% end %>
<% end %>
diff --git a/app/views/submissions/_logo.html.erb b/app/views/submissions/_logo.html.erb
new file mode 100644
index 00000000..f6b67f5c
--- /dev/null
+++ b/app/views/submissions/_logo.html.erb
@@ -0,0 +1 @@
+<%= render 'shared/logo', width: 40, height: 40 %>
diff --git a/app/views/submissions/_phone_form.html.erb b/app/views/submissions/_phone_form.html.erb
index 5b176dee..e625a9f6 100644
--- a/app/views/submissions/_phone_form.html.erb
+++ b/app/views/submissions/_phone_form.html.erb
@@ -9,7 +9,7 @@
- <% template.submitters.each do |item| %>
+ <% template.submitters.each_with_index do |item, index| %>
<% if item["name"] == 'Me' %>
<% next %>
<% end %>
@@ -22,18 +22,18 @@
<% end %>
-
+ <%= tag.input type: 'tel', pattern: '^\+[0-9\s\-]+$', oninvalid: "this.value ? this.setCustomValidity('Use internatioanl format: +1xxx...') : ''", oninput: "this.setCustomValidity('')", name: 'submission[1][submitters][][phone]', autocomplete: 'off', class: 'input input-sm input-bordered w-full', placeholder: 'Phone', required: index.zero? %>
<% if template.submitters.size > 1 %>
-
+
<% end %>
<% if template.submitters.size == 1 %>
-
+
<% end %>
@@ -43,10 +43,12 @@
-
- <%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
- Add New
-
+ <% if params[:selfsign].blank? %>
+
+ <%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
+ Add New
+
+ <% end %>
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
diff --git a/app/views/submissions/_submitters_order.html.erb b/app/views/submissions/_submitters_order.html.erb
index a37de594..3cc6353a 100644
--- a/app/views/submissions/_submitters_order.html.erb
+++ b/app/views/submissions/_submitters_order.html.erb
@@ -2,8 +2,8 @@
diff --git a/app/views/templates/_template.html.erb b/app/views/templates/_template.html.erb
index 5982a1d8..9a149d05 100644
--- a/app/views/templates/_template.html.erb
+++ b/app/views/templates/_template.html.erb
@@ -6,7 +6,7 @@
<%= svg_icon('user', class: 'w-4 h-4') %>
- <%= template.author.full_name.presence || template.author.email %>
+ <%= template.author.full_name.presence || template.author.email.to_s.sub(/\+\w+@/, '@') %>
@@ -52,10 +52,10 @@
<% end %>
- <% if (!template.deleted_at? || !Docuseal.multitenant?) && can?(:destroy, template) %>
-
- <%= button_to template_path(template), data: { turbo_confirm: 'Are you sure?' }, params: { permanently: template.deleted_at? }.compact_blank, method: :delete, class: 'btn btn-xs hover:btn-outline bg-base-200 btn-circle', aria_label: 'Restore' do %>
- <%= svg_icon('trash', class: 'w-4 h-4 enabled') %>
+ <% if can?(:destroy, template) %>
+
+ <%= button_to template_path(template), data: { turbo_confirm: template.deleted_at? ? 'Template deletion is irreversible and will permanently remove all associated signed documents with it. Are you sure?' : 'Are you sure?' }, params: { permanently: template.deleted_at? }.compact_blank, method: :delete, class: 'btn btn-xs hover:btn-outline bg-base-200 btn-circle', aria_label: 'Restore' do %>
+ <%= svg_icon(template.deleted_at? ? 'trash' : 'archive', class: 'w-4 h-4 enabled') %>
<%= svg_icon('loader', class: 'w-4 h-4 animate-spin disabled') %>
<% end %>
diff --git a/app/views/templates/_title.html.erb b/app/views/templates/_title.html.erb
index 7eb71c27..f797ee6a 100644
--- a/app/views/templates/_title.html.erb
+++ b/app/views/templates/_title.html.erb
@@ -14,7 +14,7 @@
<% if can?(:update, template) %>
-
+
<%= svg_icon('pencil_share', class: 'w-5 h-5') %>
@@ -23,13 +23,16 @@
+ <% if !template.deleted_at? %>
+ <%= render 'shared/clipboard_copy', text: start_form_url(slug: @template.slug), class: 'btn btn-sm btn-neutral text-white', icon_class: 'w-6 h-6 text-white', copy_title: 'Copy Link', copied_title: 'Copied', copy_title_md: 'Link', copied_title_md: 'Copied' %>
+ <% end %>
<% if !template.deleted_at? && can?(:destroy, template) %>
- <%= button_to button_title(title: 'Remove', disabled_with: 'Removing', icon: svg_icon('trash', class: 'w-6 h-6')), template_path(template), class: 'btn btn-outline btn-sm', method: :delete, data: { turbo_confirm: 'Are you sure?' } %>
+ <%= button_to button_title(title: 'Archive', disabled_with: 'Archiving', title_class: 'hidden md:inline', icon: svg_icon('archive', class: 'w-6 h-6')), template_path(template), class: 'btn btn-outline btn-sm', method: :delete, data: { turbo_confirm: 'Are you sure?' } %>
<% end %>
<% if can?(:create, template) %>
<%= link_to new_template_path(base_template_id: template.id), class: 'btn btn-outline btn-sm', data: { turbo_frame: :modal } do %>
<%= svg_icon('copy', class: 'w-6 h-6') %>
-
Clone
+
Clone
<% end %>
<% end %>
<% if !template.deleted_at? && can?(:update, template) %>
diff --git a/app/views/templates/new.html.erb b/app/views/templates/new.html.erb
index 108f9a8b..ff63e881 100644
--- a/app/views/templates/new.html.erb
+++ b/app/views/templates/new.html.erb
@@ -4,7 +4,7 @@
<%= hidden_field_tag :base_template_id, @base_template.id %>
<% end %>
- <%= f.text_field :name, required: true, placeholder: 'Document Template Name', class: 'base-input' %>
+ <%= f.text_field :name, required: true, placeholder: 'Document Name', class: 'base-input' %>
diff --git a/app/views/templates/show.html.erb b/app/views/templates/show.html.erb
index 3fcabd80..65c8db22 100644
--- a/app/views/templates/show.html.erb
+++ b/app/views/templates/show.html.erb
@@ -1,23 +1,18 @@
<%= render 'title', template: @template %>
<% is_show_tabs = @pagy.pages > 1 || params[:status].present? %>
-<% if !@pagy.count.zero? || @template.submitters.to_a.size == 1 || params[:q].present? %>
+<% if !@pagy.count.zero? || params[:q].present? %>
Submissions
-
+
<% if params[:q].present? || params[:status].present? || @pagy.pages > 1 %>
<%= render 'shared/search_input' %>
<% end %>
- <%= link_to new_template_submissions_export_path(@template), class: 'order-3 md:order-1 btn btn-ghost text-base', data: { turbo_frame: 'modal' } do %>
+ <%= link_to new_template_submissions_export_path(@template), class: 'btn btn-ghost text-base', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('download', class: 'w-6 h-6 stroke-2') %>
Export
<% end %>
- <% if @template.submitters.to_a.size == 1 && !@template.deleted_at? %>
-
- <%= render 'shared/clipboard_copy', text: start_form_url(slug: @template.slug), class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: 'Copy Share Link', copied_title: 'Copied to Clipboard', copy_title_md: 'Copy', copied_title_md: 'Copied' %>
-
- <% end %>
<% if !@template.deleted_at? && can?(:create, Submission) %>
- <%= link_to new_template_submission_path(@template), class: 'order-1 btn btn-primary text-base', data: { turbo_frame: 'modal' } do %>
+ <%= link_to new_template_submission_path(@template), class: 'white-button !border', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %>
Add Recipients
<% end %>
@@ -85,12 +80,19 @@
<% if can?(:create, Submission) %>
<%= link_to new_template_submission_path(@template), class: 'base-button mt-6', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %>
- Add Recipients
+ Send to Recipients
<% end %>
<% end %>
- <%= link_to start_form_url(slug: @template.slug), class: 'white-button mt-6', target: '_blank', rel: 'noopener' do %>
- <%= svg_icon('writing', class: 'w-6 h-6') %>
- Sign it Yourself
+ <% if @template.submitters.size == 1 %>
+ <%= link_to start_form_url(slug: @template.slug), class: 'white-button mt-6', target: '_blank', rel: 'noopener' do %>
+ <%= svg_icon('writing', class: 'w-6 h-6') %>
+ Sign it Yourself
+ <% end %>
+ <% else %>
+ <%= link_to new_template_submission_path(@template, selfsign: true), class: 'white-button mt-6', data: { turbo_frame: 'modal' } do %>
+ <%= svg_icon('writing', class: 'w-6 h-6') %>
+ Sign it Yourself
+ <% end %>
<% end %>
<% end %>
diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb
index 4b74f2c4..27dee367 100644
--- a/app/views/users/index.html.erb
+++ b/app/views/users/index.html.erb
@@ -3,12 +3,14 @@
Team
- <% if can?(:create, User.new(account: current_account)) %>
- <%= link_to new_user_path, class: 'btn btn-primary btn-md gap-2', data: { turbo_frame: 'modal' } do %>
- <%= svg_icon('plus', class: 'w-6 h-6') %>
-
New User
+
+ <% if can?(:create, User.new(account: current_account)) %>
+ <%= link_to new_user_path, class: 'btn btn-primary btn-md gap-2', data: { turbo_frame: 'modal' } do %>
+ <%= svg_icon('plus', class: 'w-6 h-6') %>
+ New User
+ <% end %>
<% end %>
- <% end %>
+
diff --git a/app/views/webhook_settings/show.html.erb b/app/views/webhook_settings/show.html.erb
index 1e676cc6..ca88c87f 100644
--- a/app/views/webhook_settings/show.html.erb
+++ b/app/views/webhook_settings/show.html.erb
@@ -7,7 +7,7 @@
<%= form_for @encrypted_config, url: settings_webhooks_path, method: :post, html: { autocomplete: 'off' } do |f| %>
<%= f.label :value, 'Webhook URL', class: 'text-sm font-semibold' %>
- <%= f.text_field :value, required: true, class: 'input font-mono input-bordered w-full', placeholder: 'https://example.com/hook' %>
+ <%= f.url_field :value, class: 'input font-mono input-bordered w-full', placeholder: 'https://example.com/hook' %>
<%= f.button button_title(title: 'Save', disabled_with: 'Saving'), class: 'base-button w-full md:w-32' %>
<% end %>
diff --git a/config/application.rb b/config/application.rb
index 8c9db35e..df3b9ee5 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -25,6 +25,8 @@ module DocuSeal
config.i18n.available_locales = %i[en en-US en-GB es-ES fr-FR pt-PT de-DE]
config.i18n.fallbacks = [:en]
+ config.exceptions_app = ->(env) { ErrorsController.action(:show).call(env) }
+
config.action_view.frozen_string_literal = true
config.middleware.insert_before ActionDispatch::Static, Rack::Deflater
diff --git a/config/boot.rb b/config/boot.rb
index a10c1c60..481cacb7 100644
--- a/config/boot.rb
+++ b/config/boot.rb
@@ -24,5 +24,19 @@ if ENV['RAILS_ENV'] == 'production' && ENV['SECRET_KEY_BASE'].to_s.empty?
ENV['DATABASE_URL'] = ENV['DATABASE_URL'].to_s.empty? ? database_url : ENV.fetch('DATABASE_URL', nil)
end
+if ENV['DATABASE_URL'].to_s.split('@').last.to_s.split('/').first.to_s.include?('_')
+ require 'addressable'
+
+ url = Addressable::URI.parse(ENV.fetch('DATABASE_URL', ''))
+
+ ENV['DATABASE_HOST'] = url.host
+ ENV['DATABASE_PORT'] = (url.port || 5432).to_s
+ ENV['DATABASE_USER'] = url.user
+ ENV['DATABASE_PASSWORD'] = url.password
+ ENV['DATABASE_NAME'] = url.path.to_s.delete_prefix('/')
+
+ ENV.delete('DATABASE_URL')
+end
+
require 'bundler/setup' # Set up gems listed in the Gemfile.
require 'bootsnap/setup' # Speed up boot time by caching expensive operations.
diff --git a/config/database.yml b/config/database.yml
index 6e9b1f24..a4c503ab 100644
--- a/config/database.yml
+++ b/config/database.yml
@@ -12,7 +12,14 @@ test:
database: docuseal_test
production:
-<% if ENV['DATABASE_URL'].to_s.empty? %>
+<% if !ENV['DATABASE_HOST'].to_s.empty? %>
+ <<: *default
+ host: <%= ENV['DATABASE_HOST'] %>
+ port: <%= ENV['DATABASE_PORT'] %>
+ username: <%= ENV['DATABASE_USER'] %>
+ password: <%= ENV['DATABASE_PASSWORD'] %>
+ database: <%= ENV['DATABASE_NAME'] %>
+<% elsif ENV['DATABASE_URL'].to_s.empty? %>
adapter: sqlite3
database: <%= ENV['WORKDIR'] || '.' %>/db.sqlite3
<% elsif ENV['DATABASE_URL'].match?(/\Apostgres/) %>
diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb
index d5ffebfb..00d04479 100644
--- a/config/initializers/devise.rb
+++ b/config/initializers/devise.rb
@@ -273,11 +273,6 @@ Devise.setup do |config|
# The default HTTP method used to sign out a resource. Default is :delete.
config.sign_out_via = :delete
- # ==> OmniAuth
- # Add a new OmniAuth provider. Check the wiki for more information on setting
- # up on your models and hooks.
- config.omniauth :google_oauth2, ENV.fetch('GOOGLE_CLIENT_ID', nil), ENV.fetch('GOOGLE_CLIENT_SECRET', nil), {}
-
# ==> Warden configuration
# If you want to use other strategies, that are not supported by Devise, or
# change the failure app, you can configure them inside the config.warden block.
@@ -315,4 +310,6 @@ Devise.setup do |config|
# When set to false, does not sign a user in automatically after their password is
# changed. Defaults to true, so a user is signed in automatically after changing a password.
# config.sign_in_after_change_password = true
+
+ ActiveSupport.run_load_hooks(:devise_config, config)
end
diff --git a/config/initializers/hexapdf.rb b/config/initializers/hexapdf.rb
new file mode 100644
index 00000000..a734508a
--- /dev/null
+++ b/config/initializers/hexapdf.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+# fix NoMethodError: undefined method `field_value' for #
+ credentials: <%= ENV['GCS_CREDENTIALS'] || '{}' %>
project: <%= ENV['GCS_PROJECT'] %>
bucket: <%= ENV['GCS_BUCKET'] %>
public: <%= ENV['ACTIVE_STORAGE_PUBLIC'] == 'true' %>
diff --git a/db/migrate/20231122203341_add_preferences_to_submitters.rb b/db/migrate/20231122203341_add_preferences_to_submitters.rb
new file mode 100644
index 00000000..45b9bc87
--- /dev/null
+++ b/db/migrate/20231122203341_add_preferences_to_submitters.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class AddPreferencesToSubmitters < ActiveRecord::Migration[7.0]
+ class MigrationSubmitter < ApplicationRecord
+ self.table_name = 'submitters'
+ end
+
+ def change
+ add_column :submitters, :preferences, :text
+
+ MigrationSubmitter.where(preferences: nil).update_all(preferences: '{}')
+
+ change_column_null :submitters, :preferences, false
+ end
+end
diff --git a/db/migrate/20231122203347_add_preferences_to_submissions.rb b/db/migrate/20231122203347_add_preferences_to_submissions.rb
new file mode 100644
index 00000000..5621845b
--- /dev/null
+++ b/db/migrate/20231122203347_add_preferences_to_submissions.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class AddPreferencesToSubmissions < ActiveRecord::Migration[7.0]
+ class MigrationSubmission < ApplicationRecord
+ self.table_name = 'submissions'
+ end
+
+ def change
+ add_column :submissions, :preferences, :text
+
+ MigrationSubmission.where(preferences: nil).update_all(preferences: '{}')
+
+ change_column_null :submissions, :preferences, false
+ end
+end
diff --git a/db/migrate/20231122212612_create_email_messages.rb b/db/migrate/20231122212612_create_email_messages.rb
new file mode 100644
index 00000000..e3efb23f
--- /dev/null
+++ b/db/migrate/20231122212612_create_email_messages.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class CreateEmailMessages < ActiveRecord::Migration[7.0]
+ def change
+ create_table :email_messages do |t|
+ t.string :uuid, null: false, index: true
+ t.references :author, null: false, foreign_key: { to_table: :users }, index: false
+ t.references :account, null: false, foreign_key: true, index: true
+ t.text :subject, null: false
+ t.text :body, null: false
+ t.string :sha1, null: false, index: true
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 2af25b24..4a548876 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.0].define(version: 2023_11_30_132129) do
+ActiveRecord::Schema[7.0].define(version: 2023_11_22_212612) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -81,6 +81,20 @@ ActiveRecord::Schema[7.0].define(version: 2023_11_30_132129) do
t.index ["submitter_id"], name: "index_document_generation_events_on_submitter_id"
end
+ create_table "email_messages", force: :cascade do |t|
+ t.string "uuid", null: false
+ t.bigint "author_id", null: false
+ t.bigint "account_id", null: false
+ t.text "subject", null: false
+ t.text "body", null: false
+ t.string "sha1", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id"], name: "index_email_messages_on_account_id"
+ t.index ["sha1"], name: "index_email_messages_on_sha1"
+ t.index ["uuid"], name: "index_email_messages_on_uuid"
+ end
+
create_table "encrypted_configs", force: :cascade do |t|
t.bigint "account_id", null: false
t.string "key", null: false
@@ -125,6 +139,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_11_30_132129) do
t.text "source", null: false
t.string "submitters_order", null: false
t.string "slug", null: false
+ t.text "preferences", null: false
t.index ["created_by_user_id"], name: "index_submissions_on_created_by_user_id"
t.index ["slug"], name: "index_submissions_on_slug", unique: true
t.index ["template_id"], name: "index_submissions_on_template_id"
@@ -146,6 +161,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_11_30_132129) do
t.string "name"
t.string "phone"
t.string "application_key"
+ t.text "preferences", null: false
t.index ["email"], name: "index_submitters_on_email"
t.index ["slug"], name: "index_submitters_on_slug", unique: true
t.index ["submission_id"], name: "index_submitters_on_submission_id"
@@ -230,6 +246,8 @@ ActiveRecord::Schema[7.0].define(version: 2023_11_30_132129) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "document_generation_events", "submitters"
+ add_foreign_key "email_messages", "accounts"
+ add_foreign_key "email_messages", "users", column: "author_id"
add_foreign_key "encrypted_configs", "accounts"
add_foreign_key "encrypted_user_configs", "users"
add_foreign_key "submission_events", "submissions"
diff --git a/lib/accounts.rb b/lib/accounts.rb
index fcab3cad..0f6cac94 100644
--- a/lib/accounts.rb
+++ b/lib/accounts.rb
@@ -71,6 +71,21 @@ module Accounts
end
end
+ def load_timeserver_url(account)
+ if Docuseal.multitenant?
+ Docuseal::TIMESERVER_URL
+ else
+ url = EncryptedConfig.find_by(account:, key: EncryptedConfig::TIMESTAMP_SERVER_URL_KEY)&.value
+
+ unless Docuseal.multitenant?
+ url ||=
+ Account.order(:id).first.encrypted_configs.find_by(key: EncryptedConfig::TIMESTAMP_SERVER_URL_KEY)&.value
+ end
+
+ url
+ end.presence
+ end
+
def can_send_emails?(_account)
return true if Docuseal.multitenant?
return true if ENV['SMTP_ADDRESS'].present?
diff --git a/lib/docuseal.rb b/lib/docuseal.rb
index fa7b8773..0fecc306 100644
--- a/lib/docuseal.rb
+++ b/lib/docuseal.rb
@@ -28,6 +28,7 @@ module Docuseal
end
CERTS = JSON.parse(ENV.fetch('CERTS', '{}'))
+ TIMESERVER_URL = ENV.fetch('TIMESERVER_URL', nil)
DEFAULT_URL_OPTIONS = {
host: HOST,
diff --git a/lib/email_messages.rb b/lib/email_messages.rb
new file mode 100644
index 00000000..49e9de12
--- /dev/null
+++ b/lib/email_messages.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module EmailMessages
+ module_function
+
+ def find_or_create_for_account_user(account, user, subject, body)
+ subject = SubmitterMailer::DEFAULT_INVITATION_SUBJECT if subject.blank?
+
+ sha1 = Digest::SHA1.hexdigest({ subject:, body: }.to_json)
+
+ message = account.email_messages.find_by(sha1:)
+
+ message ||= account.email_messages.create!(author: user, subject:, body:)
+
+ message
+ end
+end
diff --git a/lib/pdf_processor.rb b/lib/pdf_processor.rb
new file mode 100644
index 00000000..79716929
--- /dev/null
+++ b/lib/pdf_processor.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+class PdfProcessor < HexaPDF::Content::Processor
+ attr_reader :result, :contents
+ attr_accessor :handler
+
+ def initialize(page)
+ super
+
+ @contents = ''.b
+ @result = []
+
+ @serializer = HexaPDF::Serializer.new
+ end
+
+ def process(operator, operands = [])
+ super
+
+ contents << @operators[operator].serialize(
+ @serializer,
+ *handler.call(self, operator, operands)
+ )
+ end
+
+ def self.call(data, pdf_handler, result_handler, acc = {})
+ doc = HexaPDF::Document.new(io: StringIO.new(data))
+
+ doc.pages.each do |page|
+ processor = PdfProcessor.new(page)
+ processor.handler = pdf_handler
+
+ page.process_contents(processor)
+
+ page.contents = processor.contents
+
+ processor.result.each do |item|
+ result_handler.call(item, page, acc)
+ end
+ end
+
+ new_io = StringIO.new
+
+ doc.write(new_io)
+
+ [new_io.tap(&:rewind).read, acc]
+ end
+end
diff --git a/lib/replace_email_variables.rb b/lib/replace_email_variables.rb
index a6035a5a..ecf568ee 100644
--- a/lib/replace_email_variables.rb
+++ b/lib/replace_email_variables.rb
@@ -1,24 +1,31 @@
# frozen_string_literal: true
module ReplaceEmailVariables
- TEMAPLTE_NAME = '{{template.name}}'
+ TEMPLATE_NAME = '{{template.name}}'
+ TEMPLATE_ID = '{{template.id}}'
SUBMITTER_LINK = '{{submitter.link}}'
ACCOUNT_NAME = '{{account.name}}'
SUBMITTER_EMAIL = '{{submitter.email}}'
SUBMITTER_NAME = '{{submitter.name}}'
+ SUBMITTER_ID = '{{submitter.id}}'
SUBMISSION_LINK = '{{submission.link}}'
+ SUBMISSION_ID = '{{submission.id}}'
SUBMISSION_SUBMITTERS = '{{submission.submitters}}'
DOCUMENTS_LINKS = '{{documents.links}}'
DOCUMENTS_LINK = '{{documents.link}}'
module_function
+ # rubocop:disable Metrics
def call(text, submitter:, tracking_event_type: 'click_email')
submitter_link = build_submitter_link(submitter, tracking_event_type)
submission_link = build_submission_link(submitter.submission) if submitter.submission
- text = text.gsub(TEMAPLTE_NAME, submitter.template.name) if submitter.template
+ text = text.gsub(TEMPLATE_NAME, submitter.template.name) if submitter.template
+ text = text.gsub(TEMPLATE_ID, submitter.template.id.to_s) if submitter.template
+ text = text.gsub(SUBMITTER_ID, submitter.id.to_s)
+ text = text.gsub(SUBMISSION_ID, submitter.submission.id.to_s) if submitter.submission
text = text.gsub(SUBMITTER_EMAIL, submitter.email) if submitter.email
text = text.gsub(SUBMITTER_NAME, submitter.name || submitter.email || submitter.phone)
text = text.gsub(SUBMITTER_LINK, submitter_link)
@@ -33,6 +40,7 @@ module ReplaceEmailVariables
text
end
+ # rubocop:enable Metrics
def build_documents_links_text(submitter)
Rails.application.routes.url_helpers.submissions_preview_url(
diff --git a/lib/submission_events.rb b/lib/submission_events.rb
index 9649f91d..c873522f 100644
--- a/lib/submission_events.rb
+++ b/lib/submission_events.rb
@@ -7,6 +7,7 @@ module SubmissionEvents
send_email: 'Email sent',
send_reminder_email: 'Reminder email sent',
send_sms: 'SMS sent',
+ send_2fa_sms: 'Verification SMS sent',
open_email: 'Email opened',
click_email: 'Email link clicked',
click_sms: 'SMS link clicked',
diff --git a/lib/submissions.rb b/lib/submissions.rb
index 70aa41d1..e4630fa7 100644
--- a/lib/submissions.rb
+++ b/lib/submissions.rb
@@ -27,11 +27,17 @@ module Submissions
submission.save!
end
- def create_from_emails(template:, user:, emails:, source:, mark_as_sent: false)
+ def create_from_emails(template:, user:, emails:, source:, mark_as_sent: false, params: {})
+ preferences = Submitters.normalize_preferences(template.account, user, params)
+
parse_emails(emails).uniq.map do |email|
- submission = template.submissions.new(created_by_user: user, source:, template_submitters: template.submitters)
+ submission = template.submissions.new(created_by_user: user,
+ source:,
+ template_submitters: template.submitters)
+
submission.submitters.new(email: normalize_email(email),
uuid: template.submitters.first['uuid'],
+ preferences:,
sent_at: mark_as_sent ? Time.current : nil)
submission.tap(&:save!)
@@ -45,13 +51,13 @@ module Submissions
end
def create_from_submitters(template:, user:, submissions_attrs:, source:, mark_as_sent: false,
- submitters_order: DEFAULT_SUBMITTERS_ORDER)
+ submitters_order: DEFAULT_SUBMITTERS_ORDER, params: {})
Submissions::CreateFromSubmitters.call(
- template:, user:, submissions_attrs:, source:, mark_as_sent:, submitters_order:
+ template:, user:, submissions_attrs:, source:, mark_as_sent:, submitters_order:, params:
)
end
- def send_signature_requests(submissions, params)
+ def send_signature_requests(submissions)
submissions.each do |submission|
submitters = submission.submitters.reject(&:completed_at?)
@@ -59,9 +65,9 @@ module Submissions
first_submitter =
submission.template_submitters.filter_map { |s| submitters.find { |e| e.uuid == s['uuid'] } }.first
- Submitters.send_signature_requests([first_submitter], params)
+ Submitters.send_signature_requests([first_submitter])
else
- Submitters.send_signature_requests(submitters, params)
+ Submitters.send_signature_requests(submitters)
end
end
end
diff --git a/lib/submissions/create_from_submitters.rb b/lib/submissions/create_from_submitters.rb
index 1edbd631..6863f35d 100644
--- a/lib/submissions/create_from_submitters.rb
+++ b/lib/submissions/create_from_submitters.rb
@@ -4,10 +4,19 @@ module Submissions
module CreateFromSubmitters
module_function
- def call(template:, user:, submissions_attrs:, source:, submitters_order:, mark_as_sent: false)
+ def call(template:, user:, submissions_attrs:, source:, submitters_order:, mark_as_sent: false, params: {})
+ preferences = Submitters.normalize_preferences(template.account, user, params)
+
Array.wrap(submissions_attrs).map do |attrs|
+ submission_preferences = Submitters.normalize_preferences(template.account, user, attrs)
+ submission_preferences = preferences.merge(submission_preferences)
+
+ set_submission_preferences = submission_preferences.slice('send_email')
+ set_submission_preferences['send_email'] = true if params['send_completed_email']
+
submission = template.submissions.new(created_by_user: user, source:,
- template_submitters: template.submitters, submitters_order:)
+ preferences: set_submission_preferences,
+ template_submitters: [], submitters_order:)
maybe_set_template_fields(submission, attrs[:submitters])
@@ -15,21 +24,26 @@ module Submissions
uuid = find_submitter_uuid(template, submitter_attrs, index)
next if uuid.blank?
+ next if submitter_attrs.slice('email', 'phone', 'name').compact_blank.blank?
+
+ submission.template_submitters << template.submitters.find { |e| e['uuid'] == uuid }
is_order_sent = submitters_order == 'random' || index.zero?
- build_submitter(submission:, attrs: submitter_attrs, uuid:, is_order_sent:, mark_as_sent:)
+ build_submitter(submission:, attrs: submitter_attrs, uuid:,
+ is_order_sent:, mark_as_sent:, user:,
+ preferences: preferences.merge(submission_preferences))
end
submission.tap(&:save!)
end
end
- def maybe_set_template_fields(submission, submitters_attrs)
- template_fields = submission.template.fields.deep_dup
+ def maybe_set_template_fields(submission, submitters_attrs, submitter_uuid: nil)
+ template_fields = (submission.template_fields || submission.template.fields).deep_dup
submitters_attrs.each_with_index do |submitter_attrs, index|
- submitter_uuid = find_submitter_uuid(submission.template, submitter_attrs, index)
+ submitter_uuid ||= find_submitter_uuid(submission.template, submitter_attrs, index)
process_readonly_fields_param(submitter_attrs[:readonly_fields], template_fields, submitter_uuid)
@@ -85,8 +99,9 @@ module Submissions
template.submitters[index]&.dig('uuid')
end
- def build_submitter(submission:, attrs:, uuid:, is_order_sent:, mark_as_sent:)
+ def build_submitter(submission:, attrs:, uuid:, is_order_sent:, mark_as_sent:, user:, preferences:)
email = Submissions.normalize_email(attrs[:email])
+ submitter_preferences = Submitters.normalize_preferences(submission.account, user, attrs)
submission.submitters.new(
email:,
@@ -96,6 +111,7 @@ module Submissions
completed_at: attrs[:completed] ? Time.current : nil,
sent_at: mark_as_sent && email.present? && is_order_sent ? Time.current : nil,
values: attrs[:values] || {},
+ preferences: preferences.merge(submitter_preferences),
uuid:
)
end
diff --git a/lib/submissions/generate_audit_trail.rb b/lib/submissions/generate_audit_trail.rb
index 1cdd8499..ca54de58 100644
--- a/lib/submissions/generate_audit_trail.rb
+++ b/lib/submissions/generate_audit_trail.rb
@@ -19,19 +19,22 @@ module Submissions
INFO_CREATOR = "#{Docuseal.product_name} (#{Docuseal::PRODUCT_URL})".freeze
SIGN_REASON = 'Signed with DocuSeal.co'
- VERIFIED_TEXT = if Docuseal.multitenant?
- 'Verified by DocuSeal'
- else
- 'Verified'
- end
+ VERIFIED_TEXT = 'Verified'
UNVERIFIED_TEXT = 'Unverified'
+ CURRENCY_SYMBOLS = {
+ 'USD' => '$',
+ 'EUR' => '€',
+ 'GBP' => '£'
+ }.freeze
+
module_function
# rubocop:disable Metrics
def call(submission)
account = submission.template.account
pkcs = Accounts.load_signing_pkcs(account)
+ tsa_url = Accounts.load_timeserver_url(account)
verify_url = Rails.application.routes.url_helpers.settings_esign_url(**Docuseal.default_url_options)
composer = HexaPDF::Composer.new(skip_page_creation: true)
@@ -63,7 +66,7 @@ module Submissions
composer.new_page
composer.column(columns: 1) do |column|
- add_logo(column)
+ add_logo(column, submission)
column.text('Audit Log',
font_size: 16,
@@ -73,9 +76,11 @@ module Submissions
composer.column(columns: 1) do |column|
column.text("Envelope ID: #{submission.id}", font_size: 12, padding: [20, 0, 10, 0], position: :float)
- column.formatted_text([
- { link: verify_url, text: 'Verify', style: :link }
- ], font_size: 9, padding: [22, 0, 10, 0], position: :float, align: :right)
+
+ unless submission.source_embed?
+ column.formatted_text([{ link: verify_url, text: 'Verify', style: :link }],
+ font_size: 9, padding: [22, 0, 10, 0], position: :float, align: :right)
+ end
end
composer.draw_box(divider)
@@ -151,11 +156,11 @@ module Submissions
[
composer.document.layout.formatted_text_box(
[
- submitter.email && {
- text: "Email verification: #{click_email_event ? VERIFIED_TEXT : UNVERIFIED_TEXT}\n"
+ submitter.email && click_email_event && {
+ text: "Email verification: #{VERIFIED_TEXT}\n"
},
- submitter.phone && {
- text: "Phone verification: #{is_phone_verified ? VERIFIED_TEXT : UNVERIFIED_TEXT}\n"
+ submitter.phone && is_phone_verified && {
+ text: "Phone verification: #{VERIFIED_TEXT}\n"
},
completed_event.data['ip'] && { text: "IP: #{completed_event.data['ip']}\n" },
completed_event.data['sid'] && { text: "Session ID: #{completed_event.data['sid']}\n" },
@@ -201,7 +206,15 @@ module Submissions
composer.image(io, width:, height:, margin: [0, 0, 10, 0])
composer.formatted_text_box([{ text: '' }])
- elsif field['type'] == 'file'
+ elsif field['type'].in?(%w[file payment])
+ if field['type'] == 'payment'
+ unit = CURRENCY_SYMBOLS[field['preferences']['currency']]
+
+ price = ApplicationController.helpers.number_to_currency(field['preferences']['price'], unit:)
+
+ composer.formatted_text_box([{ text: "Paid #{price}\n" }], padding: [0, 0, 10, 0])
+ end
+
composer.formatted_text_box(
Array.wrap(value).map do |uuid|
attachment = submitter.attachments.find { |a| a.uuid == uuid }
@@ -215,7 +228,10 @@ module Submissions
elsif field['type'] == 'checkbox'
composer.formatted_text_box([{ text: value.to_s.titleize }], padding: [0, 0, 10, 0])
else
- value = I18n.l(Date.parse(value), format: :long, locale: account.locale) if field['type'] == 'date'
+ if field['type'] == 'date'
+ value = TimeUtils.format_date_string(value, field.dig('preferences', 'format'), account.locale)
+ end
+
value = value.join(', ') if value.is_a?(Array)
composer.formatted_text_box([{ text: value.to_s.presence || 'n/a' }], padding: [0, 0, 10, 0])
@@ -254,10 +270,16 @@ module Submissions
composer.document.trailer.info[:Creator] = INFO_CREATOR
- composer.document.sign(io, reason: SIGN_REASON,
- certificate: pkcs.certificate,
- key: pkcs.key,
- certificate_chain: pkcs.ca_certs || [])
+ sign_params = {
+ reason: SIGN_REASON,
+ certificate: pkcs.certificate,
+ key: pkcs.key,
+ certificate_chain: pkcs.ca_certs || []
+ }
+
+ sign_params[:timestamp_handler] = Submissions::TimestampHandler.new(tsa_url:) if tsa_url
+
+ composer.document.sign(io, **sign_params)
ActiveStorage::Attachment.create!(
blob: ActiveStorage::Blob.create_and_upload!(
@@ -268,7 +290,7 @@ module Submissions
)
end
- def add_logo(column)
+ def add_logo(column, _submission = nil)
column.image(PdfIcons.logo_io, width: 40, height: 40, position: :float)
column.formatted_text([{ text: 'DocuSeal',
diff --git a/lib/submissions/generate_result_attachments.rb b/lib/submissions/generate_result_attachments.rb
index caa7c524..1ef366bd 100644
--- a/lib/submissions/generate_result_attachments.rb
+++ b/lib/submissions/generate_result_attachments.rb
@@ -29,6 +29,7 @@ module Submissions
account = submitter.submission.template.account
pkcs = Accounts.load_signing_pkcs(account)
+ tsa_url = Accounts.load_timeserver_url(account)
pdfs_index = build_pdfs_index(submitter)
@@ -39,11 +40,18 @@ module Submissions
field.fetch('areas', []).each do |area|
pdf = pdfs_index[area['attachment_uuid']]
+ next if pdf.nil?
+
page = pdf.pages[area['page']]
+
+ next if page.nil?
+
page.rotate(0, flatten: true) if page[:Rotate] != 0
page[:Annots] ||= []
- page[:Annots] = page[:Annots].reject { |e| e[:A] && e[:A][:URI].to_s.starts_with?('file:///docuseal_field') }
+ page[:Annots] = page[:Annots].reject do |e|
+ e.present? && e[:A] && e[:A][:URI].to_s.starts_with?('file:///docuseal_field')
+ end
width = page.box.width
height = page.box.height
@@ -69,7 +77,7 @@ module Submissions
scale = [(area['w'] * width) / image.width,
(area['h'] * height) / image.height].min
- io = StringIO.new(image.resize([scale * 4, 1].min).write_to_buffer('.png'))
+ io = StringIO.new(image.resize([scale * 4, 1].select(&:positive?).min).write_to_buffer('.png'))
canvas.image(
io,
@@ -99,7 +107,7 @@ module Submissions
width: image.width * scale,
height: image.height * scale
)
- when 'file'
+ when 'file', 'payment'
items = Array.wrap(value).each_with_object([]) do |uuid, acc|
attachment = submitter.attachments.find { |a| a.uuid == uuid }
@@ -206,7 +214,9 @@ module Submissions
layouter_my_text.fit([text_my_text], w, height_diff_my_text.positive? ? box_height_my_text : h)
.draw(canvas, x + TEXT_LEFT_MARGIN, y - height_diff_my_text + 17)
else
- value = I18n.l(Date.parse(value), format: :default, locale: account.locale) if field['type'] == 'date'|| field['type'] == 'my_date'
+ if field['type'].in?(%w[date my_date])
+ value = TimeUtils.format_date_string(value, field.dig('preferences', 'format'), account.locale)
+ end
text = HexaPDF::Layout::TextFragment.create(Array.wrap(value).join(', '), font: pdf.fonts.add(FONT_NAME),
font_size:)
@@ -240,7 +250,9 @@ module Submissions
submitter.submission.template_schema.map do |item|
pdf = pdfs_index[item['attachment_uuid']]
- attachment = save_signed_pdf(pdf:, submitter:, pkcs:, uuid: item['attachment_uuid'], name: item['name'])
+ attachment = save_signed_pdf(pdf:, submitter:, pkcs:, tsa_url:,
+ uuid: item['attachment_uuid'],
+ name: item['name'])
image_pdfs << pdf if original_documents.find { |a| a.uuid == item['attachment_uuid'] }.image?
@@ -267,15 +279,21 @@ module Submissions
end
# rubocop:enable Metrics
- def save_signed_pdf(pdf:, submitter:, pkcs:, uuid:, name:)
+ def save_signed_pdf(pdf:, submitter:, pkcs:, tsa_url:, uuid:, name:)
io = StringIO.new
pdf.trailer.info[:Creator] = info_creator
- pdf.sign(io, reason: sign_reason(submitter.email),
- certificate: pkcs.certificate,
- key: pkcs.key,
- certificate_chain: pkcs.ca_certs || [])
+ sign_params = {
+ reason: sign_reason(submitter.email),
+ certificate: pkcs.certificate,
+ key: pkcs.key,
+ certificate_chain: pkcs.ca_certs || []
+ }
+
+ sign_params[:timestamp_handler] = Submissions::TimestampHandler.new(tsa_url:) if tsa_url
+
+ pdf.sign(io, **sign_params)
ActiveStorage::Attachment.create!(
uuid:,
@@ -302,7 +320,7 @@ module Submissions
Submissions::EnsureResultGenerated.call(latest_submitter) if latest_submitter
documents = latest_submitter&.documents&.preload(:blob).to_a.presence
- documents ||= submitter.submission.template.documents.preload(:blob)
+ documents ||= submitter.submission.template_schema_documents.preload(:blob)
documents.to_h do |attachment|
pdf =
diff --git a/lib/submissions/normalize_param_utils.rb b/lib/submissions/normalize_param_utils.rb
new file mode 100644
index 00000000..8d4a4172
--- /dev/null
+++ b/lib/submissions/normalize_param_utils.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+module Submissions
+ module NormalizeParamUtils
+ module_function
+
+ def normalize_submissions_params!(submissions_params, template)
+ attachments = []
+
+ Array.wrap(submissions_params).each do |submission|
+ submission[:submitters].each_with_index do |submitter, index|
+ _, new_attachments = normalize_submitter_params!(submitter, template, index)
+
+ attachments.push(*new_attachments)
+ end
+ end
+
+ [submissions_params, attachments]
+ end
+
+ def normalize_submitter_params!(submitter_params, template, index = nil, for_submitter: nil)
+ default_values = submitter_params[:values] || {}
+
+ submitter_params[:fields]&.each do |f|
+ default_values[f[:name]] = f[:default_value] if f[:default_value].present?
+ end
+
+ return submitter_params if default_values.blank?
+
+ values, new_attachments =
+ Submitters::NormalizeValues.call(template,
+ default_values,
+ submitter_name: submitter_params[:role] ||
+ template.submitters.dig(index, 'name'),
+ for_submitter:,
+ throw_errors: true)
+
+ submitter_params[:values] = values
+
+ [submitter_params, new_attachments]
+ end
+
+ def save_default_value_attachments!(attachments, submitters)
+ return if attachments.blank?
+
+ attachments_index = attachments.index_by(&:uuid)
+
+ submitters.each do |submitter|
+ submitter.values.to_a.each do |_, value|
+ attachment = attachments_index[value]
+
+ next unless attachment
+
+ attachment.record = submitter
+
+ attachment.save!
+ end
+ end
+ end
+ end
+end
diff --git a/lib/submissions/timestamp_handler.rb b/lib/submissions/timestamp_handler.rb
new file mode 100644
index 00000000..96f09f41
--- /dev/null
+++ b/lib/submissions/timestamp_handler.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+module Submissions
+ class TimestampHandler
+ HASH_ALGORITHM = 'SHA512'
+
+ TimestampError = Class.new(StandardError)
+
+ attr_reader :tsa_url
+
+ def initialize(tsa_url:)
+ @tsa_url = tsa_url
+ end
+
+ def finalize_objects(_signature_field, signature)
+ signature.document.version = '2.0'
+
+ signature[:Type] = :DocTimeStamp
+ signature[:Filter] = :'Adobe.PPKLite'
+ signature[:SubFilter] = :'ETSI.RFC3161'
+ end
+
+ def sign(io, byte_range)
+ digest = OpenSSL::Digest.new(HASH_ALGORITHM)
+
+ io.pos = byte_range[0]
+ digest << io.read(byte_range[1])
+ io.pos = byte_range[2]
+ digest << io.read(byte_range[3])
+
+ uri = Addressable::URI.parse(tsa_url)
+
+ conn = Faraday.new(uri.origin) do |c|
+ c.basic_auth(uri.user, uri.password) if uri.password.present?
+ end
+
+ response = conn.post(uri.path, build_payload(digest.digest),
+ 'content-type' => 'application/timestamp-query')
+
+ raise TimestampError if response.status != 200 || response.body.blank?
+
+ OpenSSL::Timestamp::Response.new(response.body).token.to_der
+ end
+
+ def build_payload(digest)
+ req = OpenSSL::Timestamp::Request.new
+ req.algorithm = HASH_ALGORITHM
+ req.message_imprint = digest
+
+ req.to_der
+ end
+ end
+end
diff --git a/lib/submitters.rb b/lib/submitters.rb
index 3c20854a..1e8e88c7 100644
--- a/lib/submitters.rb
+++ b/lib/submitters.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
module Submitters
+ TRUE_VALUES = ['1', 'true', true].freeze
+
module_function
def search(submitters, keyword)
@@ -43,21 +45,30 @@ module Submitters
)
end
- def send_signature_requests(submitters, params)
- return if params[:send_email] != true && params[:send_email] != '1'
+ def normalize_preferences(account, user, params)
+ preferences = {}
- submitters.each do |submitter|
- next if submitter.email.blank?
+ message_params = params['message'].presence || params.slice('subject', 'body').presence
- enqueue_invitation_email(submitter, params)
+ if message_params.present?
+ email_message = EmailMessages.find_or_create_for_account_user(account, user,
+ message_params['subject'],
+ message_params['body'])
end
+
+ preferences['email_message_uuid'] = email_message.uuid if email_message
+ preferences['send_email'] = params['send_email'].in?(TRUE_VALUES) if params.key?('send_email')
+ preferences['send_sms'] = params['send_sms'].in?(TRUE_VALUES) if params.key?('send_sms')
+
+ preferences
end
- def enqueue_invitation_email(submitter, params)
- subject, body = params.values_at(:subject, :body) if params[:is_custom_message] == '1'
+ def send_signature_requests(submitters)
+ submitters.each do |submitter|
+ next if submitter.email.blank?
+ next if submitter.preferences['send_email'] == false
- SendSubmitterInvitationEmailJob.perform_later('submitter_id' => submitter.id,
- 'body' => body,
- 'subject' => subject)
+ SendSubmitterInvitationEmailJob.perform_later('submitter_id' => submitter.id)
+ end
end
end
diff --git a/lib/submitters/form_configs.rb b/lib/submitters/form_configs.rb
new file mode 100644
index 00000000..d5f69e7b
--- /dev/null
+++ b/lib/submitters/form_configs.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Submitters
+ module FormConfigs
+ module_function
+
+ def call(submitter)
+ configs = submitter.submission.template.account.account_configs
+ .where(key: [AccountConfig::FORM_COMPLETED_BUTTON_KEY,
+ AccountConfig::ALLOW_TYPED_SIGNATURE])
+
+ completed_button = configs.find { |e| e.key == AccountConfig::FORM_COMPLETED_BUTTON_KEY }&.value || {}
+
+ with_typed_signature = configs.find { |e| e.key == AccountConfig::ALLOW_TYPED_SIGNATURE }&.value != false
+
+ { completed_button:, with_typed_signature: }
+ end
+ end
+end
diff --git a/lib/submitters/normalize_values.rb b/lib/submitters/normalize_values.rb
index f7025037..4a4a916d 100644
--- a/lib/submitters/normalize_values.rb
+++ b/lib/submitters/normalize_values.rb
@@ -9,25 +9,25 @@ module Submitters
module_function
- def call(template, values, submitter_name)
- submitter =
- template.submitters.find { |e| e['name'] == submitter_name } ||
- raise(UnknownSubmitterName, "Unknown submitter: #{submitter_name}")
-
- fields = template.fields.select { |e| e['submitter_uuid'] == submitter['uuid'] }
+ def call(template, values, submitter_name: nil, for_submitter: nil, throw_errors: false)
+ fields = fetch_fields(template, submitter_name:, for_submitter:)
fields_uuid_index = fields.index_by { |e| e['uuid'] }
fields_name_index = build_fields_index(fields)
attachments = []
- normalized_values = values.to_h.to_h do |key, value|
+ normalized_values = values.to_h.filter_map do |key, value|
if fields_uuid_index[key].blank?
- key = fields_name_index[key]&.dig('uuid') || raise(UnknownFieldName, "Unknown field: #{key}")
+ key = fields_name_index[key]&.dig('uuid')
+
+ raise(UnknownFieldName, "Unknown field: #{key}") if key.blank? && throw_errors
end
+ next if key.blank?
+
if fields_uuid_index[key]['type'].in?(%w[initials signature image file])
- new_value, new_attachments = normalize_attachment_value(value, template.account)
+ new_value, new_attachments = normalize_attachment_value(value, template.account, for_submitter)
attachments.push(*new_attachments)
@@ -35,32 +35,50 @@ module Submitters
end
[key, value]
- end
+ end.to_h
[normalized_values, attachments]
end
+ def fetch_fields(template, submitter_name: nil, for_submitter: nil)
+ if submitter_name
+ submitter =
+ template.submitters.find { |e| e['name'] == submitter_name } ||
+ raise(UnknownSubmitterName, "Unknown submitter: #{submitter_name}")
+ end
+
+ fields = for_submitter&.submission&.template_fields || template.fields
+
+ fields.select { |e| e['submitter_uuid'] == (for_submitter&.uuid || submitter['uuid']) }
+ end
+
def build_fields_index(fields)
fields.index_by { |e| e['name'] }.merge(fields.index_by { |e| e['name'].to_s.parameterize.underscore })
end
- def normalize_attachment_value(value, account)
+ def normalize_attachment_value(value, account, for_submitter = nil)
if value.is_a?(Array)
- new_attachments = value.map { |v| build_attachment(v, account) }
+ new_attachments = value.map { |v| find_or_build_attachment(v, account, for_submitter) }
[new_attachments.map(&:uuid), new_attachments]
else
- new_attachment = build_attachment(value, account)
+ new_attachment = find_or_build_attachment(value, account, for_submitter)
[new_attachment.uuid, new_attachment]
end
end
- def build_attachment(value, account)
- ActiveStorage::Attachment.new(
- blob: find_or_create_blobs(account, value),
+ def find_or_build_attachment(value, account, for_submitter = nil)
+ blob = find_or_create_blobs(account, value)
+
+ attachment = for_submitter.attachments.find_by(blob_id: blob.id) if for_submitter
+
+ attachment ||= ActiveStorage::Attachment.new(
+ blob:,
name: 'attachments'
)
+
+ attachment
end
def find_or_create_blobs(account, url)
diff --git a/lib/submitters/submit_values.rb b/lib/submitters/submit_values.rb
index 632e8762..193f35e9 100644
--- a/lib/submitters/submit_values.rb
+++ b/lib/submitters/submit_values.rb
@@ -21,9 +21,7 @@ module Submitters
submitter.submission.save!
- return submitter unless submitter.completed_at?
-
- ProcessSubmitterCompletionJob.perform_later(submitter)
+ ProcessSubmitterCompletionJob.perform_later(submitter) if submitter.completed_at?
submitter
end
@@ -101,7 +99,7 @@ module Submitters
def replace_default_variables(value, attrs, template, with_time: false)
return if value.blank?
- value.gsub(VARIABLE_REGEXP) do |e|
+ value.to_s.gsub(VARIABLE_REGEXP) do |e|
case key = ::Regexp.last_match(1)
when 'time'
if with_time
diff --git a/lib/time_utils.rb b/lib/time_utils.rb
index c708ead5..3c87ed5f 100644
--- a/lib/time_utils.rb
+++ b/lib/time_utils.rb
@@ -1,6 +1,26 @@
# frozen_string_literal: true
module TimeUtils
+ MONTH_FORMATS = {
+ 'M' => '%-m',
+ 'MM' => '%m',
+ 'MMM' => '%b',
+ 'MMMM' => '%B'
+ }.freeze
+
+ DAY_FORMATS = {
+ 'D' => '%-d',
+ 'DD' => '%d'
+ }.freeze
+
+ YEAR_FORMATS = {
+ 'YYYY' => '%Y',
+ 'YY' => '%y'
+ }.freeze
+
+ DEFAULT_DATE_FORMAT_US = 'MM/DD/YYYY'
+ DEFAULT_DATE_FORMAT = 'DD/MM/YYYY'
+
module_function
def timezone_abbr(timezone, time = Time.current)
@@ -10,4 +30,20 @@ module TimeUtils
tz_info.abbreviation(time)
end
+
+ def format_date_string(string, format, locale)
+ date = Date.parse(string)
+
+ format ||= locale.to_s.ends_with?('US') ? DEFAULT_DATE_FORMAT_US : DEFAULT_DATE_FORMAT
+
+ i18n_format = format.sub(/D+/, DAY_FORMATS[format[/D+/]])
+ .sub(/M+/, MONTH_FORMATS[format[/M+/]])
+ .sub(/Y+/, YEAR_FORMATS[format[/Y+/]])
+
+ I18n.l(date, format: i18n_format, locale:)
+ rescue Date::Error => e
+ Rollbar.error(e) if defined?(Rollbar)
+
+ string
+ end
end
diff --git a/lib/users.rb b/lib/users.rb
index 775e3e97..0d1bea56 100644
--- a/lib/users.rb
+++ b/lib/users.rb
@@ -4,12 +4,21 @@ module Users
module_function
def from_omniauth(oauth)
- user = User.find_by(email: oauth.info.email)
+ user = User.find_by(email: oauth.info.email.to_s.downcase)
return user if user
- User.new(email: oauth.info.email,
- first_name: oauth.extra.id_info.given_name,
- last_name: oauth.extra.id_info.family_name)
+ case oauth['provider'].to_s
+ when 'google_oauth2'
+ User.new(email: oauth.info.email,
+ first_name: oauth.extra.id_info.given_name,
+ last_name: oauth.extra.id_info.family_name)
+ when 'microsoft_office365'
+ User.new(email: oauth.info.email,
+ first_name: oauth.info.first_name,
+ last_name: oauth.info.last_name)
+ when 'github'
+ User.new(email: oauth.info.email, first_name: oauth.info.name)
+ end
end
end
diff --git a/spec/system/template_spec.rb b/spec/system/template_spec.rb
index 5f41b691..35cd4bad 100644
--- a/spec/system/template_spec.rb
+++ b/spec/system/template_spec.rb
@@ -18,7 +18,6 @@ RSpec.describe 'Template' do
expect(page).to have_content(template.name)
expect(page).to have_content('There are no Submissions')
expect(page).to have_content('Send an invitation to fill and complete the form')
- expect(page).to have_link('Add Recipients', href: new_template_submission_path(template))
expect(page).to have_link('Sign it Yourself')
end
end
@@ -43,10 +42,10 @@ RSpec.describe 'Template' do
visit template_path(template)
end
- it 'removes a template' do
+ it 'archives a template' do
expect do
accept_confirm('Are you sure?') do
- click_button 'Remove'
+ click_button 'Archive'
end
end.to change { Template.active.count }.by(-1)