Compare commits

...
50 Commits
Author SHA1 Message Date
Pete Matsyburka 8ef1c309e0 fix email messages user assoc 2023-12-10 21:59:50 +02:00
Pete Matsyburka a32dcc0dc5 fix image scale result generation 2023-12-07 22:46:15 +02:00
Pete Matsyburka 16a2fde4b7 fix result annots 2023-12-07 22:46:08 +02:00
Pete Matsyburka 7856f7a134 add template clone api endpoint 2023-12-06 11:29:55 +02:00
Pete Matsyburka c545924fe9 adjust errors controller 2023-12-06 10:50:20 +02:00
Pete Matsyburka b1510bb88c fix GCS credentials set via ENV var 2023-12-06 00:12:59 +02:00
Pete Matsyburka 31d2024ce5 add send_2fa_sms event type 2023-12-05 19:42:47 +02:00
Pete Matsyburka 23c49f8706 add sign up email placeholder 2023-12-05 18:30:07 +02:00
Pete Matsyburka 62eb0cda6b return 422 on form update values when completed 2023-12-05 18:18:32 +02:00
Alex Turchyn 9f48c6e625 add MS oauth 2023-12-05 17:38:56 +02:00
Pete Matsyburka 83ce401cf2 rescue timestamp response error 2023-12-04 12:57:22 +02:00
Pete Matsyburka 541b025ad0 fix timeserver config loading 2023-12-04 12:47:19 +02:00
Pete Matsyburka 5fd018df46 adjust audit trail verified message 2023-12-03 22:30:03 +02:00
Pete Matsyburka 2822b0086a fix spec 2023-12-03 20:24:43 +02:00
Pete Matsyburka e27606f443 use rails storage proxy for audit log url 2023-12-03 13:03:36 +02:00
Pete Matsyburka 6c95cd5945 use template archive icon 2023-12-03 03:27:56 +02:00
Pete Matsyburka d723146060 allow to invite fewer submitters than in template 2023-12-03 00:27:29 +02:00
Pete Matsyburka 3b03e61ae3 use template links with 2+ submitters 2023-12-03 00:06:03 +02:00
Pete Matsyburka 172513b96c fix submission email sending preferences 2023-12-02 23:16:38 +02:00
Pete Matsyburka 68be42a48d add form attachments to completed emails 2023-12-02 23:03:24 +02:00
Pete Matsyburka 0370e363bc make webhook URL removable 2023-12-02 22:19:24 +02:00
Pete Matsyburka ced88476cc add ability to use timestamp server 2023-12-02 18:14:33 +02:00
Pete Matsyburka c5d71505ef allow to disable text signature via account prefs 2023-12-02 16:26:37 +02:00
Pete Matsyburka 489f9859be add force 2fa toggle 2023-12-02 01:50:59 +02:00
Pete Matsyburka a87ca65839 adjust audit trail 2023-12-01 18:44:00 +02:00
Pete Matsyburka fa78d0976d fix spec 2023-12-01 16:59:28 +02:00
Pete Matsyburka 4aaab2563b configurable values normalize exception 2023-12-01 16:59:19 +02:00
Pete Matsyburka e073cf2c27 refactor 2023-12-01 00:38:11 +02:00
Pete Matsyburka 173d406b1f adjust api email sending 2023-11-30 20:14:09 +02:00
Pete Matsyburka 3bcbad6f04 preserve submitters order via api 2023-11-30 12:32:17 +02:00
Pete Matsyburka e67854fe56 better sign yourself flow 2023-11-30 11:56:11 +02:00
Pete Matsyburka 7510e0cb6b do not delete area in preview mode 2023-11-29 21:59:22 +02:00
Pete Matsyburka 30c913b54f add template api folder filters 2023-11-29 20:23:40 +02:00
Pete Matsyburka 2cda9cd736 make default fields non editable 2023-11-27 20:25:20 +02:00
Pete Matsyburka 342d29f272 allow to permanently delete template in the cloud 2023-11-27 20:25:11 +02:00
Pete Matsyburka 13f76c9944 adjust submission title link 2023-11-26 00:33:05 +02:00
Pete Matsyburka ce4e55f387 autoadjust field value font size 2023-11-26 00:33:02 +02:00
Pete Matsyburka 712107ba5a limit manual email sending in cloud 2023-11-25 13:36:37 +02:00
Pete Matsyburka 1aa867893e fix bcc address removal 2023-11-24 23:22:04 +02:00
Pete Matsyburka 4f6142f9c0 adjust form builder 2023-11-24 23:09:39 +02:00
Pete Matsyburka c303349666 fix default builder fields 2023-11-24 17:34:57 +02:00
Pete Matsyburka e85df170f2 fix connection to DB URL with underscores 2023-11-24 17:17:56 +02:00
Pete Matsyburka 01333e5b2e preserve submitter preferences 2023-11-23 20:03:52 +02:00
Pete Matsyburka 514987138a adjust readme 2023-11-23 16:00:08 +02:00
Pete Matsyburka 0814c6d065 adjust submissions logo 2023-11-22 22:15:10 +02:00
Pete Matsyburka 542a443fb8 add custom exceptions handling controller 2023-11-22 01:41:39 +02:00
Pete Matsyburka f2c6bb1c3d add submission order param 2023-11-22 01:04:47 +02:00
Alex Turchyn b85c9cfdb7 adjust form builder 2023-11-21 23:16:34 +02:00
Pete Matsyburka e07b50c22d fix undefined method 'field_value' 2023-11-21 13:08:40 +02:00
Pete Matsyburka 4dde9890e0 update deps 2023-11-20 17:28:31 +02:00
105 changed files with 1089 additions and 274 deletions

No files matched your search

+2
View File
@@ -21,7 +21,9 @@ gem 'jwt'
gem 'lograge' gem 'lograge'
gem 'mysql2', require: false gem 'mysql2', require: false
gem 'oj' gem 'oj'
gem 'omniauth-github'
gem 'omniauth-google-oauth2' gem 'omniauth-google-oauth2'
gem 'omniauth-microsoft-office365'
gem 'omniauth-rails_csrf_protection' gem 'omniauth-rails_csrf_protection'
gem 'pagy' gem 'pagy'
gem 'pdf-reader' gem 'pdf-reader'
+10 -2
View File
@@ -246,7 +246,7 @@ GEM
hashdiff (1.0.1) hashdiff (1.0.1)
hashery (2.1.2) hashery (2.1.2)
hashie (5.0.0) hashie (5.0.0)
hexapdf (0.33.0) hexapdf (0.34.1)
cmdparse (~> 3.0, >= 3.0.3) cmdparse (~> 3.0, >= 3.0.3)
geom2d (~> 0.4, >= 0.4.1) geom2d (~> 0.4, >= 0.4.1)
openssl (>= 2.2.1) openssl (>= 2.2.1)
@@ -328,18 +328,24 @@ GEM
hashie (>= 3.4.6) hashie (>= 3.4.6)
rack (>= 2.2.3) rack (>= 2.2.3)
rack-protection rack-protection
omniauth-github (2.0.1)
omniauth (~> 2.0)
omniauth-oauth2 (~> 1.8)
omniauth-google-oauth2 (1.1.1) omniauth-google-oauth2 (1.1.1)
jwt (>= 2.0) jwt (>= 2.0)
oauth2 (~> 2.0.6) oauth2 (~> 2.0.6)
omniauth (~> 2.0) omniauth (~> 2.0)
omniauth-oauth2 (~> 1.8.0) omniauth-oauth2 (~> 1.8.0)
omniauth-microsoft-office365 (0.0.8)
omniauth
omniauth-oauth2
omniauth-oauth2 (1.8.0) omniauth-oauth2 (1.8.0)
oauth2 (>= 1.4, < 3) oauth2 (>= 1.4, < 3)
omniauth (~> 2.0) omniauth (~> 2.0)
omniauth-rails_csrf_protection (1.0.1) omniauth-rails_csrf_protection (1.0.1)
actionpack (>= 4.2) actionpack (>= 4.2)
omniauth (~> 2.0) omniauth (~> 2.0)
openssl (3.1.0) openssl (3.2.0)
orm_adapter (0.5.0) orm_adapter (0.5.0)
os (1.1.4) os (1.1.4)
pagy (6.0.4) pagy (6.0.4)
@@ -589,7 +595,9 @@ DEPENDENCIES
lograge lograge
mysql2 mysql2
oj oj
omniauth-github
omniauth-google-oauth2 omniauth-google-oauth2
omniauth-microsoft-office365
omniauth-rails_csrf_protection omniauth-rails_csrf_protection
pagy pagy
pdf-reader pdf-reader
+3 -3
View File
@@ -34,10 +34,10 @@ DocuSeal is an open source platform that provides secure and efficient digital d
## Features ## Features
- [x] PDF form fields builder (WYSIWYG) - [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] Multiple submitters per document
- [x] Automated emails via SMTP - [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] Automatic PDF eSignature
- [x] PDF signature verification - [x] PDF signature verification
- [x] Users management - [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 HOST=your-domain-name.com docker-compose up
``` ```
## For Companies ## For Businesses
### Integrate seamless document signing into your web or mobile apps with DocuSeal! ### 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. 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.
@@ -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
+53 -32
View File
@@ -13,6 +13,10 @@ module Api
submissions = Submissions.search(@submissions, params[:q]) submissions = Submissions.search(@submissions, params[:q])
submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present? 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, submissions = paginate(submissions.preload(:created_by_user, :template, :submitters,
audit_trail_attachment: :blob)) audit_trail_attachment: :blob))
@@ -50,35 +54,14 @@ module Api
end end
def create def create
is_send_email = !params[:send_email].in?(['false', false]) params[:send_email] = true unless params.key?(:send_email)
params[:send_sms] = false unless params.key?(:send_sms)
submissions = submissions = create_submissions(@template, params)
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( Submissions.send_signature_requests(submissions)
template: @template,
user: current_user,
source: :api,
mark_as_sent: is_send_email,
submitters_order: params[:submitters_order] || 'preserved',
submissions_attrs:
)
end
Submissions.send_signature_requests(submissions, send_email: is_send_email) render json: submissions.flat_map(&:submitters)
submitters = submissions.flat_map(&:submitters)
save_default_value_attachments!(attachments, submitters)
render json: submitters
rescue Submitters::NormalizeValues::UnknownFieldName, Submitters::NormalizeValues::UnknownSubmitterName => e rescue Submitters::NormalizeValues::UnknownFieldName, Submitters::NormalizeValues::UnknownSubmitterName => e
render json: { error: e.message }, status: :unprocessable_entity render json: { error: e.message }, status: :unprocessable_entity
end end
@@ -91,6 +74,35 @@ module Api
private 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 = 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:
)
save_default_value_attachments!(attachments, submissions.flat_map(&:submitters))
submissions
end
end
def serialize_params def serialize_params
{ {
only: %i[id source submitters_order created_at updated_at], only: %i[id source submitters_order created_at updated_at],
@@ -107,11 +119,19 @@ module Api
end end
def submissions_params def submissions_params
params.permit(submission: [{ key = params.key?(:submission) ? :submission : :submissions
submitters: [[:uuid, :name, :email, :role, :completed, :phone, :application_key,
{ values: {}, readonly_fields: [], params.permit(
fields: [%i[name default_value readonly validation_pattern invalid_message]] }]] 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
def normalize_submissions_params!(submissions_params, template) def normalize_submissions_params!(submissions_params, template)
@@ -128,7 +148,8 @@ module Api
values, new_attachments = values, new_attachments =
Submitters::NormalizeValues.call(template, Submitters::NormalizeValues.call(template,
default_values, default_values,
submitter[:role] || template.submitters[index]['name']) submitter[:role] || template.submitters[index]['name'],
throw_errors: true)
attachments.push(*new_attachments) attachments.push(*new_attachments)
@@ -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
@@ -9,6 +9,7 @@ module Api
templates = params[:archived] ? templates.archived : templates.active templates = params[:archived] ? templates.archived : templates.active
templates = templates.where(application_key: params[:application_key]) if params[:application_key].present? 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)) templates = paginate(templates.preload(:author, documents_attachments: :blob))
+3 -2
View File
@@ -44,8 +44,9 @@ class ApplicationController < ActionController::Base
redirect_to setup_index_path unless User.exists? redirect_to setup_index_path unless User.exists?
end end
def button_title(title: 'Submit', disabled_with: 'Submitting', icon: nil, icon_disabled: nil) 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:, icon:, icon_disabled: }) render_to_string(partial: 'shared/button_title',
locals: { title:, disabled_with:, title_class:, icon:, icon_disabled: })
end end
def svg_icon(icon_name, class: '') def svg_icon(icon_name, class: '')
+12
View File
@@ -5,6 +5,7 @@ class DashboardController < ApplicationController
before_action :maybe_redirect_product_url before_action :maybe_redirect_product_url
before_action :maybe_render_landing before_action :maybe_render_landing
before_action :maybe_redirect_mfa_setup
load_and_authorize_resource :template_folder, parent: false load_and_authorize_resource :template_folder, parent: false
load_and_authorize_resource :template, 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 redirect_to Docuseal::PRODUCT_URL, allow_other_host: true
end 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 def maybe_render_landing
return if signed_in? return if signed_in?
+35
View File
@@ -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
+16 -6
View File
@@ -5,13 +5,11 @@ class MfaSetupController < ApplicationController
authorize!(:update, current_user) authorize!(:update, current_user)
end end
def new before_action :set_provision_url, only: %i[show new]
current_user.otp_secret ||= User.generate_otp_secret
current_user.save! def show; end
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name) def new; end
end
def edit; end def edit; end
@@ -26,7 +24,7 @@ class MfaSetupController < ApplicationController
@error_message = 'Code is invalid' @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
end end
@@ -41,4 +39,16 @@ class MfaSetupController < ApplicationController
render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/edit'), status: :unprocessable_entity render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/edit'), status: :unprocessable_entity
end end
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 end
@@ -12,7 +12,7 @@ class NotificationsSettingsController < ApplicationController
def index; end def index; end
def create 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' redirect_back fallback_location: settings_notifications_path, notice: 'Changes have been saved'
else else
redirect_back fallback_location: settings_notifications_path, alert: 'Unable to save' redirect_back fallback_location: settings_notifications_path, alert: 'Unable to save'
+22 -8
View File
@@ -14,21 +14,20 @@ class StartFormController < ApplicationController
def update def update
@submitter = Submitter.where(submission: @template.submissions.where(deleted_at: nil)) @submitter = Submitter.where(submission: @template.submissions.where(deleted_at: nil))
.order(id: :desc)
.then { |rel| params[:resubmit].present? ? rel.where(completed_at: nil) : rel } .then { |rel| params[:resubmit].present? ? rel.where(completed_at: nil) : rel }
.find_or_initialize_by(**submitter_params.compact_blank) .find_or_initialize_by(**submitter_params.compact_blank)
if @submitter.completed_at? if @submitter.completed_at?
redirect_to start_form_completed_path(@template.slug, email: submitter_params[:email]) redirect_to start_form_completed_path(@template.slug, email: submitter_params[:email])
else else
@submitter.assign_attributes( if @template.submitters.to_a.size > 1 && @submitter.new_record?
uuid: @template.submitters.first['uuid'], @error_message = 'Not found'
ip: request.remote_ip,
ua: request.user_agent
)
@submitter.submission ||= Submission.new(template: @template, return render :show
template_submitters: @template.submitters, end
source: :link)
assign_submission_attributes(@submitter, @template) if @submitter.new_record?
if @submitter.save if @submitter.save
redirect_to submit_form_path(@submitter.slug) redirect_to submit_form_path(@submitter.slug)
@@ -46,6 +45,21 @@ class StartFormController < ApplicationController
private 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 def submitter_params
params.require(:submitter).permit(:email, :phone, :name).tap do |attrs| params.require(:submitter).permit(:email, :phone, :name).tap do |attrs|
attrs[:email] = Submissions.normalize_email(attrs[:email]) attrs[:email] = Submissions.normalize_email(attrs[:email])
+10 -3
View File
@@ -34,23 +34,30 @@ class SubmissionsController < ApplicationController
def create def create
authorize!(:create, Submission) authorize!(:create, Submission)
if params[:is_custom_message] != '1'
params.delete(:subject)
params.delete(:body)
end
submissions = submissions =
if params[:emails].present? if params[:emails].present?
Submissions.create_from_emails(template: @template, Submissions.create_from_emails(template: @template,
user: current_user, user: current_user,
source: :invite, source: :invite,
mark_as_sent: params[:send_email] == '1', mark_as_sent: params[:send_email] == '1',
emails: params[:emails]) emails: params[:emails],
params: params.merge('send_completed_email' => true))
else else
Submissions.create_from_submitters(template: @template, Submissions.create_from_submitters(template: @template,
user: current_user, user: current_user,
source: :invite, source: :invite,
submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random', submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random',
mark_as_sent: params[:send_email] == '1', 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 end
Submissions.send_signature_requests(submissions, params) Submissions.send_signature_requests(submissions)
redirect_to template_path(@template), notice: 'New recipients have been added' redirect_to template_path(@template), notice: 'New recipients have been added'
end end
@@ -38,6 +38,10 @@ class SubmitFormController < ApplicationController
def update def update
submitter = Submitter.find_by!(slug: params[:slug]) submitter = Submitter.find_by!(slug: params[:slug])
if submitter.completed_at?
return render json: { error: 'Form has been completed already.' }, status: :unprocessable_entity
end
Submitters::SubmitValues.call(submitter, params, request) Submitters::SubmitValues.call(submitter, params, request)
head :ok head :ok
@@ -4,6 +4,13 @@ class SubmittersSendEmailController < ApplicationController
load_and_authorize_resource :submitter, id_param: :submitter_slug, find_by: :slug load_and_authorize_resource :submitter, id_param: :submitter_slug, find_by: :slug
def create 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! SubmitterMailer.invitation_email(@submitter).deliver_later!
SubmissionEvent.create!(submitter: @submitter, event_type: 'send_email') SubmissionEvent.create!(submitter: @submitter, event_type: 'send_email')
+3 -1
View File
@@ -58,9 +58,11 @@ class TemplatesController < ApplicationController
def destroy def destroy
notice = notice =
if !Docuseal.multitenant? && params[:permanently].present? if params[:permanently].present?
@template.destroy! @template.destroy!
Rollbar.info("Remove template: #{@template.id}") if defined?(Rollbar)
'Template has been removed.' 'Template has been removed.'
else else
@template.update!(deleted_at: Time.current) @template.update!(deleted_at: Time.current)
@@ -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
@@ -7,7 +7,9 @@ class WebhookSettingsController < ApplicationController
def show; end def show; end
def create 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.') redirect_back(fallback_location: settings_webhooks_path, notice: 'Webhook URL has been saved.')
end end
+2
View File
@@ -79,6 +79,8 @@ window.customElements.define('template-builder', class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.appElem = document.createElement('div') this.appElem = document.createElement('div')
this.appElem.classList.add('md:h-screen')
this.app = createApp(TemplateBuilder, { this.app = createApp(TemplateBuilder, {
template: reactive(JSON.parse(this.dataset.template)), template: reactive(JSON.parse(this.dataset.template)),
backgroundColor: '#faf7f5', backgroundColor: '#faf7f5',
+1
View File
@@ -17,6 +17,7 @@ window.customElements.define('submission-form', class extends HTMLElement {
isDemo: this.dataset.isDemo === 'true', isDemo: this.dataset.isDemo === 'true',
attribution: this.dataset.attribution !== 'false', attribution: this.dataset.attribution !== 'false',
withConfetti: true, withConfetti: true,
withTypedSignature: this.dataset.withTypedSignature !== 'false',
values: reactive(JSON.parse(this.dataset.values)), values: reactive(JSON.parse(this.dataset.values)),
completedButton: JSON.parse(this.dataset.completedButton), completedButton: JSON.parse(this.dataset.completedButton),
attachments: reactive(JSON.parse(this.dataset.attachments)), attachments: reactive(JSON.parse(this.dataset.attachments)),
+6
View File
@@ -223,6 +223,7 @@
:field="currentField" :field="currentField"
:previous-value="previousSignatureValue" :previous-value="previousSignatureValue"
:is-direct-upload="isDirectUpload" :is-direct-upload="isDirectUpload"
:with-typed-signature="withTypedSignature"
:attachments-index="attachmentsIndex" :attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
@attached="attachments.push($event)" @attached="attachments.push($event)"
@@ -386,6 +387,11 @@ export default {
required: false, required: false,
default: false default: false
}, },
withTypedSignature: {
type: Boolean,
required: false,
default: true
},
baseUrl: { baseUrl: {
type: String, type: String,
required: false, required: false,
@@ -23,7 +23,7 @@
</a> </a>
</span> </span>
<span <span
v-else v-else-if="withTypedSignature"
class="tooltip" class="tooltip"
:data-tip="t('type_text')" :data-tip="t('type_text')"
> >
@@ -149,6 +149,11 @@ export default {
required: true, required: true,
default: false default: false
}, },
withTypedSignature: {
type: Boolean,
required: false,
default: true
},
attachmentsIndex: { attachmentsIndex: {
type: Object, type: Object,
required: false, required: false,
+8 -3
View File
@@ -42,7 +42,7 @@
v-model="field.submitter_uuid" v-model="field.submitter_uuid"
class="border-r" class="border-r"
:compact="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]'" :menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px]'"
:submitters="template.submitters" :submitters="template.submitters"
@update:model-value="save" @update:model-value="save"
@@ -51,7 +51,7 @@
<FieldType <FieldType
v-model="field.type" v-model="field.type"
:button-width="27" :button-width="27"
:editable="editable" :editable="editable && !defaultField"
:button-classes="'px-1'" :button-classes="'px-1'"
:menu-classes="'bg-white rounded-t-none'" :menu-classes="'bg-white rounded-t-none'"
@update:model-value="[maybeUpdateOptions(), save()]" @update:model-value="[maybeUpdateOptions(), save()]"
@@ -60,7 +60,7 @@
<span <span
v-if="field.type !== 'checkbox' || field.name" v-if="field.type !== 'checkbox' || field.name"
ref="name" ref="name"
:contenteditable="editable" :contenteditable="editable && !defaultField"
class="pr-1 cursor-text outline-none block" class="pr-1 cursor-text outline-none block"
style="min-width: 2px" style="min-width: 2px"
@keydown.enter.prevent="onNameEnter" @keydown.enter.prevent="onNameEnter"
@@ -160,6 +160,11 @@ export default {
required: false, required: false,
default: false default: false
}, },
defaultField: {
type: Object,
required: false,
default: null
},
editable: { editable: {
type: Boolean, type: Boolean,
required: false, required: false,
+37 -9
View File
@@ -4,10 +4,11 @@
class="mx-auto pl-3 md:pl-4 h-full" class="mx-auto pl-3 md:pl-4 h-full"
> >
<div <div
v-if="$slots.buttons || withTitle"
class="flex justify-between py-1.5 items-center pr-4 sticky top-0 z-10" class="flex justify-between py-1.5 items-center pr-4 sticky top-0 z-10"
:style="{ backgroundColor }" :style="{ backgroundColor }"
> >
<div class="flex space-x-3"> <div class="flex items-center space-x-3">
<a <a
v-if="withLogo" v-if="withLogo"
href="/" href="/"
@@ -15,9 +16,10 @@
<Logo /> <Logo />
</a> </a>
<Contenteditable <Contenteditable
v-if="withTitle"
:model-value="template.name" :model-value="template.name"
:editable="editable" :editable="editable"
class="text-3xl font-semibold focus:text-clip" class="text-xl md:text-3xl font-semibold focus:text-clip"
:icon-stroke-width="2.3" :icon-stroke-width="2.3"
@update:model-value="updateName" @update:model-value="updateName"
/> />
@@ -28,10 +30,25 @@
name="buttons" name="buttons"
/> />
<template v-else> <template v-else>
<a
:href="template.submitters.length > 1 ? `/templates/${template.id}/submissions/new?selfsign=true` : `/d/${template.slug}`"
class="btn btn-primary btn-ghost text-base hidden md:flex"
:target="template.submitters.length > 1 ? '' : '_blank'"
:data-turbo-frame="template.submitters.length > 1 ? 'modal' : ''"
@click="maybeShowEmptyTemplateAlert"
>
<IconWritingSign
width="20"
class="inline"
/>
<span class="hidden md:inline">
Sign Yourself
</span>
</a>
<a <a
:href="`/templates/${template.id}/submissions/new`" :href="`/templates/${template.id}/submissions/new`"
data-turbo-frame="modal" data-turbo-frame="modal"
class="btn btn-primary text-base" class="white-button md:!px-6"
@click="maybeShowEmptyTemplateAlert" @click="maybeShowEmptyTemplateAlert"
> >
<IconUsersPlus <IconUsersPlus
@@ -39,7 +56,7 @@
class="inline" class="inline"
/> />
<span class="hidden md:inline"> <span class="hidden md:inline">
Recipients Send
</span> </span>
</a> </a>
<button <button
@@ -64,7 +81,10 @@
</template> </template>
</div> </div>
</div> </div>
<div class="flex md:max-h-[calc(100vh-60px)]"> <div
class="flex"
:class="$slots.buttons || withTitle ? 'md:max-h-[calc(100%_-_60px)]' : 'md:max-h-[100%]'"
>
<div <div
ref="previews" ref="previews"
:style="{ 'display': isBreakpointLg ? 'none' : 'initial' }" :style="{ 'display': isBreakpointLg ? 'none' : 'initial' }"
@@ -90,7 +110,7 @@
/> />
<div <div
class="sticky bottom-0 py-2" class="sticky bottom-0 py-2"
:class="{ 'bg-base-100': withStickySubmitters }" :style="withStickySubmitters ? { backgroundColor } : {}"
> >
<Upload <Upload
v-if="sortedDocuments.length && editable && withUploadButton" v-if="sortedDocuments.length && editable && withUploadButton"
@@ -124,6 +144,7 @@
:selected-submitter="selectedSubmitter" :selected-submitter="selectedSubmitter"
:document="document" :document="document"
:is-drag="!!dragField" :is-drag="!!dragField"
:default-fields="defaultFields"
:draw-field="drawField" :draw-field="drawField"
:editable="editable" :editable="editable"
:base-url="baseUrl" :base-url="baseUrl"
@@ -200,7 +221,8 @@
> >
<div <div
v-if="drawField" v-if="drawField"
class="sticky inset-0 bg-base-100 h-full" class="sticky inset-0 h-full"
:style="{ backgroundColor }"
> >
<div class="bg-base-300 rounded-lg p-5 text-center space-y-4"> <div class="bg-base-300 rounded-lg p-5 text-center space-y-4">
<p> <p>
@@ -248,7 +270,7 @@ import Contenteditable from './contenteditable'
import DocumentPreview from './preview' import DocumentPreview from './preview'
import DocumentControls from './controls' import DocumentControls from './controls'
import FieldType from './field_type' import FieldType from './field_type'
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 { v4 } from 'uuid'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
@@ -262,6 +284,7 @@ export default {
IconPlus, IconPlus,
FieldType, FieldType,
IconX, IconX,
IconWritingSign,
Logo, Logo,
Dropzone, Dropzone,
DocumentPreview, DocumentPreview,
@@ -336,6 +359,11 @@ export default {
required: false, required: false,
default: true default: true
}, },
withTitle: {
type: Boolean,
required: false,
default: true
},
withPhone: { withPhone: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -488,7 +516,7 @@ export default {
this.selectedAreaRef.value = null 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.removeArea(this.selectedAreaRef.value)
this.selectedAreaRef.value = null this.selectedAreaRef.value = null
@@ -24,11 +24,10 @@
* *
</span> </span>
<IconPencil <IconPencil
v-if="editable"
class="cursor-pointer flex-none opacity-0 group-hover/contenteditable-container:opacity-100 group-hover/contenteditable:opacity-100 align-middle peer-focus:hidden" class="cursor-pointer flex-none opacity-0 group-hover/contenteditable-container:opacity-100 group-hover/contenteditable:opacity-100 align-middle peer-focus:hidden"
:style="iconInline ? {} : { right: -(1.1 * iconWidth) + 'px' }" :style="iconInline ? {} : { right: -(1.1 * iconWidth) + 'px' }"
title="Edit" title="Edit"
:class="{ 'ml-1': !withRequired, 'absolute': !iconInline, 'inline align-bottom': iconInline }" :class="{ invisible: !editable, 'ml-1': !withRequired, 'absolute': !iconInline, 'inline align-bottom': iconInline }"
:width="iconWidth" :width="iconWidth"
:stroke-width="iconStrokeWidth" :stroke-width="iconStrokeWidth"
@click="[focusContenteditable(), selectOnEditClick && selectContent()]" @click="[focusContenteditable(), selectOnEditClick && selectContent()]"
@@ -8,6 +8,7 @@
:editable="editable" :editable="editable"
:areas="areasIndex[index]" :areas="areasIndex[index]"
:is-drag="isDrag" :is-drag="isDrag"
:default-fields="defaultFields"
:draw-field="drawField" :draw-field="drawField"
:selected-submitter="selectedSubmitter" :selected-submitter="selectedSubmitter"
:image="image" :image="image"
@@ -35,6 +36,11 @@ export default {
required: false, required: false,
default: () => ({}) default: () => ({})
}, },
defaultFields: {
type: Array,
required: false,
default: () => []
},
selectedSubmitter: { selectedSubmitter: {
type: Object, type: Object,
required: true required: true
+8 -2
View File
@@ -14,7 +14,7 @@
<div class="flex items-center p-1 space-x-1"> <div class="flex items-center p-1 space-x-1">
<FieldType <FieldType
v-model="field.type" v-model="field.type"
:editable="editable" :editable="editable && !defaultField"
:button-width="20" :button-width="20"
@update:model-value="[maybeUpdateOptions(), save()]" @update:model-value="[maybeUpdateOptions(), save()]"
@click="scrollToFirstArea" @click="scrollToFirstArea"
@@ -22,7 +22,7 @@
<Contenteditable <Contenteditable
ref="name" ref="name"
:model-value="field.name || defaultName" :model-value="field.name || defaultName"
:editable="editable" :editable="editable && !defaultField"
:icon-inline="true" :icon-inline="true"
:icon-width="18" :icon-width="18"
:icon-stroke-width="1.6" :icon-stroke-width="1.6"
@@ -66,6 +66,7 @@
/> />
</button> </button>
<span <span
v-if="!defaultField"
class="dropdown dropdown-end" class="dropdown dropdown-end"
> >
<label <label
@@ -280,6 +281,11 @@ export default {
type: Object, type: Object,
required: true required: true
}, },
defaultField: {
type: Object,
required: false,
default: null
},
editable: { editable: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -74,7 +74,7 @@
:stroke-width="1.6" :stroke-width="1.6"
/> />
<span class="py-1"> <span class="py-1">
Add Submitter Add {{ names[submitters.length] }}
</span> </span>
</a> </a>
</li> </li>
@@ -100,7 +100,7 @@
<label <label
v-else v-else
tabindex="0" tabindex="0"
class="cursor-pointer group/contenteditable-container rounded-md p-2 border border-base-300 w-full flex justify-between" class="group cursor-pointer group/contenteditable-container rounded-md p-2 border border-base-300 hover:border-content w-full flex justify-between"
> >
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<span <span
@@ -117,7 +117,7 @@
@update:model-value="$emit('name-change', selectedSubmitter)" @update:model-value="$emit('name-change', selectedSubmitter)"
/> />
</div> </div>
<span class="flex items-center"> <span class="flex items-center transition-all duration-75 group-hover:border border-base-content/20 border-dashed w-6 h-6 flex justify-center items-center rounded">
<IconPlus <IconPlus
width="18" width="18"
height="18" height="18"
@@ -169,7 +169,7 @@
:stroke-width="1.6" :stroke-width="1.6"
/> />
<span class="py-1"> <span class="py-1">
Add Submitter Add {{ names[submitters.length] }}
</span> </span>
</a> </a>
</li> </li>
@@ -239,16 +239,16 @@ export default {
}, },
names () { names () {
return [ return [
'First Submitter', 'First Party',
'Second Submitter', 'Second Party',
'Third Submitter', 'Third Party',
'Fourth Submitter', 'Fourth Party',
'Fifth Submitter', 'Fifth Party',
'Sixth Submitter', 'Sixth Party',
'Seventh Submitter', 'Seventh Party',
'Eighth Submitter', 'Eighth Party',
'Ninth Submitter', 'Ninth Party',
'Tenth Submitter' 'Tenth Party'
] ]
}, },
selectedSubmitter () { selectedSubmitter () {
+9 -4
View File
@@ -24,6 +24,7 @@
:field="field" :field="field"
:type-index="fields.filter((f) => f.type === field.type).indexOf(field)" :type-index="fields.filter((f) => f.type === field.type).indexOf(field)"
:editable="editable && !dragField" :editable="editable && !dragField"
:default-field="defaultFields.find((f) => f.name === field.name)"
:draggable="editable" :draggable="editable"
@dragstart="dragField = field" @dragstart="dragField = field"
@dragend="dragField = null" @dragend="dragField = null"
@@ -72,14 +73,14 @@
<button <button
v-if="withPhone || type != 'phone'" v-if="withPhone || type != 'phone'"
draggable="true" draggable="true"
class="flex items-center justify-center border border-dashed border-base-300 w-full rounded relative" class="group flex items-center justify-center border border-dashed border-base-300 hover:border-base-content/20 w-full rounded relative"
:style="{ backgroundColor: backgroundColor }" :style="{ backgroundColor: backgroundColor }"
@dragstart="onDragstart({ type: type })" @dragstart="onDragstart({ type: type })"
@dragend="$emit('drag-end')" @dragend="$emit('drag-end')"
@click="addField(type)" @click="addField(type)"
> >
<div class="w-0 absolute left-0"> <div class="flex items-console group-hover:bg-base-200/50 transition-all cursor-grab h-full absolute left-0">
<IconDrag class="cursor-grab" /> <IconDrag class=" my-auto" />
</div> </div>
<div class="flex items-center flex-col px-2 py-2"> <div class="flex items-center flex-col px-2 py-2">
<component :is="icon" /> <component :is="icon" />
@@ -195,7 +196,7 @@ export default {
}, },
submitterDefaultFields () { submitterDefaultFields () {
return this.defaultFields.filter((f) => { return this.defaultFields.filter((f) => {
return !this.fields.find((field) => field.name === f.name) && (!f.role || f.role === this.selectedSubmitter.name) return !this.submitterFields.find((field) => field.name === f.name) && (!f.role || f.role === this.selectedSubmitter.name)
}) })
} }
}, },
@@ -257,6 +258,10 @@ export default {
this.fields.push(field) this.fields.push(field)
if (['signature', 'initials', 'cells'].includes(type)) {
this.$emit('set-draw', { field })
}
this.save() this.save()
} }
} }
+6
View File
@@ -23,6 +23,7 @@
:area="item.area" :area="item.area"
:field="item.field" :field="item.field"
:editable="editable" :editable="editable"
:default-field="defaultFields.find((f) => f.name === item.field.name)"
@start-resize="resizeDirection = $event" @start-resize="resizeDirection = $event"
@stop-resize="resizeDirection = null" @stop-resize="resizeDirection = null"
@start-drag="isMove = true" @start-drag="isMove = true"
@@ -69,6 +70,11 @@ export default {
required: false, required: false,
default: () => [] default: () => []
}, },
defaultFields: {
type: Array,
required: false,
default: () => []
},
selectedSubmitter: { selectedSubmitter: {
type: Object, type: Object,
required: true required: true
+5 -3
View File
@@ -24,14 +24,16 @@ class ProcessSubmitterCompletionJob < ApplicationJob
def enqueue_completed_emails(submitter) def enqueue_completed_emails(submitter)
user = submitter.submission.created_by_user || submitter.template.author 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 bcc = submitter.submission.template.account.account_configs
.find_by(key: AccountConfig::BCC_EMAILS)&.value .find_by(key: AccountConfig::BCC_EMAILS)&.value
SubmitterMailer.completed_email(submitter, user, bcc:).deliver_later! SubmitterMailer.completed_email(submitter, user, bcc:).deliver_later!
end 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? SubmitterMailer.documents_copy_email(submitter, to:).deliver_later! if to.present?
end end
@@ -48,6 +50,6 @@ class ProcessSubmitterCompletionJob < ApplicationJob
next_submitter = submitter.submission.submitters.find { |s| s.uuid == next_submitter_item['uuid'] } 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
end end
@@ -4,7 +4,7 @@ class SendSubmitterInvitationEmailJob < ApplicationJob
def perform(params = {}) def perform(params = {})
submitter = Submitter.find(params['submitter_id']) 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') SubmissionEvent.create!(submitter:, event_type: 'send_email')
+34 -9
View File
@@ -3,18 +3,26 @@
class SubmitterMailer < ApplicationMailer class SubmitterMailer < ApplicationMailer
MAX_ATTACHMENTS_SIZE = 10.megabytes 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 @current_account = submitter.submission.template.account
@submitter = submitter @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) @email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY)
subject = subject =
if @email_config || subject.present? if @email_config || @subject
ReplaceEmailVariables.call(subject.presence || @email_config.value['subject'], submitter:) ReplaceEmailVariables.call(@subject || @email_config.value['subject'], submitter:)
else else
'You are invited to submit a form' DEFAULT_INVITATION_SUBJECT
end end
mail(to: @submitter.friendly_name, mail(to: @submitter.friendly_name,
@@ -86,7 +94,26 @@ class SubmitterMailer < ApplicationMailer
total_size = audit_trail_data.size total_size = audit_trail_data.size
end 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'] == 'file' }
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 total_size += attachment.byte_size
break if total_size >= MAX_ATTACHMENTS_SIZE break if total_size >= MAX_ATTACHMENTS_SIZE
@@ -94,9 +121,7 @@ class SubmitterMailer < ApplicationMailer
attachments[attachment.filename.to_s] = attachment.download attachments[attachment.filename.to_s] = attachment.download
end end
attachments[submitter.submission.audit_trail.filename.to_s] = audit_trail_data if audit_trail_data total_size
documents
end end
def from_address_for_submitter(submitter) def from_address_for_submitter(submitter)
+1
View File
@@ -15,6 +15,7 @@ class Account < ApplicationRecord
has_many :users, dependent: :destroy has_many :users, dependent: :destroy
has_many :encrypted_configs, dependent: :destroy has_many :encrypted_configs, dependent: :destroy
has_many :account_configs, dependent: :destroy has_many :account_configs, dependent: :destroy
has_many :email_messages, dependent: :destroy
has_many :templates, dependent: :destroy has_many :templates, dependent: :destroy
has_many :template_folders, dependent: :destroy has_many :template_folders, dependent: :destroy
has_one :default_template_folder, -> { where(name: TemplateFolder::DEFAULT_NAME) }, has_one :default_template_folder, -> { where(name: TemplateFolder::DEFAULT_NAME) },
+2
View File
@@ -25,6 +25,8 @@ class AccountConfig < ApplicationRecord
SUBMITTER_COMPLETED_EMAIL_KEY = 'submitter_completed_email' SUBMITTER_COMPLETED_EMAIL_KEY = 'submitter_completed_email'
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY = 'submitter_documents_copy_email' SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY = 'submitter_documents_copy_email'
BCC_EMAILS = 'bcc_emails' BCC_EMAILS = 'bcc_emails'
FORCE_MFA = 'force_mfa'
ALLOW_TYPED_SIGNATURE = 'allow_typed_signature'
SUBMITTER_REMAILERS = 'submitter_reminders' SUBMITTER_REMAILERS = 'submitter_reminders'
FORM_COMPLETED_BUTTON_KEY = 'form_completed_button' FORM_COMPLETED_BUTTON_KEY = 'form_completed_button'
+39
View File
@@ -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
+1
View File
@@ -25,6 +25,7 @@ class EncryptedConfig < ApplicationRecord
FILES_STORAGE_KEY = 'active_storage', FILES_STORAGE_KEY = 'active_storage',
EMAIL_SMTP_KEY = 'action_mailer_smtp', EMAIL_SMTP_KEY = 'action_mailer_smtp',
ESIGN_CERTS_KEY = 'esign_certs', ESIGN_CERTS_KEY = 'esign_certs',
TIMESTAMP_SERVER_URL_KEY = 'timestamp_server_url',
APP_URL_KEY = 'app_url', APP_URL_KEY = 'app_url',
WEBHOOK_URL_KEY = 'webhook_url' WEBHOOK_URL_KEY = 'webhook_url'
].freeze ].freeze
+7 -1
View File
@@ -6,6 +6,7 @@
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# deleted_at :datetime # deleted_at :datetime
# preferences :text not null
# slug :string not null # slug :string not null
# source :text not null # source :text not null
# submitters_order :string not null # submitters_order :string not null
@@ -36,9 +37,12 @@ class Submission < ApplicationRecord
has_many :submitters, dependent: :destroy has_many :submitters, dependent: :destroy
has_many :submission_events, dependent: :destroy has_many :submission_events, dependent: :destroy
attribute :preferences, :string, default: -> { {} }
serialize :template_fields, JSON serialize :template_fields, JSON
serialize :template_schema, JSON serialize :template_schema, JSON
serialize :template_submitters, JSON serialize :template_submitters, JSON
serialize :preferences, JSON
attribute :source, :string, default: 'link' attribute :source, :string, default: 'link'
attribute :submitters_order, :string, default: 'random' attribute :submitters_order, :string, default: 'random'
@@ -68,7 +72,9 @@ class Submission < ApplicationRecord
}, scope: false, prefix: true }, scope: false, prefix: true
def audit_trail_url 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 end
alias audit_log_url audit_trail_url alias audit_log_url audit_trail_url
end end
+1
View File
@@ -39,6 +39,7 @@ class SubmissionEvent < ApplicationRecord
send_email: 'send_email', send_email: 'send_email',
send_reminder_email: 'send_reminder_email', send_reminder_email: 'send_reminder_email',
send_sms: 'send_sms', send_sms: 'send_sms',
send_2fa_sms: 'send_2fa_sms',
open_email: 'open_email', open_email: 'open_email',
click_email: 'click_email', click_email: 'click_email',
click_sms: 'click_sms', click_sms: 'click_sms',
+3
View File
@@ -12,6 +12,7 @@
# name :string # name :string
# opened_at :datetime # opened_at :datetime
# phone :string # phone :string
# preferences :text not null
# sent_at :datetime # sent_at :datetime
# slug :string not null # slug :string not null
# ua :string # ua :string
@@ -37,9 +38,11 @@ class Submitter < ApplicationRecord
has_one :account, through: :template has_one :account, through: :template
attribute :values, :string, default: -> { {} } attribute :values, :string, default: -> { {} }
attribute :preferences, :string, default: -> { {} }
attribute :slug, :string, default: -> { SecureRandom.base58(14) } attribute :slug, :string, default: -> { SecureRandom.base58(14) }
serialize :values, JSON serialize :values, JSON
serialize :preferences, JSON
has_many_attached :documents has_many_attached :documents
has_many_attached :attachments has_many_attached :attachments
+1 -1
View File
@@ -33,7 +33,7 @@
# fk_rails_... (folder_id => template_folders.id) # fk_rails_... (folder_id => template_folders.id)
# #
class Template < ApplicationRecord class Template < ApplicationRecord
DEFAULT_SUBMITTER_NAME = 'First Submitter' DEFAULT_SUBMITTER_NAME = 'First Party'
belongs_to :author, class_name: 'User' belongs_to :author, class_name: 'User'
belongs_to :account belongs_to :account
+5 -1
View File
@@ -55,9 +55,13 @@ class User < ApplicationRecord
has_many :template_folders, dependent: :destroy, foreign_key: :author_id, inverse_of: :author has_many :template_folders, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
has_many :user_configs, dependent: :destroy has_many :user_configs, dependent: :destroy
has_many :encrypted_configs, dependent: :destroy, class_name: 'EncryptedUserConfig' 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 :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable
devise :registerable, :omniauthable, omniauth_providers: [:google_oauth2] if Docuseal.multitenant?
if Docuseal.multitenant?
devise :registerable, :omniauthable, omniauth_providers: %i[google_oauth2 microsoft_office365 github]
end
attribute :role, :string, default: ADMIN_ROLE attribute :role, :string, default: ADMIN_ROLE
attribute :uuid, :string, default: -> { SecureRandom.uuid } attribute :uuid, :string, default: -> { SecureRandom.uuid }
+29
View File
@@ -36,6 +36,35 @@
</div> </div>
<% end %> <% end %>
<% end %> <% end %>
<% if can?(:manage, AccountConfig) %>
<div class="px-1 mt-8">
<h2 class="text-2xl font-bold mb-2">Preferences</h2>
<% 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 %>
<div class="flex items-center justify-between py-2.5">
<span>
Force 2FA with Authenticator App
</span>
<%= f.check_box :value, class: 'toggle', checked: account_config.value, onchange: 'this.form.requestSubmit()' %>
</div>
<% 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 %>
<div class="flex items-center justify-between py-2.5">
<span>
Allow typed text signatures
</span>
<%= f.check_box :value, class: 'toggle', checked: account_config.value != false, onchange: 'this.form.requestSubmit()' %>
</div>
<% end %>
<% end %>
</div>
<% end %>
</div> </div>
<div class="w-0 md:w-52"></div> <div class="w-0 md:w-52"></div>
</div> </div>
+1 -1
View File
@@ -34,7 +34,7 @@
"submitters": [ "submitters": [
{ {
"name": "John Doe", "name": "John Doe",
"role": "<%= current_account.templates.last ? current_account.templates.last.submitters.first['name'] : 'First Submitter' %>", "role": "<%= current_account.templates.last ? current_account.templates.last.submitters.first['name'] : 'First Party' %>",
"email": "<%= current_user.email.sub('@', '+test@') %>", "email": "<%= current_user.email.sub('@', '+test@') %>",
"values": { "values": {
"Form Text Field Name": "Default Value" "Form Text Field Name": "Default Value"
+1 -1
View File
@@ -8,7 +8,7 @@
<% end %> <% end %>
<% if can?(:create, ::Template) %> <% if can?(:create, ::Template) %>
<%= render 'templates/upload_button' %> <%= 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') %> <%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %>
<span class="hidden md:block">Create</span> <span class="hidden md:block">Create</span>
<% end %> <% end %>
+13 -6
View File
@@ -8,7 +8,7 @@
<div class="space-y-2"> <div class="space-y-2">
<div class="form-control"> <div class="form-control">
<%= f.label :email, class: 'label' %> <%= 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' %>
</div> </div>
</div> </div>
<div class="form-control"> <div class="form-control">
@@ -16,11 +16,18 @@
</div> </div>
<% end %> <% end %>
<% if devise_mapping.omniauthable? %> <% if devise_mapping.omniauthable? %>
<%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %> <div class="space-y-4">
<set-timezone data-input-id="state" data-params="true"></set-timezone> <%= 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 %> <set-timezone data-input-id="state" data-params="true"></set-timezone>
<%= 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' %> <%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %>
<% end %> <%= 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| %>
<set-timezone data-input-id="state_microsoft" data-params="true"></set-timezone>
<%= 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 %>
</div>
<% end %> <% end %>
<%= render 'devise/shared/links' %> <%= render 'devise/shared/links' %>
</div> </div>
+12 -5
View File
@@ -20,11 +20,18 @@
</div> </div>
<% end %> <% end %>
<% if devise_mapping.omniauthable? %> <% if devise_mapping.omniauthable? %>
<%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %> <div class="space-y-4">
<set-timezone data-input-id="state" data-params="true"></set-timezone> <%= 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 %> <set-timezone data-input-id="state" data-params="true"></set-timezone>
<%= 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' %> <%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %>
<% end %> <%= 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| %>
<set-timezone data-input-id="state_microsoft" data-params="true"></set-timezone>
<%= 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 %>
</div>
<% end %> <% end %>
<%= render 'devise/shared/links' %> <%= render 'devise/shared/links' %>
</div> </div>
File renamed without changes.
File renamed without changes.
File renamed without changes.
+26
View File
@@ -97,5 +97,31 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<% encrypted_config = EncryptedConfig.find_or_initialize_by(account: current_account, key: EncryptedConfig::TIMESTAMP_SERVER_URL_KEY) %>
<% if !Docuseal.multitenant? && can?(:manage, encrypted_config) %>
<div class="flex-grow max-w-xl">
<div class="flex justify-between items-end mb-4 mt-8">
<h2 class="text-3xl font-bold">Timestamp Server</h2>
</div>
<%= form_for encrypted_config, url: timestamp_server_index_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
<div class="form-control">
<%= f.label :value, class: 'label' do %>
<span class="flex items-center space-x-1">
<span>
Timeserver URL
</span>
<span class="tooltip" data-tip="URL of the trusted timeserver to be used to generate timestamp signatures.">
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
</span>
</span>
<% end %>
<%= f.url_field :value, autocomplete: 'off', class: 'base-input', placeholder: 'URL (optional)' %>
</div>
<div class="form-control pt-2">
<%= f.button button_title(title: 'Save', disabled_with: 'Updating'), class: 'base-button' %>
</div>
<% end %>
<% end %>
</div>
</div> </div>
</div> </div>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="24" height="24" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 4m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v0a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z" /><path d="M5 8v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-10" /><path d="M10 12l4 0" /></svg>

After

Width:  |  Height:  |  Size: 449 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" x="0px" y="0px" width="100" height="100" viewBox="0 0 48 48">
<path fill="#ff5722" d="M6 6H22V22H6z" transform="rotate(-180 14 14)"></path><path fill="#4caf50" d="M26 6H42V22H26z" transform="rotate(-180 34 14)"></path><path fill="#ffc107" d="M26 26H42V42H26z" transform="rotate(-180 34 34)"></path><path fill="#03a9f4" d="M6 26H22V42H6z" transform="rotate(-180 14 34)"></path>
</svg>

After

Width:  |  Height:  |  Size: 463 B

+17
View File
@@ -0,0 +1,17 @@
<%= form_for '', url: mfa_setup_path, data: { turbo_frame: :_top }, html: { id: 'mfa_form' } do |f| %>
<p class="text-center">
Use an authenticator mobile app like Google Authenticator or 1Password to scan the QR code below.
</p>
<div>
<%== RQRCode::QRCode.new(@provision_url).as_svg(viewbox: true, svg_attributes: { class: 'w-80 h-80 my-4 mx-auto' }) %>
</div>
<div class="form-control my-6 space-y-2">
<%= f.text_field :otp_attempt, required: true, placeholder: 'XXX-XXX', class: 'base-input text-center' %>
<span>
<%= @error_message %>
</span>
</div>
<div class="form-control mt-4">
<%= f.button button_title(title: 'Save'), class: 'base-button' %>
</div>
<% end %>
+1 -17
View File
@@ -1,19 +1,3 @@
<%= render 'shared/turbo_modal', title: 'Setup 2FA' do %> <%= render 'shared/turbo_modal', title: 'Setup 2FA' do %>
<%= form_for '', url: mfa_setup_path, data: { turbo_frame: :_top } do |f| %> <%= render 'mfa_setup/form' %>
<p class="text-center">
Use an authenticator mobile app like Google Authenticator or 1Password to scan the QR code below.
</p>
<div>
<%== RQRCode::QRCode.new(@provision_url).as_svg(viewbox: true, svg_attributes: { class: 'w-80 h-80 my-4 mx-auto' }) %>
</div>
<div class="form-control my-6 space-y-2">
<%= f.text_field :otp_attempt, required: true, placeholder: 'XXX-XXX', class: 'base-input text-center' %>
<span>
<%= @error_message %>
</span>
</div>
<div class="form-control mt-4">
<%= f.button button_title(title: 'Save'), class: 'base-button' %>
</div>
<% end %>
<% end %> <% end %>
+4
View File
@@ -0,0 +1,4 @@
<div class="max-w-lg mx-auto px-2">
<h1 class="text-4xl font-bold text-center mt-8">Setup 2FA</h1>
<%= render 'mfa_setup/form' %>
</div>
@@ -0,0 +1,15 @@
<script>
if (!window.customElements.get('autosize-field')) {
window.customElements.define('autosize-field', class extends HTMLElement {
connectedCallback() {
if (this.field.scrollHeight > this.field.clientHeight) {
this.field.classList.remove('text-[1.5vw]', 'lg:text-base');
this.field.classList.add('text-[1.0vw]', 'lg:text-[0.70rem]');
}
}
get field() {
return this.closest('field-value');
}
});
}
</script>
+2 -2
View File
@@ -2,13 +2,13 @@
<span class="flex items-center justify-center space-x-2"> <span class="flex items-center justify-center space-x-2">
<%= icon %> <%= icon %>
<% if title %> <% if title %>
<span><%= title %></span> <span class="<%= local_assigns[:title_class] %>"><%= title %></span>
<% end %> <% end %>
</span> </span>
</span> </span>
<span class="disabled"> <span class="disabled">
<span class="flex items-center justify-center space-x-2"> <span class="flex items-center justify-center space-x-2">
<%= local_assigns[:icon_disabled] || svg_icon('loader', class: 'w-5 h-5 animate-spin') %> <%= local_assigns[:icon_disabled] || svg_icon('loader', class: 'w-5 h-5 animate-spin') %>
<span><%= disabled_with %>...</span> <span class="<%= local_assigns[:title_class] %>"><%= disabled_with %>...</span>
</span> </span>
</span> </span>
+5 -3
View File
@@ -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' %> <%= 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' %>
</div> </div>
<% end %> <% end %>
<div> <% 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' %> <div>
</div> <%= 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' %>
</div>
<% end %>
</div> </div>
</div> </div>
<%= render 'shared/attribution', link_path: '/start' %> <%= render 'shared/attribution', link_path: '/start' %>
+2 -1
View File
@@ -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| %> <%= 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| %>
<div class="form-control !mt-0"> <div class="form-control !mt-0">
<%= f.label :email, class: 'label' %> <%= 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 %>
</div> </div>
<div class="form-control"> <div class="form-control">
<%= f.button button_title(title: 'Start', disabled_with: 'Starting'), class: 'base-button' %> <%= f.button button_title(title: 'Start', disabled_with: 'Starting'), class: 'base-button' %>
@@ -9,7 +9,7 @@
</a> </a>
</div> </div>
<div class="grid <%= 'md:grid-cols-2' if template.submitters.size > 1 %> gap-4"> <div class="grid <%= 'md:grid-cols-2' if template.submitters.size > 1 %> gap-4">
<% template.submitters.each do |item| %> <% template.submitters.each_with_index do |item, index| %>
<submitter-item class="form-control"> <submitter-item class="form-control">
<% if template.submitters.size > 1 %> <% if template.submitters.size > 1 %>
<label class="label pt-0 pb-1 text-xs"> <label class="label pt-0 pb-1 text-xs">
@@ -18,11 +18,11 @@
<% end %> <% end %>
<input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>"> <input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>">
<submitters-autocomplete data-field="name"> <submitters-autocomplete data-field="name">
<input type="text" name="submission[1][submitters][][name]" autocomplete="off" class="input input-sm input-bordered w-full" placeholder="Name" required> <%= tag.input type: 'text', name: 'submission[1][submitters][][name]', autocomplete: 'off', class: 'input input-sm input-bordered w-full', placeholder: 'Name', required: index.zero?, value: params[:selfsign] && index.zero? ? current_user.full_name : '' %>
</submitters-autocomplete> </submitters-autocomplete>
<div class="grid <%= 'md:grid-cols-2 gap-1' if template.submitters.size == 1 %>"> <div class="grid <%= 'md:grid-cols-2 gap-1' if template.submitters.size == 1 %>">
<submitters-autocomplete data-field="email"> <submitters-autocomplete data-field="email">
<input type="email" multiple name="submission[1][submitters][][email]" autocomplete="off" class="input input-sm input-bordered mt-1.5 w-full" placeholder="Email (optional)"> <input type="email" multiple name="submission[1][submitters][][email]" autocomplete="off" class="input input-sm input-bordered mt-1.5 w-full" placeholder="Email (optional)" value="<%= params[:selfsign] && index.zero? ? current_user.email : '' %>">
</submitters-autocomplete> </submitters-autocomplete>
<submitters-autocomplete data-field="phone"> <submitters-autocomplete data-field="phone">
<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 mt-1.5 w-full" placeholder="Phone (optional)"> <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 mt-1.5 w-full" placeholder="Phone (optional)">
@@ -34,10 +34,12 @@
</div> </div>
</div> </div>
</div> </div>
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem"> <% if params[:selfsign].blank? %>
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %> <a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
<span>Add New</span> <%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
</a> <span>Add New</span>
</a>
<% end %>
</dynamic-list> </dynamic-list>
<div> <div>
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %> <%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
+8 -6
View File
@@ -18,14 +18,14 @@
</a> </a>
</div> </div>
<div class="grid md:grid-cols-2 gap-4"> <div class="grid md:grid-cols-2 gap-4">
<% template.submitters.each do |item| %> <% template.submitters.each_with_index do |item, index| %>
<submitter-item class="form-control"> <submitter-item class="form-control">
<label class="label pt-0 pb-1 text-xs"> <label class="label pt-0 pb-1 text-xs">
<span class="label-text"> <%= item['name'] %></span> <span class="label-text"> <%= item['name'] %></span>
</label> </label>
<input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>"> <input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>">
<submitters-autocomplete data-field="email"> <submitters-autocomplete data-field="email">
<input type="email" multiple name="submission[1][submitters][][email]" autocomplete="off" class="input input-sm input-bordered w-full" placeholder="Email" required> <%= 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 : '' %>
</submitters-autocomplete> </submitters-autocomplete>
</submitter-item> </submitter-item>
<% end %> <% end %>
@@ -33,10 +33,12 @@
</div> </div>
</div> </div>
</div> </div>
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem"> <% if params[:selfsign].blank? %>
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %> <a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
<span>Add New</span> <%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
</a> <span>Add New</span>
</a>
<% end %>
</dynamic-list> </dynamic-list>
<% end %> <% end %>
<div> <div>
+1
View File
@@ -0,0 +1 @@
<%= render 'shared/logo', width: 40, height: 40 %>
+10 -8
View File
@@ -9,7 +9,7 @@
</a> </a>
</div> </div>
<div class="grid <%= template.submitters.size > 1 ? 'md:grid-cols-2 gap-4' : 'gap-1' %>"> <div class="grid <%= template.submitters.size > 1 ? 'md:grid-cols-2 gap-4' : 'gap-1' %>">
<% template.submitters.each do |item| %> <% template.submitters.each_with_index do |item, index| %>
<submitter-item class="grid <%= template.submitters.size > 1 ? 'gap-4' : 'md:grid-cols-2 gap-1' %>"> <submitter-item class="grid <%= template.submitters.size > 1 ? 'gap-4' : 'md:grid-cols-2 gap-1' %>">
<div class="form-control"> <div class="form-control">
<% if template.submitters.size > 1 %> <% if template.submitters.size > 1 %>
@@ -19,18 +19,18 @@
<% end %> <% end %>
<input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>"> <input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>">
<submitters-autocomplete data-field="phone"> <submitters-autocomplete data-field="phone">
<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> <%= 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? %>
</submitters-autocomplete> </submitters-autocomplete>
<% if template.submitters.size > 1 %> <% if template.submitters.size > 1 %>
<submitters-autocomplete data-field="name"> <submitters-autocomplete data-field="name">
<input type="text" name="submission[1][submitters][][name]" autocomplete="off" class="input input-sm input-bordered mt-1.5 w-full" placeholder="Name (optional)"> <input type="text" name="submission[1][submitters][][name]" autocomplete="off" class="input input-sm input-bordered mt-1.5 w-full" placeholder="Name (optional)" value="<%= params[:selfsign] && index.zero? ? current_user.full_name : '' %>">
</submitters-autocomplete> </submitters-autocomplete>
<% end %> <% end %>
</div> </div>
<% if template.submitters.size == 1 %> <% if template.submitters.size == 1 %>
<div class="form-control flex"> <div class="form-control flex">
<submitters-autocomplete data-field="name"> <submitters-autocomplete data-field="name">
<input type="text" name="submission[1][submitters][][name]" autocomplete="off" class="input input-sm input-bordered w-full" placeholder="Name (optional)"> <input type="text" name="submission[1][submitters][][name]" autocomplete="off" class="input input-sm input-bordered w-full" placeholder="Name (optional)" value="<%= params[:selfsign] && index.zero? ? current_user.full_name : '' %>">
</submitters-autocomplete> </submitters-autocomplete>
</div> </div>
<% end %> <% end %>
@@ -40,10 +40,12 @@
</div> </div>
</div> </div>
</div> </div>
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem"> <% if params[:selfsign].blank? %>
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %> <a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
<span>Add New</span> <%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
</a> <span>Add New</span>
</a>
<% end %>
</dynamic-list> </dynamic-list>
<div> <div>
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %> <%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
@@ -2,8 +2,8 @@
<div class="form-control"> <div class="form-control">
<%= f.label :preserve_order, for: uuid = SecureRandom.uuid, class: 'flex items-center cursor-pointer' do %> <%= f.label :preserve_order, for: uuid = SecureRandom.uuid, class: 'flex items-center cursor-pointer' do %>
<%= f.check_box :preserve_order, id: uuid, class: 'base-checkbox', checked: template.submissions.last&.submitters_order.in?(['preserved', nil]) %> <%= f.check_box :preserve_order, id: uuid, class: 'base-checkbox', checked: template.submissions.last&.submitters_order.in?(['preserved', nil]) %>
<span class="label">Preserve submitters order</span> <span class="label">Preserve order</span>
<span class="tooltip" data-tip="When checked, notifications will be sent to the second submitter once the form is completed by the previous submitter. Uncheck this option to send notifications to all submitters simultaneously right away."> <span class="tooltip" data-tip="When checked, notifications will be sent to the second party once the form is completed by the previous party. Uncheck this option to send notifications to all parties simultaneously right away.">
<%= svg_icon('info_circle', class: 'w-4 h-4') %> <%= svg_icon('info_circle', class: 'w-4 h-4') %>
</span> </span>
<% end %> <% end %>
+5 -2
View File
@@ -1,7 +1,8 @@
<div class="flex absolute <%= field['readonly'] ? 'text-[1.5vw] lg:text-xs' : 'text-[1.5vw] lg:text-base' %>" style="width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%"> <field-value class="flex absolute text-[1.5vw] lg:text-base" style="width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%">
<% if field['type'].in?(['signature', 'image', 'initials']) %> <% if field['type'].in?(['signature', 'image', 'initials']) %>
<img class="object-contain mx-auto" src="<%= attachments_index[value].url %>" loading="lazy"> <img class="object-contain mx-auto" src="<%= attachments_index[value].url %>" loading="lazy">
<% elsif field['type'] == 'file' %> <% elsif field['type'] == 'file' %>
<autosize-field></autosize-field>
<div class="px-0.5 flex flex-col justify-center"> <div class="px-0.5 flex flex-col justify-center">
<% Array.wrap(value).each do |val| %> <% Array.wrap(value).each do |val| %>
<a target="_blank" href="<%= attachments_index[val].url %>"> <a target="_blank" href="<%= attachments_index[val].url %>">
@@ -31,10 +32,12 @@
<% end %> <% end %>
</div> </div>
<% elsif field['type'] == 'date' %> <% elsif field['type'] == 'date' %>
<autosize-field></autosize-field>
<div class="flex items-center px-0.5"> <div class="flex items-center px-0.5">
<%= l(Date.parse(value), format: :long, locale: local_assigns[:locale]) %> <%= l(Date.parse(value), format: :long, locale: local_assigns[:locale]) %>
</div> </div>
<% else %> <% else %>
<autosize-field></autosize-field>
<div class="flex items-center px-0.5 whitespace-pre-wrap"><%= Array.wrap(value).join(', ') %></div> <div class="flex items-center px-0.5 whitespace-pre-wrap"><%= Array.wrap(value).join(', ') %></div>
<% end %> <% end %>
</div> </field-value>
+1 -1
View File
@@ -1,4 +1,4 @@
<%= render 'shared/turbo_modal', title: 'Add New Recipients' do %> <%= render 'shared/turbo_modal', title: params[:selfsign] ? 'Add Recipients' : 'Add New Recipients' do %>
<% options = [['via Email', 'email'], ['via Phone', 'phone'], ['Detailed', 'detailed']] %> <% options = [['via Email', 'email'], ['via Phone', 'phone'], ['Detailed', 'detailed']] %>
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="relative text-center mt-4 block"> <toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="relative text-center mt-4 block">
<div class="join"> <div class="join">
+10 -7
View File
@@ -1,25 +1,27 @@
<div style="max-width: 1600px" class="mx-auto pl-4"> <div style="max-width: 1600px" class="mx-auto pl-4">
<div class="flex justify-between py-1.5 items-center pr-4 sticky top-0 md:relative z-10 bg-base-100"> <div class="flex justify-between py-1.5 items-center pr-4 sticky top-0 md:relative z-10 bg-base-100">
<a href="<%= template_path(@submission.template) %>" class="flex space-x-3 py-1"> <a href="<%= signed_in? && @submission.template.account_id == current_account&.id ? template_path(@submission.template) : '/' %>" class="flex items-center space-x-3 py-1">
<span><%= render 'shared/logo', width: 40, height: 40 %></span> <span><%= render 'submissions/logo' %></span>
<span class="text-3xl font-semibold focus:text-clip"><%= @submission.template.name %></span> <span class="text-xl md:text-3xl font-semibold focus:text-clip" style="overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;"><%= @submission.template.name %></span>
</a> </a>
<div class="space-x-3 flex items-center"> <div class="space-x-3 flex items-center">
<% if @submission.audit_trail.present? %> <% if @submission.audit_trail.present? %>
<a href="<%= rails_blob_path(@submission.audit_trail) %>" class="white-button" target="_blank"> <a href="<%= rails_blob_path(@submission.audit_trail) %>" class="white-button" target="_blank">
<%= svg_icon('external_link', class: 'w-6 h-6') %> <%= svg_icon('external_link', class: 'w-6 h-6') %>
Audit Log <span class="hidden md:inline">
Audit Log
</span>
</a> </a>
<% end %> <% end %>
<% if last_submitter = @submission.submitters.to_a.select(&:completed_at?).max_by(&:completed_at) %> <% if last_submitter = @submission.submitters.to_a.select(&:completed_at?).max_by(&:completed_at) %>
<download-button data-src="<%= submitter_download_index_path(last_submitter.slug) %>" class="base-button"> <download-button data-src="<%= submitter_download_index_path(last_submitter.slug) %>" class="base-button">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton"> <span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6') %> <%= svg_icon('download', class: 'w-6 h-6') %>
<span>Download</span> <span class="hidden md:inline">Download</span>
</span> </span>
<span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton"> <span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-6 h-6 animate-spin') %> <%= svg_icon('loader', class: 'w-6 h-6 animate-spin') %>
<span>Downloading</span> <span class="hidden md:inline">Downloading</span>
</span> </span>
</download-button> </download-button>
<% elsif @submission.submitters.to_a.size == 1 %> <% elsif @submission.submitters.to_a.size == 1 %>
@@ -53,7 +55,7 @@
<% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %> <% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %>
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.uuid, "#{index}.jpg")) %> <% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.uuid, "#{index}.jpg")) %>
<div id="<%= "page-#{document.uuid}-#{index}" %>" class="relative"> <div id="<%= "page-#{document.uuid}-#{index}" %>" class="relative">
<img loading="lazy" src="<%= page.url %>" width="<%= page.metadata['width'] %>" class="shadow-md mb-4" height="<%= page.metadata['height'] %>"> <img loading="lazy" src="<%= page.url %>" width="<%= page.metadata['width'] %>" class="border rounded mb-4" height="<%= page.metadata['height'] %>">
<div class="top-0 bottom-0 left-0 right-0 absolute"> <div class="top-0 bottom-0 left-0 right-0 absolute">
<% document_annots_index[index]&.each do |annot| %> <% document_annots_index[index]&.each do |annot| %>
<%= render 'submissions/annotation', annot: %> <%= render 'submissions/annotation', annot: %>
@@ -172,3 +174,4 @@
</div> </div>
</div> </div>
</div> </div>
<%= render 'scripts/autosize_field' %>
@@ -1,4 +1,4 @@
<% data_attachments = attachments_index.values.select { |e| e.record_id == submitter.id }.to_json(only: %i[uuid], methods: %i[url filename content_type]) %> <% data_attachments = attachments_index.values.select { |e| e.record_id == submitter.id }.to_json(only: %i[uuid], methods: %i[url filename content_type]) %>
<% data_fields = (submitter.submission.template_fields || submitter.submission.template.fields).select { |f| f['submitter_uuid'] == submitter.uuid }.to_json %> <% data_fields = (submitter.submission.template_fields || submitter.submission.template.fields).select { |f| f['submitter_uuid'] == submitter.uuid }.to_json %>
<% completed_button_params = submitter.submission.template.account.account_configs.find_by(key: AccountConfig::FORM_COMPLETED_BUTTON_KEY)&.value || {} %> <% configs = Submitters::FormConfigs.call(submitter) %>
<submission-form data-is-demo="<%= Docuseal.demo? %>" data-completed-button="<%= completed_button_params.to_json %>" data-go-to-last="<%= submitter.opened_at? %>" data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(Struct.new(:id).new(@submitter.submission.template.account_id)) %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-authenticity-token="<%= form_authenticity_token %>" data-values="<%= submitter.values.to_json %>"></submission-form> <submission-form data-is-demo="<%= Docuseal.demo? %>" data-completed-button="<%= configs[:completed_button].to_json %>" data-go-to-last="<%= submitter.opened_at? %>" data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(Struct.new(:id).new(@submitter.submission.template.account_id)) %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-authenticity-token="<%= form_authenticity_token %>" data-values="<%= submitter.values.to_json %>" data-with-typed-signature="<%= configs[:with_typed_signature] %>"></submission-form>
+1
View File
@@ -41,3 +41,4 @@
</div> </div>
</div> </div>
</div> </div>
<%= render 'scripts/autosize_field' %>
+24 -2
View File
@@ -35,7 +35,18 @@
</button> </button>
</form> </form>
<% else %> <% else %>
<%= render 'shared/clipboard_copy', text: submit_form_url(slug: submitter.slug), class: 'btn btn-sm btn-neutral text-white md:w-36 flex', icon_class: 'w-6 h-6 text-white', copy_title: 'Copy Link', copy_title_md: 'Copy', copied_title_md: 'Copied' %> <% if current_user.email == submitter.email %>
<form data-turbo="false" method="get" action="<%= submit_form_url(slug: submitter.slug) %>">
<button onclick="event.stopPropagation()" class="btn btn-sm btn-neutral btn-outline bg-white md:w-36 flex">
<span class="flex items-center justify-center space-x-2">
<%= svg_icon('writing_sign', class: 'w-4 h-4 stroke-2') %>
<span class="hidden md:inline">Sign now</span>
</span>
</button>
</form>
<% else %>
<%= render 'shared/clipboard_copy', text: submit_form_url(slug: submitter.slug), class: 'btn btn-sm btn-neutral text-white md:w-36 flex', icon_class: 'w-6 h-6 text-white', copy_title: 'Copy Link', copy_title_md: 'Copy', copied_title_md: 'Copied' %>
<% end %>
<% end %> <% end %>
<span class="btn btn-outline btn-sm w-20 md:w-24">View</span> <span class="btn btn-outline btn-sm w-20 md:w-24">View</span>
<% if !submission.deleted_at? && can?(:destroy, submission) %> <% if !submission.deleted_at? && can?(:destroy, submission) %>
@@ -85,7 +96,18 @@
</form> </form>
<% elsif !is_submission_completed %> <% elsif !is_submission_completed %>
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<%= render 'shared/clipboard_copy', text: submit_form_url(slug: submitter.slug), class: 'btn btn-xs text-xs btn-neutral text-white md:w-36 flex', icon_class: 'w-4 h-4 text-white', copy_title: 'Copy Link' %> <% if current_user.email == submitter.email %>
<form data-turbo="false" method="get" action="<%= submit_form_url(slug: submitter.slug) %>">
<button onclick="event.stopPropagation()" class="btn btn-xs btn-outline btn-neutral bg-white md:w-36">
<span class="flex items-center justify-center space-x-2">
<%= svg_icon('writing_sign', class: 'w-4 h-4 stroke-2') %>
<span class="hidden md:inline">Sign now</span>
</span>
</button>
</form>
<% else %>
<%= render 'shared/clipboard_copy', text: submit_form_url(slug: submitter.slug), class: 'btn btn-xs text-xs btn-neutral text-white md:w-36 flex', icon_class: 'w-4 h-4 text-white', copy_title: 'Copy Link' %>
<% end %>
</div> </div>
<% end %> <% end %>
</div> </div>
+4 -4
View File
@@ -52,10 +52,10 @@
</a> </a>
</span> </span>
<% end %> <% end %>
<% if (!template.deleted_at? || !Docuseal.multitenant?) && can?(:destroy, template) %> <% if can?(:destroy, template) %>
<span class="tooltip tooltip-left" data-tip="<%= template.deleted_at? ? 'Remove' : 'Archive' %>"> <span class="tooltip tooltip-left" data-tip="<%= template.deleted_at? ? 'Delete' : 'Archive' %>">
<%= 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 %> <%= 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('trash', class: 'w-4 h-4 enabled') %> <%= svg_icon(template.deleted_at? ? 'trash' : 'archive', class: 'w-4 h-4 enabled') %>
<%= svg_icon('loader', class: 'w-4 h-4 animate-spin disabled') %> <%= svg_icon('loader', class: 'w-4 h-4 animate-spin disabled') %>
<% end %> <% end %>
</span> </span>
+6 -3
View File
@@ -14,7 +14,7 @@
</span> </span>
</a> </a>
<% if can?(:update, template) %> <% if can?(:update, template) %>
<span class="pl-1 tooltip tooltip-right opacity-0 hover:opacity-100 peer-hover:opacity-100" data-tip="Move"> <span class="pl-1 tooltip tooltip-right md:opacity-0 hover:opacity-100 peer-hover:opacity-100" data-tip="Move">
<a href="<%= edit_template_folder_path(template.id) %>" data-turbo-frame="modal"> <a href="<%= edit_template_folder_path(template.id) %>" data-turbo-frame="modal">
<%= svg_icon('pencil_share', class: 'w-5 h-5') %> <%= svg_icon('pencil_share', class: 'w-5 h-5') %>
</a> </a>
@@ -23,13 +23,16 @@
</div> </div>
</div> </div>
<div class="flex md:justify-between space-x-2 flex-none pt-1"> <div class="flex md:justify-between space-x-2 flex-none pt-1">
<% 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) %> <% 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 %> <% end %>
<% if can?(:create, template) %> <% 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 %> <%= 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') %> <%= svg_icon('copy', class: 'w-6 h-6') %>
<span>Clone</span> <span class="hidden md:inline">Clone</span>
<% end %> <% end %>
<% end %> <% end %>
<% if !template.deleted_at? && can?(:update, template) %> <% if !template.deleted_at? && can?(:update, template) %>
+1 -1
View File
@@ -4,7 +4,7 @@
<%= hidden_field_tag :base_template_id, @base_template.id %> <%= hidden_field_tag :base_template_id, @base_template.id %>
<% end %> <% end %>
<div class="form-control mt-6"> <div class="form-control mt-6">
<%= 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' %>
</div> </div>
<div class="mt-3 mb-4 flex items-center justify-between"> <div class="mt-3 mb-4 flex items-center justify-between">
<a href="#" onclick="[event.preventDefault(), window.folder_name.focus()]"> <a href="#" onclick="[event.preventDefault(), window.folder_name.focus()]">
+15 -13
View File
@@ -1,23 +1,18 @@
<%= render 'title', template: @template %> <%= render 'title', template: @template %>
<% is_show_tabs = @pagy.pages > 1 || params[:status].present? %> <% 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? %>
<div class="flex justify-between md:items-end flex-col md:flex-row <%= is_show_tabs ? 'mb-4' : 'mb-6' %>"> <div class="flex justify-between md:items-end flex-col md:flex-row <%= is_show_tabs ? 'mb-4' : 'mb-6' %>">
<p class="text-3xl font-bold">Submissions</p> <p class="text-3xl font-bold">Submissions</p>
<div class="flex md:space-x-2 mt-3 md:mt-0"> <div class="flex justify-end space-x-2 mt-3 md:mt-0">
<% if params[:q].present? || params[:status].present? || @pagy.pages > 1 %> <% if params[:q].present? || params[:status].present? || @pagy.pages > 1 %>
<%= render 'shared/search_input' %> <%= render 'shared/search_input' %>
<% end %> <% 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') %> <%= svg_icon('download', class: 'w-6 h-6 stroke-2') %>
<span>Export</span> <span>Export</span>
<% end %> <% end %>
<% if @template.submitters.to_a.size == 1 && !@template.deleted_at? %>
<span class="order-1">
<%= 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' %>
</span>
<% end %>
<% if !@template.deleted_at? && can?(:create, Submission) %> <% 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') %> <%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %>
<span>Add <span class="hidden md:inline">Recipients</span></span> <span>Add <span class="hidden md:inline">Recipients</span></span>
<% end %> <% end %>
@@ -85,12 +80,19 @@
<% if can?(:create, Submission) %> <% if can?(:create, Submission) %>
<%= link_to new_template_submission_path(@template), class: 'base-button mt-6', data: { turbo_frame: 'modal' } do %> <%= 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') %> <%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %>
<span class="mr-1">Add Recipients</span> <span class="mr-1">Send to Recipients</span>
<% end %> <% end %>
<% end %> <% end %>
<%= link_to start_form_url(slug: @template.slug), class: 'white-button mt-6', target: '_blank', rel: 'noopener' do %> <% if @template.submitters.size == 1 %>
<%= svg_icon('writing', class: 'w-6 h-6') %> <%= link_to start_form_url(slug: @template.slug), class: 'white-button mt-6', target: '_blank', rel: 'noopener' do %>
<span class="mr-1">Sign it Yourself</span> <%= svg_icon('writing', class: 'w-6 h-6') %>
<span class="mr-1">Sign it Yourself</span>
<% 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') %>
<span class="mr-1">Sign it Yourself</span>
<% end %>
<% end %> <% end %>
<% end %> <% end %>
</div> </div>
+7 -5
View File
@@ -3,12 +3,14 @@
<div class="md:flex-grow"> <div class="md:flex-grow">
<div class="flex justify-between mb-4"> <div class="flex justify-between mb-4">
<h1 class="text-4xl font-bold">Team</h1> <h1 class="text-4xl font-bold">Team</h1>
<% if can?(:create, User.new(account: current_account)) %> <div class="flex items-center space-x-4">
<%= link_to new_user_path, class: 'btn btn-primary btn-md gap-2', data: { turbo_frame: 'modal' } do %> <% if can?(:create, User.new(account: current_account)) %>
<%= svg_icon('plus', class: 'w-6 h-6') %> <%= link_to new_user_path, class: 'btn btn-primary btn-md gap-2', data: { turbo_frame: 'modal' } do %>
<span>New User</span> <%= svg_icon('plus', class: 'w-6 h-6') %>
<span>New User</span>
<% end %>
<% end %> <% end %>
<% end %> </div>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="table w-full table-lg rounded-b-none overflow-hidden"> <table class="table w-full table-lg rounded-b-none overflow-hidden">
+1 -1
View File
@@ -7,7 +7,7 @@
<%= form_for @encrypted_config, url: settings_webhooks_path, method: :post, html: { autocomplete: 'off' } do |f| %> <%= 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.label :value, 'Webhook URL', class: 'text-sm font-semibold' %>
<div class="flex flex-row flex-wrap space-y-2 md:space-y-0 md:flex-nowrap md:space-x-4 mt-2"> <div class="flex flex-row flex-wrap space-y-2 md:space-y-0 md:flex-nowrap md:space-x-4 mt-2">
<%= 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' %> <%= f.button button_title(title: 'Save', disabled_with: 'Saving'), class: 'base-button w-full md:w-32' %>
</div> </div>
<% end %> <% end %>
+2
View File
@@ -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.available_locales = %i[en en-US en-GB es-ES fr-FR pt-PT de-DE]
config.i18n.fallbacks = [:en] config.i18n.fallbacks = [:en]
config.exceptions_app = ->(env) { ErrorsController.action(:show).call(env) }
config.action_view.frozen_string_literal = true config.action_view.frozen_string_literal = true
config.middleware.insert_before ActionDispatch::Static, Rack::Deflater config.middleware.insert_before ActionDispatch::Static, Rack::Deflater
+14
View File
@@ -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) ENV['DATABASE_URL'] = ENV['DATABASE_URL'].to_s.empty? ? database_url : ENV.fetch('DATABASE_URL', nil)
end 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 'bundler/setup' # Set up gems listed in the Gemfile.
require 'bootsnap/setup' # Speed up boot time by caching expensive operations. require 'bootsnap/setup' # Speed up boot time by caching expensive operations.
+8 -1
View File
@@ -12,7 +12,14 @@ test:
database: docuseal_test database: docuseal_test
production: 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 adapter: sqlite3
database: <%= ENV['WORKDIR'] || '.' %>/db.sqlite3 database: <%= ENV['WORKDIR'] || '.' %>/db.sqlite3
<% elsif ENV['DATABASE_URL'].match?(/\Apostgres/) %> <% elsif ENV['DATABASE_URL'].match?(/\Apostgres/) %>
+8
View File
@@ -14,6 +14,7 @@ Devise.otp_allowed_drift = 60.seconds
# #
# Use this hook to configure devise mailer, warden hooks and so forth. # Use this hook to configure devise mailer, warden hooks and so forth.
# Many of these configuration options can be set straight in your model. # Many of these configuration options can be set straight in your model.
# rubocop:disable Metrics/BlockLength
Devise.setup do |config| Devise.setup do |config|
config.warden do |manager| config.warden do |manager|
manager.default_strategies(scope: :user).unshift(:two_factor_authenticatable) manager.default_strategies(scope: :user).unshift(:two_factor_authenticatable)
@@ -277,6 +278,12 @@ Devise.setup do |config|
# Add a new OmniAuth provider. Check the wiki for more information on setting # Add a new OmniAuth provider. Check the wiki for more information on setting
# up on your models and hooks. # up on your models and hooks.
config.omniauth :google_oauth2, ENV.fetch('GOOGLE_CLIENT_ID', nil), ENV.fetch('GOOGLE_CLIENT_SECRET', nil), {} config.omniauth :google_oauth2, ENV.fetch('GOOGLE_CLIENT_ID', nil), ENV.fetch('GOOGLE_CLIENT_SECRET', nil), {}
config.omniauth :microsoft_office365, ENV.fetch('OFFICE365_CLIENT_ID', nil),
ENV.fetch('OFFICE365_CLIENT_SECRET', nil), {}
if ENV['GITHUB_CLIENT_ID']
config.omniauth :github, ENV.fetch('GITHUB_CLIENT_ID', nil), ENV.fetch('GITHUB_CLIENT_SECRET', nil), {}
end
# ==> Warden configuration # ==> Warden configuration
# If you want to use other strategies, that are not supported by Devise, or # If you want to use other strategies, that are not supported by Devise, or
@@ -316,3 +323,4 @@ Devise.setup do |config|
# changed. Defaults to true, so a user is signed in automatically after changing a password. # changed. Defaults to true, so a user is signed in automatically after changing a password.
# config.sign_in_after_change_password = true # config.sign_in_after_change_password = true
end end
# rubocop:enable Metrics/BlockLength
+14
View File
@@ -0,0 +1,14 @@
# frozen_string_literal: true
# fix NoMethodError: undefined method `field_value' for #<HexaPDF::Type::AcroForm::Field
module HexaPDF
module Type
module AcroForm
class Field
def field_value
''
end
end
end
end
end
+4 -1
View File
@@ -42,13 +42,16 @@ Rails.application.routes.draw do
end end
end end
resources :templates, only: %i[update show index destroy] do resources :templates, only: %i[update show index destroy] do
resources :clone, only: %i[create], controller: 'templates_clone'
resources :submissions, only: %i[index create] resources :submissions, only: %i[index create]
resources :documents, only: %i[create], controller: 'templates_documents' resources :documents, only: %i[create], controller: 'templates_documents'
end end
end end
resources :verify_pdf_signature, only: %i[create] resources :verify_pdf_signature, only: %i[create]
resource :mfa_setup, only: %i[new edit create destroy], controller: 'mfa_setup' resource :mfa_setup, only: %i[show new edit create destroy], controller: 'mfa_setup'
resources :account_configs, only: %i[create]
resources :timestamp_server, only: %i[create]
resources :dashboard, only: %i[index] resources :dashboard, only: %i[index]
resources :setup, only: %i[index create] resources :setup, only: %i[index create]
resource :newsletter, only: %i[show update] resource :newsletter, only: %i[show update]
+1 -1
View File
@@ -15,7 +15,7 @@ aws_s3:
google: google:
service: GCS service: GCS
credentials: <%= JSON.parse(ENV['GCS_CREDENTIALS'] || '{}') %> credentials: <%= ENV['GCS_CREDENTIALS'] || '{}' %>
project: <%= ENV['GCS_PROJECT'] %> project: <%= ENV['GCS_PROJECT'] %>
bucket: <%= ENV['GCS_BUCKET'] %> bucket: <%= ENV['GCS_BUCKET'] %>
public: <%= ENV['ACTIVE_STORAGE_PUBLIC'] == 'true' %> public: <%= ENV['ACTIVE_STORAGE_PUBLIC'] == 'true' %>
@@ -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
@@ -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
@@ -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
+19 -1
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2023_11_19_222105) 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 # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@@ -81,6 +81,20 @@ ActiveRecord::Schema[7.0].define(version: 2023_11_19_222105) do
t.index ["submitter_id"], name: "index_document_generation_events_on_submitter_id" t.index ["submitter_id"], name: "index_document_generation_events_on_submitter_id"
end 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| create_table "encrypted_configs", force: :cascade do |t|
t.bigint "account_id", null: false t.bigint "account_id", null: false
t.string "key", null: false t.string "key", null: false
@@ -125,6 +139,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_11_19_222105) do
t.text "source", null: false t.text "source", null: false
t.string "submitters_order", null: false t.string "submitters_order", null: false
t.string "slug", 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 ["created_by_user_id"], name: "index_submissions_on_created_by_user_id"
t.index ["slug"], name: "index_submissions_on_slug", unique: true t.index ["slug"], name: "index_submissions_on_slug", unique: true
t.index ["template_id"], name: "index_submissions_on_template_id" t.index ["template_id"], name: "index_submissions_on_template_id"
@@ -146,6 +161,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_11_19_222105) do
t.string "name" t.string "name"
t.string "phone" t.string "phone"
t.string "application_key" t.string "application_key"
t.text "preferences", null: false
t.index ["email"], name: "index_submitters_on_email" t.index ["email"], name: "index_submitters_on_email"
t.index ["slug"], name: "index_submitters_on_slug", unique: true t.index ["slug"], name: "index_submitters_on_slug", unique: true
t.index ["submission_id"], name: "index_submitters_on_submission_id" t.index ["submission_id"], name: "index_submitters_on_submission_id"
@@ -229,6 +245,8 @@ ActiveRecord::Schema[7.0].define(version: 2023_11_19_222105) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" 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 "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "document_generation_events", "submitters" 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_configs", "accounts"
add_foreign_key "encrypted_user_configs", "users" add_foreign_key "encrypted_user_configs", "users"
add_foreign_key "submission_events", "submissions" add_foreign_key "submission_events", "submissions"
+15
View File
@@ -71,6 +71,21 @@ module Accounts
end end
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) def can_send_emails?(_account)
return true if Docuseal.multitenant? return true if Docuseal.multitenant?
return true if ENV['SMTP_ADDRESS'].present? return true if ENV['SMTP_ADDRESS'].present?
+1
View File
@@ -28,6 +28,7 @@ module Docuseal
end end
CERTS = JSON.parse(ENV.fetch('CERTS', '{}')) CERTS = JSON.parse(ENV.fetch('CERTS', '{}'))
TIMESERVER_URL = ENV.fetch('TIMESERVER_URL', nil)
DEFAULT_URL_OPTIONS = { DEFAULT_URL_OPTIONS = {
host: HOST, host: HOST,
+17
View File
@@ -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
+1
View File
@@ -7,6 +7,7 @@ module SubmissionEvents
send_email: 'Email sent', send_email: 'Email sent',
send_reminder_email: 'Reminder email sent', send_reminder_email: 'Reminder email sent',
send_sms: 'SMS sent', send_sms: 'SMS sent',
send_2fa_sms: 'Verification SMS sent',
open_email: 'Email opened', open_email: 'Email opened',
click_email: 'Email link clicked', click_email: 'Email link clicked',
click_sms: 'SMS link clicked', click_sms: 'SMS link clicked',
+13 -7
View File
@@ -27,11 +27,17 @@ module Submissions
submission.save! submission.save!
end 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| 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), submission.submitters.new(email: normalize_email(email),
uuid: template.submitters.first['uuid'], uuid: template.submitters.first['uuid'],
preferences:,
sent_at: mark_as_sent ? Time.current : nil) sent_at: mark_as_sent ? Time.current : nil)
submission.tap(&:save!) submission.tap(&:save!)
@@ -45,13 +51,13 @@ module Submissions
end end
def create_from_submitters(template:, user:, submissions_attrs:, source:, mark_as_sent: false, 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( 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 end
def send_signature_requests(submissions, params) def send_signature_requests(submissions)
submissions.each do |submission| submissions.each do |submission|
submitters = submission.submitters.reject(&:completed_at?) submitters = submission.submitters.reject(&:completed_at?)
@@ -59,9 +65,9 @@ module Submissions
first_submitter = first_submitter =
submission.template_submitters.filter_map { |s| submitters.find { |e| e.uuid == s['uuid'] } }.first 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 else
Submitters.send_signature_requests(submitters, params) Submitters.send_signature_requests(submitters)
end end
end end
end end
+20 -4
View File
@@ -4,10 +4,19 @@ module Submissions
module CreateFromSubmitters module CreateFromSubmitters
module_function 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| 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:, 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]) maybe_set_template_fields(submission, attrs[:submitters])
@@ -15,10 +24,15 @@ module Submissions
uuid = find_submitter_uuid(template, submitter_attrs, index) uuid = find_submitter_uuid(template, submitter_attrs, index)
next if uuid.blank? 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? 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 end
submission.tap(&:save!) submission.tap(&:save!)
@@ -85,8 +99,9 @@ module Submissions
template.submitters[index]&.dig('uuid') template.submitters[index]&.dig('uuid')
end 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]) email = Submissions.normalize_email(attrs[:email])
submitter_preferences = Submitters.normalize_preferences(submission.account, user, attrs)
submission.submitters.new( submission.submitters.new(
email:, email:,
@@ -96,6 +111,7 @@ module Submissions
completed_at: attrs[:completed] ? Time.current : nil, completed_at: attrs[:completed] ? Time.current : nil,
sent_at: mark_as_sent && email.present? && is_order_sent ? Time.current : nil, sent_at: mark_as_sent && email.present? && is_order_sent ? Time.current : nil,
values: attrs[:values] || {}, values: attrs[:values] || {},
preferences: preferences.merge(submitter_preferences),
uuid: uuid:
) )
end end
+18 -15
View File
@@ -19,11 +19,7 @@ module Submissions
INFO_CREATOR = "#{Docuseal.product_name} (#{Docuseal::PRODUCT_URL})".freeze INFO_CREATOR = "#{Docuseal.product_name} (#{Docuseal::PRODUCT_URL})".freeze
SIGN_REASON = 'Signed with DocuSeal.co' SIGN_REASON = 'Signed with DocuSeal.co'
VERIFIED_TEXT = if Docuseal.multitenant? VERIFIED_TEXT = 'Verified'
'Verified by DocuSeal'
else
'Verified'
end
UNVERIFIED_TEXT = 'Unverified' UNVERIFIED_TEXT = 'Unverified'
module_function module_function
@@ -32,6 +28,7 @@ module Submissions
def call(submission) def call(submission)
account = submission.template.account account = submission.template.account
pkcs = Accounts.load_signing_pkcs(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) verify_url = Rails.application.routes.url_helpers.settings_esign_url(**Docuseal.default_url_options)
composer = HexaPDF::Composer.new(skip_page_creation: true) composer = HexaPDF::Composer.new(skip_page_creation: true)
@@ -63,7 +60,7 @@ module Submissions
composer.new_page composer.new_page
composer.column(columns: 1) do |column| composer.column(columns: 1) do |column|
add_logo(column) add_logo(column, submission)
column.text('Audit Log', column.text('Audit Log',
font_size: 16, font_size: 16,
@@ -151,11 +148,11 @@ module Submissions
[ [
composer.document.layout.formatted_text_box( composer.document.layout.formatted_text_box(
[ [
submitter.email && { submitter.email && click_email_event && {
text: "Email verification: #{click_email_event ? VERIFIED_TEXT : UNVERIFIED_TEXT}\n" text: "Email verification: #{VERIFIED_TEXT}\n"
}, },
submitter.phone && { submitter.phone && is_phone_verified && {
text: "Phone verification: #{is_phone_verified ? VERIFIED_TEXT : UNVERIFIED_TEXT}\n" text: "Phone verification: #{VERIFIED_TEXT}\n"
}, },
completed_event.data['ip'] && { text: "IP: #{completed_event.data['ip']}\n" }, completed_event.data['ip'] && { text: "IP: #{completed_event.data['ip']}\n" },
completed_event.data['sid'] && { text: "Session ID: #{completed_event.data['sid']}\n" }, completed_event.data['sid'] && { text: "Session ID: #{completed_event.data['sid']}\n" },
@@ -254,10 +251,16 @@ module Submissions
composer.document.trailer.info[:Creator] = INFO_CREATOR composer.document.trailer.info[:Creator] = INFO_CREATOR
composer.document.sign(io, reason: SIGN_REASON, sign_params = {
certificate: pkcs.certificate, reason: SIGN_REASON,
key: pkcs.key, certificate: pkcs.certificate,
certificate_chain: pkcs.ca_certs || []) 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!( ActiveStorage::Attachment.create!(
blob: ActiveStorage::Blob.create_and_upload!( blob: ActiveStorage::Blob.create_and_upload!(
@@ -268,7 +271,7 @@ module Submissions
) )
end end
def add_logo(column) def add_logo(column, _submission = nil)
column.image(PdfIcons.logo_io, width: 40, height: 40, position: :float) column.image(PdfIcons.logo_io, width: 40, height: 40, position: :float)
column.formatted_text([{ text: 'DocuSeal', column.formatted_text([{ text: 'DocuSeal',
+19 -8
View File
@@ -29,6 +29,7 @@ module Submissions
account = submitter.submission.template.account account = submitter.submission.template.account
pkcs = Accounts.load_signing_pkcs(account) pkcs = Accounts.load_signing_pkcs(account)
tsa_url = Accounts.load_timeserver_url(account)
pdfs_index = build_pdfs_index(submitter) pdfs_index = build_pdfs_index(submitter)
@@ -42,7 +43,9 @@ module Submissions
page.rotate(0, flatten: true) if page[:Rotate] != 0 page.rotate(0, flatten: true) if page[:Rotate] != 0
page[:Annots] ||= [] 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 width = page.box.width
height = page.box.height height = page.box.height
@@ -66,7 +69,7 @@ module Submissions
scale = [(area['w'] * width) / image.width, scale = [(area['w'] * width) / image.width,
(area['h'] * height) / image.height].min (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( canvas.image(
io, io,
@@ -190,7 +193,9 @@ module Submissions
submitter.submission.template_schema.map do |item| submitter.submission.template_schema.map do |item|
pdf = pdfs_index[item['attachment_uuid']] 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? image_pdfs << pdf if original_documents.find { |a| a.uuid == item['attachment_uuid'] }.image?
@@ -217,15 +222,21 @@ module Submissions
end end
# rubocop:enable Metrics # 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 io = StringIO.new
pdf.trailer.info[:Creator] = info_creator pdf.trailer.info[:Creator] = info_creator
pdf.sign(io, reason: sign_reason(submitter.email), sign_params = {
certificate: pkcs.certificate, reason: sign_reason(submitter.email),
key: pkcs.key, certificate: pkcs.certificate,
certificate_chain: pkcs.ca_certs || []) 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!( ActiveStorage::Attachment.create!(
uuid:, uuid:,
+53
View File
@@ -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
+22 -11
View File
@@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
module Submitters module Submitters
TRUE_VALUES = ['1', 'true', true].freeze
module_function module_function
def search(submitters, keyword) def search(submitters, keyword)
@@ -43,21 +45,30 @@ module Submitters
) )
end end
def send_signature_requests(submitters, params) def normalize_preferences(account, user, params)
return if params[:send_email] != true && params[:send_email] != '1' preferences = {}
message_params = params['message'].presence || params.slice('subject', 'body').presence
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 send_signature_requests(submitters)
submitters.each do |submitter| submitters.each do |submitter|
next if submitter.email.blank? next if submitter.email.blank?
next if submitter.preferences['send_email'] == false
enqueue_invitation_email(submitter, params) SendSubmitterInvitationEmailJob.perform_later('submitter_id' => submitter.id)
end end
end end
def enqueue_invitation_email(submitter, params)
subject, body = params.values_at(:subject, :body) if params[:is_custom_message] == '1'
SendSubmitterInvitationEmailJob.perform_later('submitter_id' => submitter.id,
'body' => body,
'subject' => subject)
end
end end
+19
View File
@@ -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
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more