Compare commits

...
52 Commits
Author SHA1 Message Date
Pete Matsyburka 3c8fae78d1 use alpine3.18 2023-12-20 00:45:31 +02:00
Pete Matsyburka 441c1d7044 add builder on upload callback 2023-12-19 19:27:07 +02:00
Pete Matsyburka 203f4633f7 return 422 when submit archived submission 2023-12-19 15:51:56 +02:00
Pete Matsyburka e558f4d9fb add ability to set date field format 2023-12-17 23:51:44 +02:00
Pete Matsyburka 990e4020e9 add submitter update endpoint 2023-12-17 18:39:10 +02:00
Pete Matsyburka 1ad3e45519 add form button props 2023-12-17 16:10:17 +02:00
Pete Matsyburka 442ced8381 adjust text 2023-12-17 15:22:06 +02:00
Pete Matsyburka ba46202a4d add pdf processor class 2023-12-16 23:12:20 +02:00
Pete Matsyburka 1c77a1ee90 adjust email variables 2023-12-15 21:41:50 +02:00
Pete Matsyburka ea74b59535 add ID email variables 2023-12-15 21:41:50 +02:00
Pete Matsyburka 0e9343d9e2 process payment fields 2023-12-15 21:41:50 +02:00
Pete Matsyburka af4cf7257f extra sign in links 2023-12-14 16:47:42 +02:00
Pete Matsyburka c8d4ee58f8 reduce field area opacity 2023-12-13 17:50:13 +02:00
Pete Matsyburka 65e57c22e5 adjust integration emails 2023-12-13 15:46:42 +02:00
Pete Matsyburka 7351ae0a77 adjust integration submissions 2023-12-11 21:57:09 +02:00
Pete Matsyburka f1e0d5d137 fix result generation on missing page 2023-12-11 18:28:24 +02:00
Pete Matsyburka 8a1b71688c fix signing form with area on missing page 2023-12-11 15:18:47 +02:00
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
92 changed files with 1726 additions and 380 deletions

No files matched your search

+1 -1
View File
@@ -24,7 +24,7 @@ COPY ./app/views ./app/views
RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker
FROM ruby:3.2.2-alpine as app
FROM ruby:3.2.2-alpine3.18 as app
ENV RAILS_ENV=production
ENV BUNDLE_WITHOUT="development:test"
-2
View File
@@ -21,8 +21,6 @@ gem 'jwt'
gem 'lograge'
gem 'mysql2', require: false
gem 'oj'
gem 'omniauth-google-oauth2'
gem 'omniauth-rails_csrf_protection'
gem 'pagy'
gem 'pdf-reader'
gem 'pg', require: false
-32
View File
@@ -245,7 +245,6 @@ GEM
signet (>= 0.16, < 2.a)
hashdiff (1.0.1)
hashery (2.1.2)
hashie (5.0.0)
hexapdf (0.34.1)
cmdparse (~> 3.0, >= 3.0.3)
geom2d (~> 0.4, >= 0.4.1)
@@ -296,7 +295,6 @@ GEM
minitest (5.20.0)
msgpack (1.7.2)
multi_json (1.15.0)
multi_xml (0.6.0)
multipart-post (2.3.0)
mysql2 (0.5.5)
net-http-persistent (4.0.2)
@@ -316,29 +314,7 @@ GEM
racc (~> 1.4)
nokogiri (1.15.4-arm64-darwin)
racc (~> 1.4)
oauth2 (2.0.9)
faraday (>= 0.17.3, < 3.0)
jwt (>= 1.0, < 3.0)
multi_xml (~> 0.5)
rack (>= 1.2, < 4)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
oj (3.16.0)
omniauth (2.1.1)
hashie (>= 3.4.6)
rack (>= 2.2.3)
rack-protection
omniauth-google-oauth2 (1.1.1)
jwt (>= 2.0)
oauth2 (~> 2.0.6)
omniauth (~> 2.0)
omniauth-oauth2 (~> 1.8.0)
omniauth-oauth2 (1.8.0)
oauth2 (>= 1.4, < 3)
omniauth (~> 2.0)
omniauth-rails_csrf_protection (1.0.1)
actionpack (>= 4.2)
omniauth (~> 2.0)
openssl (3.2.0)
orm_adapter (0.5.0)
os (1.1.4)
@@ -372,8 +348,6 @@ GEM
nio4r (~> 2.0)
racc (1.7.1)
rack (2.2.8)
rack-protection (3.1.0)
rack (~> 2.2, >= 2.2.4)
rack-proxy (0.7.6)
rack
rack-test (2.1.0)
@@ -514,9 +488,6 @@ GEM
simplecov-html (0.12.3)
simplecov_json_formatter (0.1.4)
smart_properties (1.17.0)
snaky_hash (2.0.1)
hashie
version_gem (~> 1.1, >= 1.1.1)
sqlite3 (1.6.3)
mini_portile2 (~> 2.8.0)
sqlite3 (1.6.3-arm64-darwin)
@@ -537,7 +508,6 @@ GEM
uber (0.1.0)
unicode-display_width (2.5.0)
uniform_notifier (1.16.0)
version_gem (1.1.3)
warden (1.2.9)
rack (>= 2.0.9)
web-console (4.2.0)
@@ -589,8 +559,6 @@ DEPENDENCIES
lograge
mysql2
oj
omniauth-google-oauth2
omniauth-rails_csrf_protection
pagy
pdf-reader
pg
@@ -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
+8 -45
View File
@@ -13,6 +13,10 @@ module Api
submissions = Submissions.search(@submissions, params[:q])
submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present?
if params[:template_folder].present?
submissions = submissions.joins(template: :folder).where(folder: { name: params[:template_folder] })
end
submissions = paginate(submissions.preload(:created_by_user, :template, :submitters,
audit_trail_attachment: :blob))
@@ -81,7 +85,8 @@ module Api
emails:,
params:)
else
submissions_attrs, attachments = normalize_submissions_params!(submissions_params, template)
submissions_attrs, attachments =
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_params, template)
submissions = Submissions.create_from_submitters(
template:,
@@ -93,7 +98,8 @@ module Api
params:
)
save_default_value_attachments!(attachments, submissions.flat_map(&:submitters))
Submissions::NormalizeParamUtils.save_default_value_attachments!(attachments,
submissions.flat_map(&:submitters))
submissions
end
@@ -129,48 +135,5 @@ module Api
]
).fetch(key, [])
end
def normalize_submissions_params!(submissions_params, template)
attachments = []
Array.wrap(submissions_params).each do |submission|
submission[:submitters].each_with_index do |submitter, index|
default_values = submitter[:values] || {}
submitter[:fields]&.each { |f| default_values[f[:name]] = f[:default_value] if f[:default_value].present? }
next if default_values.blank?
values, new_attachments =
Submitters::NormalizeValues.call(template,
default_values,
submitter[:role] || template.submitters[index]['name'])
attachments.push(*new_attachments)
submitter[:values] = values
end
end
[submissions_params, attachments]
end
def save_default_value_attachments!(attachments, submitters)
return if attachments.blank?
attachments_index = attachments.index_by(&:uuid)
submitters.each do |submitter|
submitter.values.to_a.each do |_, value|
attachment = attachments_index[value]
next unless attachment
attachment.record = submitter
attachment.save!
end
end
end
end
end
@@ -30,5 +30,79 @@ module Api
render json: Submitters::SerializeForApi.call(@submitter, with_template: true, with_events: true)
end
def update
if @submitter.completed_at?
return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_entity
end
role = @submitter.submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name']
normalized_params, new_attachments =
Submissions::NormalizeParamUtils.normalize_submitter_params!(submitter_params.merge(role:), @submitter.template,
for_submitter: @submitter)
Submissions::CreateFromSubmitters.maybe_set_template_fields(@submitter.submission,
[normalized_params],
submitter_uuid: @submitter.uuid)
assign_submitter_attrs(@submitter, normalized_params)
ApplicationRecord.transaction do
Submissions::NormalizeParamUtils.save_default_value_attachments!(new_attachments, [@submitter])
@submitter.save!
@submitter.submission.save!
end
if @submitter.completed_at?
ProcessSubmitterCompletionJob.perform_later(@submitter)
elsif normalized_params[:send_email] || normalized_params[:send_sms]
Submitters.send_signature_requests([@submitter])
end
render json: Submitters::SerializeForApi.call(@submitter, with_template: false, with_events: false)
end
def submitter_params
submitter_params = params.key?(:submitter) ? params.require(:submitter) : params
submitter_params.permit(
:send_email, :send_sms, :uuid, :name, :email, :role, :completed, :phone, :application_key,
{ values: {}, readonly_fields: [], message: %i[subject body],
fields: [%i[name default_value readonly validation_pattern invalid_message]] }
)
end
private
def assign_submitter_attrs(submitter, attrs)
submitter.email = Submissions.normalize_email(attrs[:email]) if attrs.key?(:email)
submitter.phone = attrs[:phone].to_s.gsub(/[^0-9+]/, '') if attrs.key?(:phone)
submitter.values = submitter.values.merge(attrs[:values].to_unsafe_h) if attrs[:values]
submitter.completed_at = attrs[:completed] ? Time.current : submitter.completed_at
submitter.application_key = attrs[:application_key] if attrs.key?(:application_key)
assign_preferences(submitter, attrs)
submitter
end
def assign_preferences(submitter, attrs)
submitter_preferences = Submitters.normalize_preferences(submitter.account, current_user, attrs)
if submitter_preferences.key?('send_email')
submitter.preferences['send_email'] = submitter_preferences['send_email']
end
submitter.preferences['send_sms'] = submitter_preferences['send_sms'] if submitter_preferences.key?('send_sms')
return unless submitter_preferences.key?('email_message_uuid')
submitter.preferences['email_message_uuid'] = submitter_preferences['email_message_uuid']
submitter
end
end
end
@@ -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
+4 -1
View File
@@ -9,6 +9,7 @@ module Api
templates = params[:archived] ? templates.archived : templates.active
templates = templates.where(application_key: params[:application_key]) if params[:application_key].present?
templates = templates.joins(:folder).where(folder: { name: params[:folder] }) if params[:folder].present?
templates = paginate(templates.preload(:author, documents_attachments: :blob))
@@ -57,7 +58,9 @@ module Api
schema: [%i[attachment_uuid name]],
submitters: [%i[name uuid]],
fields: [[:uuid, :submitter_uuid, :name, :type, :required, :readonly, :default_value,
{ options: [%i[value uuid]], areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]]
{ preferences: {},
options: [%i[value uuid]],
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]]
)
end
end
+3 -2
View File
@@ -44,8 +44,9 @@ class ApplicationController < ActionController::Base
redirect_to setup_index_path unless User.exists?
end
def button_title(title: 'Submit', disabled_with: 'Submitting', icon: nil, icon_disabled: nil)
render_to_string(partial: 'shared/button_title', locals: { title:, disabled_with:, icon:, icon_disabled: })
def button_title(title: 'Submit', disabled_with: 'Submitting', title_class: '', icon: nil, icon_disabled: nil)
render_to_string(partial: 'shared/button_title',
locals: { title:, disabled_with:, title_class:, icon:, icon_disabled: })
end
def svg_icon(icon_name, class: '')
+12
View File
@@ -5,6 +5,7 @@ class DashboardController < ApplicationController
before_action :maybe_redirect_product_url
before_action :maybe_render_landing
before_action :maybe_redirect_mfa_setup
load_and_authorize_resource :template_folder, parent: false
load_and_authorize_resource :template, parent: false
@@ -62,6 +63,17 @@ class DashboardController < ApplicationController
redirect_to Docuseal::PRODUCT_URL, allow_other_host: true
end
def maybe_redirect_mfa_setup
return unless signed_in?
return if current_user.otp_required_for_login
return if !current_user.otp_required_for_login && !AccountConfig.exists?(value: true,
account_id: current_user.account_id,
key: AccountConfig::FORCE_MFA)
redirect_to mfa_setup_path, notice: 'Setup 2FA to continue'
end
def maybe_render_landing
return if signed_in?
+1 -1
View File
@@ -12,7 +12,7 @@ class ErrorsController < ActionController::Base
].freeze
def show
if request.original_fullpath.in?(ENTERPRISE_PATHS)
if request.original_fullpath.in?(ENTERPRISE_PATHS) && error_status_code == 404
return render json: { status: 404, message: ENTERPRISE_FEATURE_MESSAGE }, status: :not_found
end
+16 -6
View File
@@ -5,13 +5,11 @@ class MfaSetupController < ApplicationController
authorize!(:update, current_user)
end
def new
current_user.otp_secret ||= User.generate_otp_secret
before_action :set_provision_url, only: %i[show new]
current_user.save!
def show; end
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name)
end
def new; end
def edit; end
@@ -26,7 +24,7 @@ class MfaSetupController < ApplicationController
@error_message = 'Code is invalid'
render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/new'), status: :unprocessable_entity
render turbo_stream: turbo_stream.replace(:mfa_form, partial: 'mfa_setup/form'), status: :unprocessable_entity
end
end
@@ -41,4 +39,16 @@ class MfaSetupController < ApplicationController
render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/edit'), status: :unprocessable_entity
end
end
private
def set_provision_url
return redirect_to root_path, alert: '2FA has been set up already' if current_user.otp_required_for_login
current_user.otp_secret ||= User.generate_otp_secret
current_user.save!
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name)
end
end
@@ -1,16 +0,0 @@
# frozen_string_literal: true
class OmniauthCallbacksController < Devise::OmniauthCallbacksController
def google_oauth2
@user = Users.from_omniauth(request.env['omniauth.auth'])
if @user.persisted?
flash[:notice] = I18n.t('devise.omniauth_callbacks.success', kind: 'Google')
sign_in_and_redirect @user, event: :authentication
else
redirect_to new_registration_path(oauth_callback: true, user: @user.slice(:email, :first_name, :last_name)),
notice: 'Please complete registration with Google auth'
end
end
end
+22 -9
View File
@@ -14,22 +14,20 @@ class StartFormController < ApplicationController
def update
@submitter = Submitter.where(submission: @template.submissions.where(deleted_at: nil))
.order(id: :desc)
.then { |rel| params[:resubmit].present? ? rel.where(completed_at: nil) : rel }
.find_or_initialize_by(**submitter_params.compact_blank)
if @submitter.completed_at?
redirect_to start_form_completed_path(@template.slug, email: submitter_params[:email])
else
@submitter.assign_attributes(
uuid: @template.submitters.first['uuid'],
ip: request.remote_ip,
ua: request.user_agent,
preferences: { 'send_email' => true }
)
if @template.submitters.to_a.size > 1 && @submitter.new_record?
@error_message = 'Not found'
@submitter.submission ||= Submission.new(template: @template,
template_submitters: @template.submitters,
source: :link)
return render :show
end
assign_submission_attributes(@submitter, @template) if @submitter.new_record?
if @submitter.save
redirect_to submit_form_path(@submitter.slug)
@@ -47,6 +45,21 @@ class StartFormController < ApplicationController
private
def assign_submission_attributes(submitter, template)
submitter.assign_attributes(
uuid: template.submitters.first['uuid'],
ip: request.remote_ip,
ua: request.user_agent,
preferences: { 'send_email' => true }
)
submitter.submission ||= Submission.new(template:,
template_submitters: template.submitters,
source: :link)
submitter
end
def submitter_params
params.require(:submitter).permit(:email, :phone, :name).tap do |attrs|
attrs[:email] = Submissions.normalize_email(attrs[:email])
+2 -2
View File
@@ -46,7 +46,7 @@ class SubmissionsController < ApplicationController
source: :invite,
mark_as_sent: params[:send_email] == '1',
emails: params[:emails],
params:)
params: params.merge('send_completed_email' => true))
else
Submissions.create_from_submitters(template: @template,
user: current_user,
@@ -54,7 +54,7 @@ class SubmissionsController < ApplicationController
submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random',
mark_as_sent: params[:send_email] == '1',
submissions_attrs: submissions_params[:submission].to_h.values,
params:)
params: params.merge('send_completed_email' => true))
end
Submissions.send_signature_requests(submissions)
+10
View File
@@ -38,6 +38,16 @@ class SubmitFormController < ApplicationController
def update
submitter = Submitter.find_by!(slug: params[:slug])
if submitter.completed_at?
return render json: { error: 'Form has been completed already.' }, status: :unprocessable_entity
end
if submitter.template.deleted_at? || submitter.submission.deleted_at?
Rollbar.info("Archived template: #{submitter.template.id}") if defined?(Rollbar)
return render json: { error: 'Form has been archived.' }, status: :unprocessable_entity
end
Submitters::SubmitValues.call(submitter, params, request)
head :ok
+3 -1
View File
@@ -58,9 +58,11 @@ class TemplatesController < ApplicationController
def destroy
notice =
if !Docuseal.multitenant? && params[:permanently].present?
if params[:permanently].present?
@template.destroy!
Rollbar.info("Remove template: #{@template.id}") if defined?(Rollbar)
'Template has been removed.'
else
@template.update!(deleted_at: Time.current)
@@ -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 create
@encrypted_config.update!(encrypted_config_params)
@encrypted_config.assign_attributes(encrypted_config_params)
@encrypted_config.value.present? ? @encrypted_config.save! : @encrypted_config.delete
redirect_back(fallback_location: settings_webhooks_path, notice: 'Webhook URL has been saved.')
end
+1
View File
@@ -86,6 +86,7 @@ window.customElements.define('template-builder', class extends HTMLElement {
backgroundColor: '#faf7f5',
withPhone: this.dataset.withPhone === 'true',
withLogo: this.dataset.withLogo !== 'false',
withPayment: this.dataset.withPayment !== 'false',
acceptFileTypes: this.dataset.acceptFileTypes,
isDirectUpload: this.dataset.isDirectUpload === 'true'
})
+1 -1
View File
@@ -10,13 +10,13 @@ window.customElements.define('submission-form', class extends HTMLElement {
this.app = createApp(Form, {
submitter: JSON.parse(this.dataset.submitter),
authenticityToken: this.dataset.authenticityToken,
canSendEmail: this.dataset.canSendEmail === 'true',
isDirectUpload: this.dataset.isDirectUpload === 'true',
goToLast: this.dataset.goToLast === 'true',
isDemo: this.dataset.isDemo === 'true',
attribution: this.dataset.attribution !== 'false',
withConfetti: true,
withTypedSignature: this.dataset.withTypedSignature !== 'false',
values: reactive(JSON.parse(this.dataset.values)),
completedButton: JSON.parse(this.dataset.completedButton),
attachments: reactive(JSON.parse(this.dataset.attachments)),
+46 -6
View File
@@ -2,7 +2,7 @@
<div
class="flex absolute lg:text-base"
:style="computedStyle"
:class="{ 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars, 'cursor-default': !submittable, 'bg-red-100 border cursor-pointer ': submittable, 'border-red-100': !isActive && submittable, 'bg-opacity-70': !isActive && !isValueSet && submittable, 'border-red-500 border-dashed z-10': isActive && submittable, 'bg-opacity-30': (isActive || isValueSet) && submittable }"
:class="{ 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars, 'cursor-default': !submittable, 'bg-red-100 border cursor-pointer ': submittable, 'border-red-100': !isActive && submittable, 'bg-opacity-80': !isActive && !isValueSet && submittable, 'border-red-500 border-dashed z-10': isActive && submittable, 'bg-opacity-40': (isActive || isValueSet) && submittable }"
>
<div
v-if="!isActive && !isValueSet && field.type !== 'checkbox' && submittable && !area.option_uuid"
@@ -53,7 +53,7 @@
:src="initials.url"
>
<div
v-else-if="field.type === 'file'"
v-else-if="field.type === 'file' || field.type === 'payment'"
class="px-0.5 flex flex-col justify-center"
>
<a
@@ -158,7 +158,7 @@
</template>
<script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper } from '@tabler/icons-vue'
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard } from '@tabler/icons-vue'
export default {
name: 'FieldArea',
@@ -231,7 +231,8 @@ export default {
checkbox: 'Checkbox',
radio: 'Radio',
multiple: 'Multiple Select',
phone: 'Phone'
phone: 'Phone',
payment: 'Payment'
}
},
fieldIcons () {
@@ -247,7 +248,8 @@ export default {
radio: IconCircleDot,
cells: IconColumns3,
multiple: IconChecks,
phone: IconPhoneCheck
phone: IconPhoneCheck,
payment: IconCreditCard
}
},
image () {
@@ -271,9 +273,15 @@ export default {
return null
}
},
locale () {
return Intl.DateTimeFormat().resolvedOptions()?.locale
},
formattedDate () {
if (this.field.type === 'date' && this.modelValue) {
return new Intl.DateTimeFormat([], { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }).format(new Date(this.modelValue))
return this.formatDate(
new Date(this.modelValue),
this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY')
)
} else {
return ''
}
@@ -281,6 +289,8 @@ export default {
attachments () {
if (this.field.type === 'file') {
return (this.modelValue || []).map((uuid) => this.attachmentsIndex[uuid])
} else if (this.field.type === 'payment') {
return [this.attachmentsIndex[this.modelValue]].filter(Boolean)
} else {
return []
}
@@ -311,6 +321,36 @@ export default {
}
},
methods: {
formatDate (date, format) {
const monthFormats = {
M: 'numeric',
MM: '2-digit',
MMM: 'short',
MMMM: 'long'
}
const dayFormats = {
D: 'numeric',
DD: '2-digit'
}
const yearFormats = {
YYYY: 'numeric',
YY: '2-digit'
}
const parts = new Intl.DateTimeFormat([], {
day: dayFormats[format.match(/D+/)],
month: monthFormats[format.match(/M+/)],
year: yearFormats[format.match(/Y+/)],
timeZone: 'UTC'
}).formatToParts(date)
return format
.replace(/D+/, parts.find((p) => p.type === 'day').value)
.replace(/M+/, parts.find((p) => p.type === 'month').value)
.replace(/Y+/, parts.find((p) => p.type === 'year').value)
},
updateMultipleSelectValue (value) {
if (this.modelValue?.includes(value)) {
const newValue = [...this.modelValue]
+1
View File
@@ -12,6 +12,7 @@
:key="areaIndex"
>
<Teleport
v-if="findPageElementForArea(area)"
:to="findPageElementForArea(area)"
>
<FieldArea
+12 -2
View File
@@ -21,7 +21,7 @@
</span>
</a>
<button
v-if="canSendEmail && !isDemo"
v-if="canSendEmail && !isDemo && withSendCopyButton"
class="white-button !h-auto flex items-center space-x-1 w-full"
:disabled="isSendingCopy"
@click.prevent="sendCopyToEmail"
@@ -36,7 +36,7 @@
</span>
</button>
<button
v-if="!isWebView"
v-if="!isWebView && withDownloadButton"
class="base-button flex items-center space-x-1 w-full"
:disabled="isDownloading"
@click.prevent="download"
@@ -115,6 +115,16 @@ export default {
required: false,
default: true
},
withDownloadButton: {
type: Boolean,
required: false,
default: true
},
withSendCopyButton: {
type: Boolean,
required: false,
default: true
},
withConfetti: {
type: Boolean,
required: false,
+53 -12
View File
@@ -223,6 +223,7 @@
:field="currentField"
:previous-value="previousSignatureValue"
:is-direct-upload="isDirectUpload"
:with-typed-signature="withTypedSignature"
:attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug"
@attached="attachments.push($event)"
@@ -265,8 +266,22 @@
@focus="$refs.areas.scrollIntoField(currentField)"
@submit="submitStep"
/>
<PaymentStep
v-else-if="currentField.type === 'payment'"
ref="currentStep"
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:field="currentField"
:submitter-slug="submitterSlug"
@attached="attachments.push($event)"
@focus="$refs.areas.scrollIntoField(currentField)"
@submit="submitStep"
/>
</div>
<div class="mt-6 md:mt-8">
<div
v-if="currentField.type !== 'payment' || submittedValues[currentField.uuid]"
class="mt-6 md:mt-8"
>
<button
ref="submitButton"
type="submit"
@@ -302,6 +317,8 @@
:is-demo="isDemo"
:attribution="attribution"
:completed-button="completedButton"
:with-send-copy-button="withSendCopyButton"
:with-download-button="withDownloadButton"
:with-confetti="withConfetti"
:can-send-email="canSendEmail && !!submitter.email"
:submitter-slug="submitterSlug"
@@ -330,6 +347,7 @@ import InitialsStep from './initials_step'
import AttachmentStep from './attachment_step'
import MultiSelectStep from './multi_select_step'
import PhoneStep from './phone_step'
import PaymentStep from './payment_step'
import TextStep from './text_step'
import DateStep from './date_step'
import FormCompleted from './completed'
@@ -350,6 +368,7 @@ export default {
IconArrowsDiagonal,
TextStep,
PhoneStep,
PaymentStep,
IconArrowsDiagonalMinimize2,
FormCompleted
},
@@ -386,6 +405,11 @@ export default {
required: false,
default: false
},
withTypedSignature: {
type: Boolean,
required: false,
default: true
},
baseUrl: {
type: String,
required: false,
@@ -396,11 +420,6 @@ export default {
required: false,
default: () => []
},
authenticityToken: {
type: String,
required: false,
default: ''
},
backgroundColor: {
type: String,
required: false,
@@ -436,6 +455,16 @@ export default {
required: false,
default: () => ({})
},
withSendCopyButton: {
type: Boolean,
required: false,
default: true
},
withDownloadButton: {
type: Boolean,
required: false,
default: true
},
completedButton: {
type: Object,
required: false,
@@ -457,6 +486,12 @@ export default {
currentStepFields () {
return this.stepFields[this.currentStep]
},
queryParams () {
return new URLSearchParams(window.location.search)
},
authenticityToken () {
return document.querySelector('meta[name="csrf-token"]')?.content
},
submitterSlug () {
return this.submitter.slug
},
@@ -519,7 +554,13 @@ export default {
}
})
if (this.goToLast) {
if (this.queryParams.get('field_uuid')) {
const stepIndex = this.stepFields.findIndex((fields) => {
return fields.some((f) => f.uuid === this.queryParams.get('field_uuid'))
})
this.currentStep = Math.max(stepIndex, 0)
} else if (this.goToLast) {
const requiredEmptyStepIndex = this.stepFields.indexOf(this.stepFields.find((fields) => fields.some((f) => f.required && !this.submittedValues[f.uuid])))
const lastFilledStepIndex = this.stepFields.indexOf([...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.submittedValues[f.uuid]))) + 1
@@ -564,7 +605,7 @@ export default {
methods: {
t,
maybeTrackEmailClick () {
const queryParams = new URLSearchParams(window.location.search)
const { queryParams } = this
if (queryParams.has('t')) {
const t = queryParams.get('t')
@@ -588,7 +629,7 @@ export default {
}
},
maybeTrackSmsClick () {
const queryParams = new URLSearchParams(window.location.search)
const { queryParams } = this
if (queryParams.has('c')) {
const c = queryParams.get('c')
@@ -661,7 +702,7 @@ export default {
async submitStep () {
this.isSubmitting = true
const stepPromise = ['signature', 'phone', 'initials'].includes(this.currentField.type)
const stepPromise = ['signature', 'phone', 'initials', 'payment'].includes(this.currentField.type)
? this.$refs.currentStep.submit
: () => Promise.resolve({})
@@ -678,7 +719,7 @@ export default {
}
await this.saveStep(formData).then(async (response) => {
if (response.status === 422) {
if (response.status === 422 || response.status === 500) {
const data = await response.json()
alert(data.error || 'Value is invalid')
@@ -708,7 +749,7 @@ export default {
}
}
}).catch(error => {
console.error('Error submitting form:', error)
console.error(error)
}).finally(() => {
this.isSubmitting = false
})
+27 -9
View File
@@ -38,7 +38,9 @@ const en = {
type: 'Type',
type_text: 'Type text',
date: 'Date',
email_has_been_sent: 'Email has been sent'
email_has_been_sent: 'Email has been sent',
processing: 'Processing',
pay_with_strip: 'Pay with Stripe'
}
const es = {
@@ -81,7 +83,9 @@ const es = {
draw: 'Dibujar',
type: 'Escribir',
type_text: 'Escribir texto',
email_has_been_sent: 'El correo electrónico ha sido enviado'
email_has_been_sent: 'El correo electrónico ha sido enviado',
processing: 'Procesando',
pay_with_strip: 'Pagar con Stripe'
}
const it = {
@@ -124,7 +128,9 @@ const it = {
type: 'Inserisci',
type_text: 'Inserisci testo',
toggle_multiline_text: 'Attiva Testo Multilinea',
email_has_been_sent: "L'email è stata inviata"
email_has_been_sent: "L'email è stata inviata",
processing: 'Elaborazione',
pay_with_strip: 'Paga con Stripe'
}
const de = {
@@ -167,7 +173,9 @@ const de = {
type: 'Eingeben',
type_text: 'Text eingeben',
toggle_multiline_text: 'Mehrzeiligen Text umschalten',
email_has_been_sent: 'Die E-Mail wurde gesendet'
email_has_been_sent: 'Die E-Mail wurde gesendet',
processing: 'Verarbeitung',
pay_with_strip: 'Mit Stripe bezahlen'
}
const fr = {
@@ -210,7 +218,9 @@ const fr = {
type: 'Saisir',
type_text: 'Saisir du texte',
toggle_multiline_text: 'Basculer le Texte Multiligne',
email_has_been_sent: "L'email a été envoyé"
email_has_been_sent: "L'email a été envoyé",
processing: 'Traitement',
pay_with_strip: 'Paiement avec Stripe'
}
const pl = {
@@ -253,7 +263,9 @@ const pl = {
type: 'Wprowadź',
type_text: 'Wprowadź tekst',
toggle_multiline_text: 'Przełącz Tekst Wielolinijkowy',
email_has_been_sent: 'E-mail został wysłany'
email_has_been_sent: 'E-mail został wysłany',
processing: 'Przetwarzanie',
pay_with_strip: 'Płatność za pomocą Stripe'
}
const uk = {
@@ -296,7 +308,9 @@ const uk = {
type: 'Текст',
type_text: 'Введіть текст',
toggle_multiline_text: 'Перемкнути Багаторядковий Текст',
email_has_been_sent: 'Електронний лист був відправлений'
email_has_been_sent: 'Електронний лист був відправлений',
processing: 'Обробка',
pay_with_strip: 'Сплатити за допомогою Stripe'
}
const cs = {
@@ -339,7 +353,9 @@ const cs = {
type: 'Zadat',
type_text: 'Zadat text',
toggle_multiline_text: 'Přepnout Víceřádkový Text',
email_has_been_sent: 'E-mail byl odeslán'
email_has_been_sent: 'E-mail byl odeslán',
processing: 'Zpracování',
pay_with_strip: 'Zaplacení přes Stripe'
}
const pt = {
@@ -382,7 +398,9 @@ const pt = {
type: 'Inserir',
type_text: 'Inserir texto',
toggle_multiline_text: 'Alternar Texto Multilinha',
email_has_been_sent: 'Email enviado'
email_has_been_sent: 'Email enviado',
processing: 'Processamento',
pay_with_strip: 'Pagar com Stripe'
}
const i18n = { en, es, it, de, fr, pl, uk, cs, pt }
@@ -0,0 +1,181 @@
<template>
<label
v-if="!modelValue && !sessionId"
:for="field.uuid"
class="label text-2xl mb-2"
>{{ field.name || defaultName }}
</label>
<div>
<input
type="text"
:value="modelValue"
hidden
:name="`values[${field.uuid}]`"
class="hidden"
>
<div
v-if="modelValue && !sessionId"
class=" text-2xl mb-2"
>
Already paid
</div>
<div v-else>
<button
v-if="sessionId"
disabled
class="base-button w-full"
>
<IconLoader
width="22"
class="animate-spin"
/>
<span>
{{ t('processing') }}...
</span>
</button>
<button
v-else
:id="field.uuid"
class="btn bg-[#7B73FF] text-white hover:bg-[#0A2540] text-lg w-full"
:class="{ disabled: isCreatingCheckout }"
:disabled="isCreatingCheckout"
@click.prevent="startCheckout"
>
<IconInnerShadowTop
v-if="isCreatingCheckout"
width="22"
class="animate-spin"
/>
<IconBrandStripe
v-else
width="22"
/>
<span>
{{ t('pay_with_strip') }}
</span>
</button>
</div>
</div>
</template>
<script>
import { IconBrandStripe, IconInnerShadowTop, IconLoader } from '@tabler/icons-vue'
export default {
name: 'PaymentStep',
components: {
IconBrandStripe,
IconInnerShadowTop,
IconLoader
},
inject: ['baseUrl', 't'],
props: {
modelValue: {
type: String,
required: false,
default: ''
},
field: {
type: Object,
required: true
},
submitterSlug: {
type: String,
required: true
}
},
emits: ['focus', 'submit', 'update:model-value', 'attached'],
data () {
return {
isCreatingCheckout: false
}
},
computed: {
queryParams () {
return new URLSearchParams(window.location.search)
},
sessionId () {
return this.queryParams.get('stripe_session_id')
},
defaultName () {
const { price, currency } = this.field.preferences || {}
const formattedPrice = new Intl.NumberFormat([], {
style: 'currency',
currency
}).format(price)
return `Pay ${formattedPrice}`
}
},
mounted () {
if (this.sessionId) {
this.$emit('submit')
}
},
methods: {
async submit () {
if (this.sessionId) {
return fetch((this.baseUrl || '/embed').replace('/embed', '/api/stripe_payments/' + this.sessionId), {
method: 'PUT',
body: JSON.stringify({
submitter_slug: this.submitterSlug
}),
headers: { 'Content-Type': 'application/json' }
}).then(async (resp) => {
if (resp.status === 422 || resp.status === 500) {
const data = await resp.json()
alert(data.error || 'Unexpected error')
return Promise.reject(new Error(data.error))
}
const attachment = await resp.json()
window.history.replaceState({}, document.title, window.location.pathname)
this.$emit('update:model-value', attachment.uuid)
this.$emit('attached', attachment)
return resp
})
} else {
return Promise.resolve({})
}
},
startCheckout () {
this.isCreatingCheckout = true
fetch((this.baseUrl || '/embed').replace('/embed', '/api/stripe_payments'), {
method: 'POST',
body: JSON.stringify({
submitter_slug: this.submitterSlug,
field_uuid: this.field.uuid
}),
headers: { 'Content-Type': 'application/json' }
}).then(async (resp) => {
if (resp.status === 422 || resp.status === 500) {
const data = await resp.json()
alert(data.message || 'Unexpected error')
return Promise.reject(new Error(data.message))
}
const { url } = await resp.json()
const link = document.createElement('a')
link.href = url
if (url) {
link.click()
}
}).finally(() => {
this.isCreatingCheckout = false
})
}
}
}
</script>
@@ -23,7 +23,7 @@
</a>
</span>
<span
v-else
v-else-if="withTypedSignature"
class="tooltip"
:data-tip="t('type_text')"
>
@@ -149,6 +149,11 @@ export default {
required: true,
default: false
},
withTypedSignature: {
type: Boolean,
required: false,
default: true
},
attachmentsIndex: {
type: Object,
required: false,
+28 -23
View File
@@ -42,7 +42,7 @@
v-model="field.submitter_uuid"
class="border-r"
:compact="true"
:editable="editable"
:editable="editable && !defaultField"
:menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px]'"
:submitters="template.submitters"
@update:model-value="save"
@@ -51,7 +51,7 @@
<FieldType
v-model="field.type"
:button-width="27"
:editable="editable"
:editable="editable && !defaultField"
:button-classes="'px-1'"
:menu-classes="'bg-white rounded-t-none'"
@update:model-value="[maybeUpdateOptions(), save()]"
@@ -60,7 +60,7 @@
<span
v-if="field.type !== 'checkbox' || field.name"
ref="name"
:contenteditable="editable"
:contenteditable="editable && !defaultField"
class="pr-1 cursor-text outline-none block"
style="min-width: 2px"
@keydown.enter.prevent="onNameEnter"
@@ -160,6 +160,11 @@ export default {
required: false,
default: false
},
defaultField: {
type: Object,
required: false,
default: null
},
editable: {
type: Boolean,
required: false,
@@ -213,30 +218,30 @@ export default {
},
borderColors () {
return [
'border-red-500/50',
'border-sky-500/50',
'border-emerald-500/50',
'border-yellow-300/50',
'border-purple-600/50',
'border-pink-500/50',
'border-cyan-500/50',
'border-orange-500/50',
'border-lime-500/50',
'border-indigo-500/50'
'border-red-500/80',
'border-sky-500/80',
'border-emerald-500/80',
'border-yellow-300/80',
'border-purple-600/80',
'border-pink-500/80',
'border-cyan-500/80',
'border-orange-500/80',
'border-lime-500/80',
'border-indigo-500/80'
]
},
bgColors () {
return [
'bg-red-100/50',
'bg-sky-100/50',
'bg-emerald-100/50',
'bg-yellow-100/50',
'bg-purple-100/50',
'bg-pink-100/50',
'bg-cyan-100/50',
'bg-orange-100/50',
'bg-lime-100/50',
'bg-indigo-100/50'
'bg-red-100/80',
'bg-sky-100/80',
'bg-emerald-100/80',
'bg-yellow-100/80',
'bg-purple-100/80',
'bg-pink-100/80',
'bg-cyan-100/80',
'bg-orange-100/80',
'bg-lime-100/80',
'bg-indigo-100/80'
]
},
isSelected () {
+63 -6
View File
@@ -8,7 +8,7 @@
class="flex justify-between py-1.5 items-center pr-4 sticky top-0 z-10"
:style="{ backgroundColor }"
>
<div class="flex space-x-3">
<div class="flex items-center space-x-3">
<a
v-if="withLogo"
href="/"
@@ -19,7 +19,7 @@
v-if="withTitle"
:model-value="template.name"
: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"
@update:model-value="updateName"
/>
@@ -30,10 +30,25 @@
name="buttons"
/>
<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
:href="`/templates/${template.id}/submissions/new`"
data-turbo-frame="modal"
class="btn btn-primary text-base"
class="white-button md:!px-6"
@click="maybeShowEmptyTemplateAlert"
>
<IconUsersPlus
@@ -41,7 +56,7 @@
class="inline"
/>
<span class="hidden md:inline">
Recipients
Send
</span>
</a>
<button
@@ -129,6 +144,7 @@
:selected-submitter="selectedSubmitter"
:document="document"
:is-drag="!!dragField"
:default-fields="defaultFields"
:draw-field="drawField"
:editable="editable"
:base-url="baseUrl"
@@ -228,6 +244,7 @@
:fields="template.fields"
:submitters="template.submitters"
:selected-submitter="selectedSubmitter"
:default-submitters="defaultSubmitters"
:default-fields="defaultFields"
:with-sticky-submitters="withStickySubmitters"
:editable="editable"
@@ -254,7 +271,7 @@ import Contenteditable from './contenteditable'
import DocumentPreview from './preview'
import DocumentControls from './controls'
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 { ref, computed } from 'vue'
@@ -268,6 +285,7 @@ export default {
IconPlus,
FieldType,
IconX,
IconWritingSign,
Logo,
Dropzone,
DocumentPreview,
@@ -284,6 +302,7 @@ export default {
baseFetch: this.baseFetch,
backgroundColor: this.backgroundColor,
withPhone: this.withPhone,
withPayment: this.withPayment,
selectedAreaRef: computed(() => this.selectedAreaRef)
}
},
@@ -332,6 +351,13 @@ export default {
required: false,
default: true
},
onUpload: {
type: Function,
required: false,
default () {
return () => {}
}
},
withStickySubmitters: {
type: Boolean,
required: false,
@@ -352,6 +378,11 @@ export default {
required: false,
default: false
},
withPayment: {
type: Boolean,
required: false,
default: false
},
fetchOptions: {
type: Object,
required: false,
@@ -419,6 +450,12 @@ export default {
window.addEventListener('keydown', this.onKeyDown)
window.addEventListener('resize', this.onWindowResize)
this.$nextTick(() => {
if (document.location.search?.includes('stripe_connect_success')) {
document.querySelector('form[action="/auth/stripe_connect"]')?.closest('.dropdown')?.querySelector('label')?.focus()
}
})
},
unmounted () {
document.removeEventListener('keyup', this.onKeyUp)
@@ -444,6 +481,12 @@ export default {
field.options = [{ value: '', uuid: v4() }]
}
if (type === 'date') {
field.preferences = {
format: Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'
}
}
this.drawField = field
this.drawOption = null
},
@@ -499,7 +542,7 @@ export default {
this.selectedAreaRef.value = null
}
if (['Backspace', 'Delete'].includes(e.key) && this.selectedAreaRef.value && document.activeElement === document.body) {
if (this.editable && ['Backspace', 'Delete'].includes(e.key) && this.selectedAreaRef.value && document.activeElement === document.body) {
this.removeArea(this.selectedAreaRef.value)
this.selectedAreaRef.value = null
@@ -620,6 +663,12 @@ export default {
field.options = [{ value: '', uuid: v4() }]
}
if (field.type === 'date') {
field.preferences = {
format: Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'
}
}
const fieldArea = {
x: (area.x - 6) / area.maskW,
y: area.y / area.maskH,
@@ -690,6 +739,10 @@ export default {
this.scrollIntoDocument(schema[0])
})
if (this.onUpload) {
this.onUpload(this.template)
}
this.save()
},
updateName (value) {
@@ -723,6 +776,10 @@ export default {
})
})
if (this.onUpload) {
this.onUpload(this.template)
}
this.save()
},
moveDocument (item, direction) {
@@ -8,6 +8,7 @@
:editable="editable"
:areas="areasIndex[index]"
:is-drag="isDrag"
:default-fields="defaultFields"
:draw-field="drawField"
:selected-submitter="selectedSubmitter"
:image="image"
@@ -35,6 +36,11 @@ export default {
required: false,
default: () => ({})
},
defaultFields: {
type: Array,
required: false,
default: () => []
},
selectedSubmitter: {
type: Object,
required: true
+107 -6
View File
@@ -14,7 +14,7 @@
<div class="flex items-center p-1 space-x-1">
<FieldType
v-model="field.type"
:editable="editable"
:editable="editable && !defaultField"
:button-width="20"
@update:model-value="[maybeUpdateOptions(), save()]"
@click="scrollToFirstArea"
@@ -22,7 +22,7 @@
<Contenteditable
ref="name"
:model-value="field.name || defaultName"
:editable="editable"
:editable="editable && !defaultField"
:icon-inline="true"
:icon-width="18"
:icon-stroke-width="1.6"
@@ -65,7 +65,12 @@
:stroke-width="1.6"
/>
</button>
<PaymentSettings
v-if="field.type === 'payment'"
:field="field"
/>
<span
v-else-if="!defaultField"
class="dropdown dropdown-end"
>
<label
@@ -106,6 +111,33 @@
Default value
</label>
</div>
<div
v-if="field.type === 'date'"
class="py-1.5 px-1 relative"
@click.stop
>
<select
v-model="field.preferences.format"
placeholder="Format"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0"
@change="save"
>
<option
v-for="format in dateFormats"
:key="format"
:value="format"
>
{{ formatDate(new Date(), format) }}
</option>
</select>
<label
:style="{ backgroundColor: backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
Format
</label>
</div>
<li
v-if="field.type != 'phone'"
@click.stop
@@ -260,6 +292,7 @@
<script>
import Contenteditable from './contenteditable'
import FieldType from './field_type'
import PaymentSettings from './payment_settings'
import { IconShape, IconNewSection, IconTrashX, IconCopy, IconSettings } from '@tabler/icons-vue'
import { v4 } from 'uuid'
@@ -269,6 +302,7 @@ export default {
Contenteditable,
IconSettings,
IconShape,
PaymentSettings,
IconNewSection,
IconTrashX,
IconCopy,
@@ -280,6 +314,11 @@ export default {
type: Object,
required: true
},
defaultField: {
type: Object,
required: false,
default: null
},
editable: {
type: Boolean,
required: false,
@@ -289,23 +328,85 @@ export default {
emits: ['set-draw', 'remove', 'scroll-to'],
data () {
return {
isNameFocus: false
isNameFocus: false,
showPaymentModal: false
}
},
computed: {
fieldNames: FieldType.computed.fieldNames,
dateFormats () {
return [
'MM/DD/YYYY',
'DD/MM/YYYY',
'YYYY-MM-DD',
'DD-MM-YYYY',
'DD.MM.YYYY',
'MMM D, YYYY',
'MMMM D, YYYY',
'D MMM YYYY',
'D MMMM YYYY'
]
},
defaultName () {
const typeIndex = this.template.fields.filter((f) => f.type === this.field.type).indexOf(this.field)
if (this.field.type === 'payment' && this.field.preferences?.price) {
const { price, currency } = this.field.preferences || {}
const suffix = { multiple: 'Select', radio: 'Group' }[this.field.type] || 'Field'
const formattedPrice = new Intl.NumberFormat([], {
style: 'currency',
currency
}).format(price)
return `${this.fieldNames[this.field.type]} ${suffix} ${typeIndex + 1}`
return `${this.fieldNames[this.field.type]} ${formattedPrice}`
} else {
const typeIndex = this.template.fields.filter((f) => f.type === this.field.type).indexOf(this.field)
const suffix = { multiple: 'Select', radio: 'Group' }[this.field.type] || 'Field'
return `${this.fieldNames[this.field.type]} ${suffix} ${typeIndex + 1}`
}
},
areas () {
return this.field.areas || []
}
},
created () {
this.field.preferences ||= {}
if (this.field.type === 'date') {
this.field.preferences.format ||=
(Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY')
}
},
methods: {
formatDate (date, format) {
const monthFormats = {
M: 'numeric',
MM: '2-digit',
MMM: 'short',
MMMM: 'long'
}
const dayFormats = {
D: 'numeric',
DD: '2-digit'
}
const yearFormats = {
YYYY: 'numeric',
YY: '2-digit'
}
const parts = new Intl.DateTimeFormat([], {
day: dayFormats[format.match(/D+/)],
month: monthFormats[format.match(/M+/)],
year: yearFormats[format.match(/Y+/)]
}).formatToParts(date)
return format
.replace(/D+/, parts.find((p) => p.type === 'day').value)
.replace(/M+/, parts.find((p) => p.type === 'month').value)
.replace(/Y+/, parts.find((p) => p.type === 'year').value)
},
copyToAllPages (field) {
const areaString = JSON.stringify(field.areas[0])
@@ -25,7 +25,7 @@
v-for="(icon, type) in fieldIcons"
:key="type"
>
<li v-if="withPhone || type !== 'phone'">
<li v-if="withPhone || withPayment || !['phone', 'payment'].includes(type)">
<a
href="#"
class="text-sm py-1 px-2"
@@ -46,11 +46,11 @@
</template>
<script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper } from '@tabler/icons-vue'
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard } from '@tabler/icons-vue'
export default {
name: 'FiledTypeDropdown',
inject: ['withPhone'],
inject: ['withPhone', 'withPayment'],
props: {
modelValue: {
type: String,
@@ -92,7 +92,8 @@ export default {
multiple: 'Multiple',
radio: 'Radio',
cells: 'Cells',
phone: 'Phone'
phone: 'Phone',
payment: 'Payment'
}
},
fieldIcons () {
@@ -108,7 +109,8 @@ export default {
radio: IconCircleDot,
multiple: IconChecks,
cells: IconColumns3,
phone: IconPhoneCheck
phone: IconPhoneCheck,
payment: IconCreditCard
}
}
},
+16 -4
View File
@@ -5,7 +5,7 @@
class="w-full rounded-lg"
:class="{ 'bg-base-100': withStickySubmitters }"
:submitters="submitters"
:editable="editable"
:editable="editable && !defaultSubmitters.length"
@new-submitter="save"
@remove="removeSubmitter"
@name-change="save"
@@ -24,6 +24,7 @@
:field="field"
:type-index="fields.filter((f) => f.type === field.type).indexOf(field)"
:editable="editable && !dragField"
:default-field="defaultFields.find((f) => f.name === field.name)"
:draggable="editable"
@dragstart="dragField = field"
@dragend="dragField = null"
@@ -70,7 +71,7 @@
:key="type"
>
<button
v-if="withPhone || type != 'phone'"
v-if="(withPhone || type != 'phone') && (withPayment || type != 'payment')"
draggable="true"
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 }"
@@ -89,7 +90,7 @@
</div>
</button>
<div
v-else
v-else-if="type == 'phone'"
class="tooltip tooltip-bottom-end flex"
data-tip="Unlock SMS-verified phone number field with paid plan. Use text field for phone numbers without verification."
>
@@ -151,7 +152,7 @@ export default {
IconDrag,
IconLock
},
inject: ['save', 'backgroundColor', 'withPhone'],
inject: ['save', 'backgroundColor', 'withPhone', 'withPayment'],
props: {
fields: {
type: Array,
@@ -167,6 +168,11 @@ export default {
required: false,
default: () => []
},
defaultSubmitters: {
type: Array,
required: false,
default: () => []
},
withStickySubmitters: {
type: Boolean,
required: false,
@@ -255,6 +261,12 @@ export default {
field.options = [{ value: '', uuid: v4() }]
}
if (type === 'date') {
field.preferences = {
format: Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'
}
}
this.fields.push(field)
if (['signature', 'initials', 'cells'].includes(type)) {
+6
View File
@@ -23,6 +23,7 @@
:area="item.area"
:field="item.field"
:editable="editable"
:default-field="defaultFields.find((f) => f.name === item.field.name)"
@start-resize="resizeDirection = $event"
@stop-resize="resizeDirection = null"
@start-drag="isMove = true"
@@ -69,6 +70,11 @@ export default {
required: false,
default: () => []
},
defaultFields: {
type: Array,
required: false,
default: () => []
},
selectedSubmitter: {
type: Object,
required: true
@@ -0,0 +1,237 @@
<template>
<span
class="dropdown dropdown-end"
:class="{ 'dropdown-open': (!field.preferences?.price || !isConnected) && !isLoading }"
>
<label
tabindex="0"
title="Settings"
class="cursor-pointer text-transparent group-hover:text-base-content"
>
<IconSettings
:width="18"
:stroke-width="1.6"
/>
</label>
<ul
tabindex="0"
class="mt-1.5 dropdown-content menu menu-xs p-2 shadow bg-base-100 rounded-box w-52 z-10"
draggable="true"
@dragstart.prevent.stop
@click="closeDropdown"
>
<div
class="py-1.5 px-1 relative"
@click.stop
>
<select
v-model="field.preferences.currency"
placeholder="Price"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0"
@change="save"
>
<option
v-for="currency in currencies"
:key="currency"
:value="currency"
>
{{ currency }}
</option>
</select>
<label
:style="{ backgroundColor: backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
Currency
</label>
</div>
<div
class="py-1.5 px-1 relative"
@click.stop
>
<input
v-model="field.preferences.price"
type="number"
placeholder="Price"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0"
@blur="save"
>
<label
v-if="field.preferences.price"
:style="{ backgroundColor: backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
Price
</label>
</div>
<div
v-if="!isConnected || isOauthSuccess"
class="py-1.5 px-1 relative"
@click.stop
>
<div
v-if="isConnected && isOauthSuccess"
class="text-sm text-center"
>
<IconCircleCheck
class="inline text-green-600 w-4 h-4"
/>
Stripe Connected
</div>
<form
v-if="!isConnected"
data-turbo="false"
action="/auth/stripe_connect"
accept-charset="UTF-8"
target="_blank"
method="post"
>
<input
type="hidden"
name="state"
:value="oauthState"
autocomplete="off"
>
<input
type="hidden"
name="redirect_uri"
:value="redirectUri"
autocomplete="off"
>
<input
type="hidden"
name="scope"
value="read_write"
autocomplete="off"
>
<input
type="hidden"
name="authenticity_token"
:value="authenticityToken"
autocomplete="off"
>
<button
type="submit"
:disabled="isLoading"
class="btn bg-[#7B73FF] hover:bg-[#0A2540] btn-sm text-white w-full"
>
<span
v-if="isLoading"
class="flex items-center space-x-1"
>
<IconInnerShadowTop
class="w-4 h-4 animate-spin inline"
/>
<span>
Connect Stripe
</span>
</span>
<span
v-else
class="flex items-center space-x-1"
>
<IconBrandStripe
class="w-4 h-4 inline"
/>
<span>
Connect Stripe
</span>
</span>
</button>
</form>
</div>
</ul>
</span>
</template>
<script>
import { IconSettings, IconCircleCheck, IconBrandStripe, IconInnerShadowTop } from '@tabler/icons-vue'
import { ref } from 'vue'
const isConnected = ref(false)
export default {
name: 'PaymentSettings',
components: {
IconSettings,
IconCircleCheck,
IconInnerShadowTop,
IconBrandStripe
},
inject: ['backgroundColor', 'save'],
props: {
field: {
type: Object,
required: true
}
},
data () {
return {
isLoading: false
}
},
computed: {
isConnected: () => isConnected.value,
isOauthSuccess () {
return document.location.search?.includes('stripe_connect_success')
},
redirectUri () {
return document.location.origin + '/auth/stripe_connect/callback'
},
currencies () {
return ['USD', 'EUR', 'GBP']
},
authenticityToken () {
return document.querySelector('meta[name="csrf-token"]')?.content
},
oauthState () {
const params = new URLSearchParams('')
params.set('redir', document.location.href)
return params.toString()
},
defaultCurrency () {
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone
if (userTimezone.startsWith('Europe')) {
return 'EUR'
} else if (userTimezone.includes('London') || userTimezone.includes('Belfast')) {
return 'GBP'
} else {
return 'USD'
}
}
},
created () {
this.field.preferences ||= {}
},
mounted () {
this.field.preferences.currency ||= this.defaultCurrency
if (!this.isConnected) {
this.checkStatus()
}
},
methods: {
checkStatus () {
this.isLoading = true
fetch('/api/stripe_connect').then(async (resp) => {
const { status } = await resp.json()
if (status === 'connected') {
isConnected.value = true
}
}).finally(() => {
this.isLoading = false
})
},
closeDropdown () {
document.activeElement.blur()
}
}
}
</script>
+2 -1
View File
@@ -24,7 +24,8 @@ class ProcessSubmitterCompletionJob < ApplicationJob
def enqueue_completed_emails(submitter)
user = submitter.submission.created_by_user || submitter.template.author
if submitter.template.account.users.exists?(id: user.id)
if submitter.template.account.users.exists?(id: user.id) &&
submitter.submission.preferences['send_email'] != false
bcc = submitter.submission.template.account.account_configs
.find_by(key: AccountConfig::BCC_EMAILS)&.value
+34 -10
View File
@@ -25,10 +25,12 @@ class SubmitterMailer < ApplicationMailer
DEFAULT_INVITATION_SUBJECT
end
mail(to: @submitter.friendly_name,
from: from_address_for_submitter(submitter),
subject:,
reply_to: (submitter.submission.created_by_user || submitter.template.author)&.friendly_name)
mail(
to: @submitter.friendly_name,
from: from_address_for_submitter(submitter),
subject:,
reply_to: (submitter.submission.created_by_user || submitter.template.author)&.friendly_name&.sub(/\+\w+@/, '@')
)
end
def completed_email(submitter, user, bcc: nil)
@@ -53,7 +55,7 @@ class SubmitterMailer < ApplicationMailer
end
mail(from: from_address_for_submitter(submitter),
to: user.friendly_name,
to: user.role == 'integration' ? user.friendly_name.sub(/\+\w+@/, '@') : user.friendly_name,
bcc:,
subject:)
end
@@ -94,7 +96,26 @@ class SubmitterMailer < ApplicationMailer
total_size = audit_trail_data.size
end
documents.each do |attachment|
total_size = add_attachments_with_size_limit(documents, total_size)
attachments[submitter.submission.audit_trail.filename.to_s] = audit_trail_data if audit_trail_data
file_fields = submitter.submission.template_fields.select { |e| e['type'].in?(%w[file payment]) }
if file_fields.pluck('submitter_uuid').uniq.size == 1
storage_attachments =
submitter.attachments.where(uuid: submitter.values.values_at(*file_fields.pluck('uuid')).flatten)
add_attachments_with_size_limit(storage_attachments, total_size)
end
documents
end
def add_attachments_with_size_limit(storage_attachments, current_size)
total_size = current_size
storage_attachments.each do |attachment|
total_size += attachment.byte_size
break if total_size >= MAX_ATTACHMENTS_SIZE
@@ -102,12 +123,15 @@ class SubmitterMailer < ApplicationMailer
attachments[attachment.filename.to_s] = attachment.download
end
attachments[submitter.submission.audit_trail.filename.to_s] = audit_trail_data if audit_trail_data
documents
total_size
end
def from_address_for_submitter(submitter)
submitter.submission.created_by_user&.friendly_name || submitter.submission.template.author.friendly_name
if submitter.submission.source.in?(%w[api embed]) &&
(from_email = AccountConfig.find_by(account: submitter.account, key: 'integration_from_email')&.value.presence)
from_email
else
(submitter.submission.created_by_user || submitter.submission.template.author).friendly_name
end
end
end
+2
View File
@@ -25,6 +25,8 @@ class AccountConfig < ApplicationRecord
SUBMITTER_COMPLETED_EMAIL_KEY = 'submitter_completed_email'
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY = 'submitter_documents_copy_email'
BCC_EMAILS = 'bcc_emails'
FORCE_MFA = 'force_mfa'
ALLOW_TYPED_SIGNATURE = 'allow_typed_signature'
SUBMITTER_REMAILERS = 'submitter_reminders'
FORM_COMPLETED_BUTTON_KEY = 'form_completed_button'
+1
View File
@@ -25,6 +25,7 @@ class EncryptedConfig < ApplicationRecord
FILES_STORAGE_KEY = 'active_storage',
EMAIL_SMTP_KEY = 'action_mailer_smtp',
ESIGN_CERTS_KEY = 'esign_certs',
TIMESTAMP_SERVER_URL_KEY = 'timestamp_server_url',
APP_URL_KEY = 'app_url',
WEBHOOK_URL_KEY = 'webhook_url'
].freeze
+3 -1
View File
@@ -72,7 +72,9 @@ class Submission < ApplicationRecord
}, scope: false, prefix: true
def audit_trail_url
audit_trail&.url
return if audit_trail.blank?
Rails.application.routes.url_helpers.rails_storage_proxy_url(audit_trail, **Docuseal.default_url_options)
end
alias audit_log_url audit_trail_url
end
+1
View File
@@ -39,6 +39,7 @@ class SubmissionEvent < ApplicationRecord
send_email: 'send_email',
send_reminder_email: 'send_reminder_email',
send_sms: 'send_sms',
send_2fa_sms: 'send_2fa_sms',
open_email: 'open_email',
click_email: 'click_email',
click_sms: 'click_sms',
+1 -2
View File
@@ -55,10 +55,9 @@ class User < ApplicationRecord
has_many :template_folders, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
has_many :user_configs, dependent: :destroy
has_many :encrypted_configs, dependent: :destroy, class_name: 'EncryptedUserConfig'
has_many :email_messages, dependent: :destroy
has_many :email_messages, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable
devise :registerable, :omniauthable, omniauth_providers: [:google_oauth2] if Docuseal.multitenant?
attribute :role, :string, default: ADMIN_ROLE
attribute :uuid, :string, default: -> { SecureRandom.uuid }
+29
View File
@@ -36,6 +36,35 @@
</div>
<% 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 class="w-0 md:w-52"></div>
</div>
+1 -1
View File
@@ -8,7 +8,7 @@
<% end %>
<% if can?(:create, ::Template) %>
<%= render 'templates/upload_button' %>
<%= link_to new_template_path, class: 'btn btn-primary text-base btn-md gap-2', data: { turbo_frame: :modal } do %>
<%= link_to new_template_path, class: 'white-button !border gap-2', data: { turbo_frame: :modal } do %>
<%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %>
<span class="hidden md:block">Create</span>
<% end %>
+13 -6
View File
@@ -8,7 +8,7 @@
<div class="space-y-2">
<div class="form-control">
<%= 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 class="form-control">
@@ -16,11 +16,18 @@
</div>
<% end %>
<% if devise_mapping.omniauthable? %>
<%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %>
<%= f.button button_title(title: 'Sign up with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), class: 'white-button w-full mt-4' %>
<% end %>
<div class="space-y-4">
<%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %>
<%= f.button button_title(title: 'Sign up with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), class: 'white-button w-full mt-4' %>
<% end %>
<%= form_for '', url: omniauth_authorize_path(resource_name, :microsoft_office365), data: { turbo: false }, method: :post do |f| %>
<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 %>
<%= render 'devise/shared/links' %>
</div>
Whitespace-only changes.
+13 -5
View File
@@ -20,11 +20,19 @@
</div>
<% end %>
<% if devise_mapping.omniauthable? %>
<%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %>
<%= f.button button_title(title: 'Sign in with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), class: 'white-button w-full mt-4' %>
<% end %>
<div class="space-y-4">
<%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %>
<%= f.button button_title(title: 'Sign in with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), class: 'white-button w-full mt-4' %>
<% end %>
<%= form_for '', url: omniauth_authorize_path(resource_name, :microsoft_office365), data: { turbo: false }, method: :post do |f| %>
<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 %>
<%= render 'extra_links' %>
<%= render 'devise/shared/links' %>
</div>
+26
View File
@@ -97,5 +97,31 @@
</tbody>
</table>
</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>
+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 %>
<%= form_for '', url: mfa_setup_path, data: { turbo_frame: :_top } 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 %>
<%= render 'mfa_setup/form' %>
<% 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>
@@ -5,7 +5,7 @@
<%= render 'email_stats' %>
<%= render 'bcc_form', config: @bcc_config %>
<div class="flex justify-between items-end mb-4 mt-8">
<h2 class="text-3xl font-bold">Signature Email Reminders</h2>
<h2 class="text-3xl font-bold">Sign Request Email Reminders</h2>
</div>
<%= render 'reminder_banner' %>
<%= render 'reminder_form', config: @reminder_config %>
+2 -2
View File
@@ -2,13 +2,13 @@
<span class="flex items-center justify-center space-x-2">
<%= icon %>
<% if title %>
<span><%= title %></span>
<span class="<%= local_assigns[:title_class] %>"><%= title %></span>
<% end %>
</span>
</span>
<span class="disabled">
<span class="flex items-center justify-center space-x-2">
<%= 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>
+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' %>
</div>
<% end %>
<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>
<% if @template.submitters.to_a.size == 1 %>
<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>
<%= 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| %>
<div class="form-control !mt-0">
<%= 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 class="form-control">
<%= f.button button_title(title: 'Start', disabled_with: 'Starting'), class: 'base-button' %>
@@ -9,7 +9,7 @@
</a>
</div>
<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">
<% if template.submitters.size > 1 %>
<label class="label pt-0 pb-1 text-xs">
@@ -18,11 +18,11 @@
<% end %>
<input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>">
<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>
<div class="grid <%= 'md:grid-cols-2 gap-1' if template.submitters.size == 1 %>">
<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 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)">
@@ -34,10 +34,12 @@
</div>
</div>
</div>
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
<span>Add New</span>
</a>
<% if params[:selfsign].blank? %>
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
<span>Add New</span>
</a>
<% end %>
</dynamic-list>
<div>
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
+8 -6
View File
@@ -18,14 +18,14 @@
</a>
</div>
<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">
<label class="label pt-0 pb-1 text-xs">
<span class="label-text"> <%= item['name'] %></span>
</label>
<input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>">
<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>
</submitter-item>
<% end %>
@@ -33,10 +33,12 @@
</div>
</div>
</div>
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
<span>Add New</span>
</a>
<% if params[:selfsign].blank? %>
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
<span>Add New</span>
</a>
<% end %>
</dynamic-list>
<% end %>
<div>
+10 -8
View File
@@ -9,7 +9,7 @@
</a>
</div>
<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' %>">
<div class="form-control">
<% if template.submitters.size > 1 %>
@@ -19,18 +19,18 @@
<% end %>
<input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>">
<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>
<% if template.submitters.size > 1 %>
<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>
<% end %>
</div>
<% if template.submitters.size == 1 %>
<div class="form-control flex">
<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>
</div>
<% end %>
@@ -40,10 +40,12 @@
</div>
</div>
</div>
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
<span>Add New</span>
</a>
<% if params[:selfsign].blank? %>
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
<span>Add New</span>
</a>
<% end %>
</dynamic-list>
<div>
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
+2 -2
View File
@@ -1,7 +1,7 @@
<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']) %>
<img class="object-contain mx-auto" src="<%= attachments_index[value].url %>" loading="lazy">
<% elsif field['type'] == 'file' %>
<% elsif field['type'].in?(['file', 'payment']) %>
<autosize-field></autosize-field>
<div class="px-0.5 flex flex-col justify-center">
<% Array.wrap(value).each do |val| %>
@@ -34,7 +34,7 @@
<% elsif field['type'] == 'date' %>
<autosize-field></autosize-field>
<div class="flex items-center px-0.5">
<%= l(Date.parse(value), format: :long, locale: local_assigns[:locale]) %>
<%= TimeUtils.format_date_string(value, field.dig('preferences', 'format'), local_assigns[:locale]) %>
</div>
<% else %>
<autosize-field></autosize-field>
+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']] %>
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="relative text-center mt-4 block">
<div class="join">
+3 -3
View File
@@ -55,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| %>
<% 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">
<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">
<% document_annots_index[index]&.each do |annot| %>
<%= render 'submissions/annotation', annot: %>
@@ -148,7 +148,7 @@
</div>
<% elsif field['type'] == 'image' %>
<img class="object-contain mx-auto max-h-28" height="<%= attachments_index[value].metadata['height'] %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy">
<% elsif field['type'] == 'file' %>
<% elsif field['type'] == 'file' || field['type'] == 'payment' %>
<div class="flex flex-col justify-center">
<% Array.wrap(value).each do |val| %>
<a target="_blank" class="flex items-center space-x-1" href="<%= attachments_index[val].url %>">
@@ -162,7 +162,7 @@
<% elsif field['type'] == 'checkbox' %>
<%= svg_icon('check', class: 'w-6 h-6') %>
<% elsif field['type'] == 'date' %>
<%= l(Date.parse(value), locale: @submission.template.account.locale, format: :long) %>
<%= TimeUtils.format_date_string(value, field.dig('preferences', 'format'), @submission.template.account.locale) %>
<% else %>
<%= Array.wrap(value).join(', ') %>
<% end %>
@@ -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_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 || {} %>
<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>
<% configs = Submitters::FormConfigs.call(submitter) %>
<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-values="<%= submitter.values.to_json %>" data-with-typed-signature="<%= configs[:with_typed_signature] %>"></submission-form>
@@ -1,6 +1,7 @@
<% if @email_config || @body.present? %>
<%= auto_link(simple_format(h(ReplaceEmailVariables.call(@body.presence || @email_config.value['body'], submitter: @submitter)))) %>
<% if !(@body.presence || @email_config.value['body']).include?(ReplaceEmailVariables::SUBMITTER_LINK) %>
<% body = (@body.presence || @email_config.value['body']).to_s %>
<%= auto_link(simple_format(h(ReplaceEmailVariables.call(body, submitter: @submitter)))) %>
<% if !body.include?(ReplaceEmailVariables::SUBMITTER_LINK) && !body.include?(ReplaceEmailVariables::SUBMITTER_ID) && !body.include?(ReplaceEmailVariables::SUBMISSION_LINK) && !body.include?(ReplaceEmailVariables::TEMPLATE_ID) %>
<p><%= link_to nil, submit_form_url(slug: @submitter.slug, t: SubmissionEvents.build_tracking_param(@submitter, 'click_email')) %></p>
<% end %>
<% else %>
+24 -2
View File
@@ -35,7 +35,18 @@
</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' %>
<% 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 %>
<span class="btn btn-outline btn-sm w-20 md:w-24">View</span>
<% if !submission.deleted_at? && can?(:destroy, submission) %>
@@ -85,7 +96,18 @@
</form>
<% elsif !is_submission_completed %>
<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>
<% end %>
</div>
+5 -5
View File
@@ -6,7 +6,7 @@
<div class="pb-6 pt-1 space-y-1">
<p class="flex items-center space-x-1 text-xs text-base-content/60">
<%= svg_icon('user', class: 'w-4 h-4') %>
<span><%= template.author.full_name.presence || template.author.email %></span>
<span><%= template.author.full_name.presence || template.author.email.to_s.sub(/\+\w+@/, '@') %></span>
</p>
<p class="flex text-xs text-base-content/60">
<span class="flex items-center space-x-1 w-1/2">
@@ -52,10 +52,10 @@
</a>
</span>
<% end %>
<% if (!template.deleted_at? || !Docuseal.multitenant?) && can?(:destroy, template) %>
<span class="tooltip tooltip-left" data-tip="<%= template.deleted_at? ? 'Remove' : '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 %>
<%= svg_icon('trash', class: 'w-4 h-4 enabled') %>
<% if can?(:destroy, template) %>
<span class="tooltip tooltip-left" data-tip="<%= template.deleted_at? ? 'Delete' : 'Archive' %>">
<%= button_to template_path(template), data: { turbo_confirm: template.deleted_at? ? 'Template deletion is irreversible and will permanently remove all associated signed documents with it. Are you sure?' : 'Are you sure?' }, params: { permanently: template.deleted_at? }.compact_blank, method: :delete, class: 'btn btn-xs hover:btn-outline bg-base-200 btn-circle', aria_label: 'Restore' do %>
<%= svg_icon(template.deleted_at? ? 'trash' : 'archive', class: 'w-4 h-4 enabled') %>
<%= svg_icon('loader', class: 'w-4 h-4 animate-spin disabled') %>
<% end %>
</span>
+6 -3
View File
@@ -14,7 +14,7 @@
</span>
</a>
<% 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">
<%= svg_icon('pencil_share', class: 'w-5 h-5') %>
</a>
@@ -23,13 +23,16 @@
</div>
</div>
<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) %>
<%= button_to button_title(title: 'Remove', disabled_with: 'Removing', icon: svg_icon('trash', class: 'w-6 h-6')), template_path(template), class: 'btn btn-outline btn-sm', method: :delete, data: { turbo_confirm: 'Are you sure?' } %>
<%= button_to button_title(title: 'Archive', disabled_with: 'Archiving', title_class: 'hidden md:inline', icon: svg_icon('archive', class: 'w-6 h-6')), template_path(template), class: 'btn btn-outline btn-sm', method: :delete, data: { turbo_confirm: 'Are you sure?' } %>
<% end %>
<% if can?(:create, template) %>
<%= link_to new_template_path(base_template_id: template.id), class: 'btn btn-outline btn-sm', data: { turbo_frame: :modal } do %>
<%= svg_icon('copy', class: 'w-6 h-6') %>
<span>Clone</span>
<span class="hidden md:inline">Clone</span>
<% end %>
<% end %>
<% if !template.deleted_at? && can?(:update, template) %>
+1 -1
View File
@@ -4,7 +4,7 @@
<%= hidden_field_tag :base_template_id, @base_template.id %>
<% end %>
<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 class="mt-3 mb-4 flex items-center justify-between">
<a href="#" onclick="[event.preventDefault(), window.folder_name.focus()]">
+15 -13
View File
@@ -1,23 +1,18 @@
<%= render 'title', template: @template %>
<% is_show_tabs = @pagy.pages > 1 || params[:status].present? %>
<% if !@pagy.count.zero? || @template.submitters.to_a.size == 1 || params[:q].present? %>
<% if !@pagy.count.zero? || params[:q].present? %>
<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>
<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 %>
<%= render 'shared/search_input' %>
<% end %>
<%= link_to new_template_submissions_export_path(@template), class: 'order-3 md:order-1 btn btn-ghost text-base', data: { turbo_frame: 'modal' } do %>
<%= link_to new_template_submissions_export_path(@template), class: 'btn btn-ghost text-base', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('download', class: 'w-6 h-6 stroke-2') %>
<span>Export</span>
<% 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) %>
<%= 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') %>
<span>Add <span class="hidden md:inline">Recipients</span></span>
<% end %>
@@ -85,12 +80,19 @@
<% if can?(:create, Submission) %>
<%= link_to new_template_submission_path(@template), class: 'base-button mt-6', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %>
<span class="mr-1">Add Recipients</span>
<span class="mr-1">Send to Recipients</span>
<% end %>
<% end %>
<%= link_to start_form_url(slug: @template.slug), class: 'white-button mt-6', target: '_blank', rel: 'noopener' do %>
<%= svg_icon('writing', class: 'w-6 h-6') %>
<span class="mr-1">Sign it Yourself</span>
<% if @template.submitters.size == 1 %>
<%= link_to start_form_url(slug: @template.slug), class: 'white-button mt-6', target: '_blank', rel: 'noopener' do %>
<%= svg_icon('writing', class: 'w-6 h-6') %>
<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 %>
</div>
+7 -5
View File
@@ -3,12 +3,14 @@
<div class="md:flex-grow">
<div class="flex justify-between mb-4">
<h1 class="text-4xl font-bold">Team</h1>
<% if can?(:create, User.new(account: current_account)) %>
<%= link_to new_user_path, class: 'btn btn-primary btn-md gap-2', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('plus', class: 'w-6 h-6') %>
<span>New User</span>
<div class="flex items-center space-x-4">
<% if can?(:create, User.new(account: current_account)) %>
<%= link_to new_user_path, class: 'btn btn-primary btn-md gap-2', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('plus', class: 'w-6 h-6') %>
<span>New User</span>
<% end %>
<% end %>
<% end %>
</div>
</div>
<div class="overflow-x-auto">
<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| %>
<%= 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">
<%= 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' %>
</div>
<% end %>
+2 -5
View File
@@ -273,11 +273,6 @@ Devise.setup do |config|
# The default HTTP method used to sign out a resource. Default is :delete.
config.sign_out_via = :delete
# ==> OmniAuth
# Add a new OmniAuth provider. Check the wiki for more information on setting
# up on your models and hooks.
config.omniauth :google_oauth2, ENV.fetch('GOOGLE_CLIENT_ID', nil), ENV.fetch('GOOGLE_CLIENT_SECRET', nil), {}
# ==> Warden configuration
# If you want to use other strategies, that are not supported by Devise, or
# change the failure app, you can configure them inside the config.warden block.
@@ -315,4 +310,6 @@ Devise.setup do |config|
# When set to false, does not sign a user in automatically after their password is
# changed. Defaults to true, so a user is signed in automatically after changing a password.
# config.sign_in_after_change_password = true
ActiveSupport.run_load_hooks(:devise_config, config)
end
+6 -3
View File
@@ -10,7 +10,7 @@ Rails.application.routes.draw do
path: '/', only: %i[sessions passwords omniauth_callbacks],
controllers: begin
options = { sessions: 'sessions', passwords: 'passwords' }
options[:omniauth_callbacks] = 'omniauth_callbacks' if Docuseal.multitenant?
options[:omniauth_callbacks] = 'omniauth_callbacks' if User.devise_modules.include?(:omniauthable)
options
end
@@ -35,20 +35,23 @@ Rails.application.routes.draw do
resources :template_folders_autocomplete, only: %i[index]
resources :submitter_email_clicks, only: %i[create]
resources :submitter_form_views, only: %i[create]
resources :submitters, only: %i[index show]
resources :submitters, only: %i[index show update]
resources :submissions, only: %i[index show create destroy] do
collection do
resources :emails, only: %i[create], controller: 'submissions', as: :submissions_emails
end
end
resources :templates, only: %i[update show index destroy] do
resources :clone, only: %i[create], controller: 'templates_clone'
resources :submissions, only: %i[index create]
resources :documents, only: %i[create], controller: 'templates_documents'
end
end
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 :setup, only: %i[index create]
resource :newsletter, only: %i[show update]
+1 -1
View File
@@ -15,7 +15,7 @@ aws_s3:
google:
service: GCS
credentials: <%= JSON.parse(ENV['GCS_CREDENTIALS'] || '{}') %>
credentials: <%= ENV['GCS_CREDENTIALS'] || '{}' %>
project: <%= ENV['GCS_PROJECT'] %>
bucket: <%= ENV['GCS_BUCKET'] %>
public: <%= ENV['ACTIVE_STORAGE_PUBLIC'] == 'true' %>
+15
View File
@@ -71,6 +71,21 @@ module Accounts
end
end
def load_timeserver_url(account)
if Docuseal.multitenant?
Docuseal::TIMESERVER_URL
else
url = EncryptedConfig.find_by(account:, key: EncryptedConfig::TIMESTAMP_SERVER_URL_KEY)&.value
unless Docuseal.multitenant?
url ||=
Account.order(:id).first.encrypted_configs.find_by(key: EncryptedConfig::TIMESTAMP_SERVER_URL_KEY)&.value
end
url
end.presence
end
def can_send_emails?(_account)
return true if Docuseal.multitenant?
return true if ENV['SMTP_ADDRESS'].present?
+1
View File
@@ -28,6 +28,7 @@ module Docuseal
end
CERTS = JSON.parse(ENV.fetch('CERTS', '{}'))
TIMESERVER_URL = ENV.fetch('TIMESERVER_URL', nil)
DEFAULT_URL_OPTIONS = {
host: HOST,
+47
View File
@@ -0,0 +1,47 @@
# frozen_string_literal: true
class PdfProcessor < HexaPDF::Content::Processor
attr_reader :result, :contents
attr_accessor :handler
def initialize(page)
super
@contents = ''.b
@result = []
@serializer = HexaPDF::Serializer.new
end
def process(operator, operands = [])
super
contents << @operators[operator].serialize(
@serializer,
*handler.call(self, operator, operands)
)
end
def self.call(data, pdf_handler, result_handler, acc = {})
doc = HexaPDF::Document.new(io: StringIO.new(data))
doc.pages.each do |page|
processor = PdfProcessor.new(page)
processor.handler = pdf_handler
page.process_contents(processor)
page.contents = processor.contents
processor.result.each do |item|
result_handler.call(item, page, acc)
end
end
new_io = StringIO.new
doc.write(new_io)
[new_io.tap(&:rewind).read, acc]
end
end
+10 -2
View File
@@ -1,24 +1,31 @@
# frozen_string_literal: true
module ReplaceEmailVariables
TEMAPLTE_NAME = '{{template.name}}'
TEMPLATE_NAME = '{{template.name}}'
TEMPLATE_ID = '{{template.id}}'
SUBMITTER_LINK = '{{submitter.link}}'
ACCOUNT_NAME = '{{account.name}}'
SUBMITTER_EMAIL = '{{submitter.email}}'
SUBMITTER_NAME = '{{submitter.name}}'
SUBMITTER_ID = '{{submitter.id}}'
SUBMISSION_LINK = '{{submission.link}}'
SUBMISSION_ID = '{{submission.id}}'
SUBMISSION_SUBMITTERS = '{{submission.submitters}}'
DOCUMENTS_LINKS = '{{documents.links}}'
DOCUMENTS_LINK = '{{documents.link}}'
module_function
# rubocop:disable Metrics
def call(text, submitter:, tracking_event_type: 'click_email')
submitter_link = build_submitter_link(submitter, tracking_event_type)
submission_link = build_submission_link(submitter.submission) if submitter.submission
text = text.gsub(TEMAPLTE_NAME, submitter.template.name) if submitter.template
text = text.gsub(TEMPLATE_NAME, submitter.template.name) if submitter.template
text = text.gsub(TEMPLATE_ID, submitter.template.id.to_s) if submitter.template
text = text.gsub(SUBMITTER_ID, submitter.id.to_s)
text = text.gsub(SUBMISSION_ID, submitter.submission.id.to_s) if submitter.submission
text = text.gsub(SUBMITTER_EMAIL, submitter.email) if submitter.email
text = text.gsub(SUBMITTER_NAME, submitter.name || submitter.email || submitter.phone)
text = text.gsub(SUBMITTER_LINK, submitter_link)
@@ -33,6 +40,7 @@ module ReplaceEmailVariables
text
end
# rubocop:enable Metrics
def build_documents_links_text(submitter)
Rails.application.routes.url_helpers.submissions_preview_url(
+1
View File
@@ -7,6 +7,7 @@ module SubmissionEvents
send_email: 'Email sent',
send_reminder_email: 'Reminder email sent',
send_sms: 'SMS sent',
send_2fa_sms: 'Verification SMS sent',
open_email: 'Email opened',
click_email: 'Email link clicked',
click_sms: 'SMS link clicked',
+14 -6
View File
@@ -8,8 +8,15 @@ module Submissions
preferences = Submitters.normalize_preferences(template.account, user, params)
Array.wrap(submissions_attrs).map do |attrs|
submission_preferences = Submitters.normalize_preferences(template.account, user, attrs)
submission_preferences = preferences.merge(submission_preferences)
set_submission_preferences = submission_preferences.slice('send_email')
set_submission_preferences['send_email'] = true if params['send_completed_email']
submission = template.submissions.new(created_by_user: user, source:,
template_submitters: template.submitters, submitters_order:)
preferences: set_submission_preferences,
template_submitters: [], submitters_order:)
maybe_set_template_fields(submission, attrs[:submitters])
@@ -17,11 +24,12 @@ module Submissions
uuid = find_submitter_uuid(template, submitter_attrs, index)
next if uuid.blank?
next if submitter_attrs.slice('email', 'phone', 'name').compact_blank.blank?
submission.template_submitters << template.submitters.find { |e| e['uuid'] == uuid }
is_order_sent = submitters_order == 'random' || index.zero?
submission_preferences = Submitters.normalize_preferences(template.account, user, attrs)
build_submitter(submission:, attrs: submitter_attrs, uuid:,
is_order_sent:, mark_as_sent:, user:,
preferences: preferences.merge(submission_preferences))
@@ -31,11 +39,11 @@ module Submissions
end
end
def maybe_set_template_fields(submission, submitters_attrs)
template_fields = submission.template.fields.deep_dup
def maybe_set_template_fields(submission, submitters_attrs, submitter_uuid: nil)
template_fields = (submission.template_fields || submission.template.fields).deep_dup
submitters_attrs.each_with_index do |submitter_attrs, index|
submitter_uuid = find_submitter_uuid(submission.template, submitter_attrs, index)
submitter_uuid ||= find_submitter_uuid(submission.template, submitter_attrs, index)
process_readonly_fields_param(submitter_attrs[:readonly_fields], template_fields, submitter_uuid)
+42 -20
View File
@@ -19,19 +19,22 @@ module Submissions
INFO_CREATOR = "#{Docuseal.product_name} (#{Docuseal::PRODUCT_URL})".freeze
SIGN_REASON = 'Signed with DocuSeal.co'
VERIFIED_TEXT = if Docuseal.multitenant?
'Verified by DocuSeal'
else
'Verified'
end
VERIFIED_TEXT = 'Verified'
UNVERIFIED_TEXT = 'Unverified'
CURRENCY_SYMBOLS = {
'USD' => '$',
'EUR' => '€',
'GBP' => '£'
}.freeze
module_function
# rubocop:disable Metrics
def call(submission)
account = submission.template.account
pkcs = Accounts.load_signing_pkcs(account)
tsa_url = Accounts.load_timeserver_url(account)
verify_url = Rails.application.routes.url_helpers.settings_esign_url(**Docuseal.default_url_options)
composer = HexaPDF::Composer.new(skip_page_creation: true)
@@ -63,7 +66,7 @@ module Submissions
composer.new_page
composer.column(columns: 1) do |column|
add_logo(column, account)
add_logo(column, submission)
column.text('Audit Log',
font_size: 16,
@@ -73,9 +76,11 @@ module Submissions
composer.column(columns: 1) do |column|
column.text("Envelope ID: #{submission.id}", font_size: 12, padding: [20, 0, 10, 0], position: :float)
column.formatted_text([
{ link: verify_url, text: 'Verify', style: :link }
], font_size: 9, padding: [22, 0, 10, 0], position: :float, align: :right)
unless submission.source_embed?
column.formatted_text([{ link: verify_url, text: 'Verify', style: :link }],
font_size: 9, padding: [22, 0, 10, 0], position: :float, align: :right)
end
end
composer.draw_box(divider)
@@ -151,11 +156,11 @@ module Submissions
[
composer.document.layout.formatted_text_box(
[
submitter.email && {
text: "Email verification: #{click_email_event ? VERIFIED_TEXT : UNVERIFIED_TEXT}\n"
submitter.email && click_email_event && {
text: "Email verification: #{VERIFIED_TEXT}\n"
},
submitter.phone && {
text: "Phone verification: #{is_phone_verified ? VERIFIED_TEXT : UNVERIFIED_TEXT}\n"
submitter.phone && is_phone_verified && {
text: "Phone verification: #{VERIFIED_TEXT}\n"
},
completed_event.data['ip'] && { text: "IP: #{completed_event.data['ip']}\n" },
completed_event.data['sid'] && { text: "Session ID: #{completed_event.data['sid']}\n" },
@@ -201,7 +206,15 @@ module Submissions
composer.image(io, width:, height:, margin: [0, 0, 10, 0])
composer.formatted_text_box([{ text: '' }])
elsif field['type'] == 'file'
elsif field['type'].in?(%w[file payment])
if field['type'] == 'payment'
unit = CURRENCY_SYMBOLS[field['preferences']['currency']]
price = ApplicationController.helpers.number_to_currency(field['preferences']['price'], unit:)
composer.formatted_text_box([{ text: "Paid #{price}\n" }], padding: [0, 0, 10, 0])
end
composer.formatted_text_box(
Array.wrap(value).map do |uuid|
attachment = submitter.attachments.find { |a| a.uuid == uuid }
@@ -215,7 +228,10 @@ module Submissions
elsif field['type'] == 'checkbox'
composer.formatted_text_box([{ text: value.to_s.titleize }], padding: [0, 0, 10, 0])
else
value = I18n.l(Date.parse(value), format: :long, locale: account.locale) if field['type'] == 'date'
if field['type'] == 'date'
value = TimeUtils.format_date_string(value, field.dig('preferences', 'format'), account.locale)
end
value = value.join(', ') if value.is_a?(Array)
composer.formatted_text_box([{ text: value.to_s.presence || 'n/a' }], padding: [0, 0, 10, 0])
@@ -254,10 +270,16 @@ module Submissions
composer.document.trailer.info[:Creator] = INFO_CREATOR
composer.document.sign(io, reason: SIGN_REASON,
certificate: pkcs.certificate,
key: pkcs.key,
certificate_chain: pkcs.ca_certs || [])
sign_params = {
reason: SIGN_REASON,
certificate: pkcs.certificate,
key: pkcs.key,
certificate_chain: pkcs.ca_certs || []
}
sign_params[:timestamp_handler] = Submissions::TimestampHandler.new(tsa_url:) if tsa_url
composer.document.sign(io, **sign_params)
ActiveStorage::Attachment.create!(
blob: ActiveStorage::Blob.create_and_upload!(
@@ -268,7 +290,7 @@ module Submissions
)
end
def add_logo(column, _account = nil)
def add_logo(column, _submission = nil)
column.image(PdfIcons.logo_io, width: 40, height: 40, position: :float)
column.formatted_text([{ text: 'DocuSeal',
+29 -11
View File
@@ -29,6 +29,7 @@ module Submissions
account = submitter.submission.template.account
pkcs = Accounts.load_signing_pkcs(account)
tsa_url = Accounts.load_timeserver_url(account)
pdfs_index = build_pdfs_index(submitter)
@@ -38,11 +39,18 @@ module Submissions
field.fetch('areas', []).each do |area|
pdf = pdfs_index[area['attachment_uuid']]
next if pdf.nil?
page = pdf.pages[area['page']]
next if page.nil?
page.rotate(0, flatten: true) if page[:Rotate] != 0
page[:Annots] ||= []
page[:Annots] = page[:Annots].reject { |e| e[:A] && e[:A][:URI].to_s.starts_with?('file:///docuseal_field') }
page[:Annots] = page[:Annots].reject do |e|
e.present? && e[:A] && e[:A][:URI].to_s.starts_with?('file:///docuseal_field')
end
width = page.box.width
height = page.box.height
@@ -66,7 +74,7 @@ module Submissions
scale = [(area['w'] * width) / image.width,
(area['h'] * height) / image.height].min
io = StringIO.new(image.resize([scale * 4, 1].min).write_to_buffer('.png'))
io = StringIO.new(image.resize([scale * 4, 1].select(&:positive?).min).write_to_buffer('.png'))
canvas.image(
io,
@@ -77,7 +85,7 @@ module Submissions
width: image.width * scale,
height: image.height * scale
)
when 'file'
when 'file', 'payment'
items = Array.wrap(value).each_with_object([]) do |uuid, acc|
attachment = submitter.attachments.find { |a| a.uuid == uuid }
@@ -156,7 +164,9 @@ module Submissions
height - (area['y'] * height))
end
else
value = I18n.l(Date.parse(value), format: :default, locale: account.locale) if field['type'] == 'date'
if field['type'] == 'date'
value = TimeUtils.format_date_string(value, field.dig('preferences', 'format'), account.locale)
end
text = HexaPDF::Layout::TextFragment.create(Array.wrap(value).join(', '), font: pdf.fonts.add(FONT_NAME),
font_size:)
@@ -190,7 +200,9 @@ module Submissions
submitter.submission.template_schema.map do |item|
pdf = pdfs_index[item['attachment_uuid']]
attachment = save_signed_pdf(pdf:, submitter:, pkcs:, uuid: item['attachment_uuid'], name: item['name'])
attachment = save_signed_pdf(pdf:, submitter:, pkcs:, tsa_url:,
uuid: item['attachment_uuid'],
name: item['name'])
image_pdfs << pdf if original_documents.find { |a| a.uuid == item['attachment_uuid'] }.image?
@@ -217,15 +229,21 @@ module Submissions
end
# rubocop:enable Metrics
def save_signed_pdf(pdf:, submitter:, pkcs:, uuid:, name:)
def save_signed_pdf(pdf:, submitter:, pkcs:, tsa_url:, uuid:, name:)
io = StringIO.new
pdf.trailer.info[:Creator] = info_creator
pdf.sign(io, reason: sign_reason(submitter.email),
certificate: pkcs.certificate,
key: pkcs.key,
certificate_chain: pkcs.ca_certs || [])
sign_params = {
reason: sign_reason(submitter.email),
certificate: pkcs.certificate,
key: pkcs.key,
certificate_chain: pkcs.ca_certs || []
}
sign_params[:timestamp_handler] = Submissions::TimestampHandler.new(tsa_url:) if tsa_url
pdf.sign(io, **sign_params)
ActiveStorage::Attachment.create!(
uuid:,
@@ -252,7 +270,7 @@ module Submissions
Submissions::EnsureResultGenerated.call(latest_submitter) if latest_submitter
documents = latest_submitter&.documents&.preload(:blob).to_a.presence
documents ||= submitter.submission.template.documents.preload(:blob)
documents ||= submitter.submission.template_schema_documents.preload(:blob)
documents.to_h do |attachment|
pdf =
+61
View File
@@ -0,0 +1,61 @@
# frozen_string_literal: true
module Submissions
module NormalizeParamUtils
module_function
def normalize_submissions_params!(submissions_params, template)
attachments = []
Array.wrap(submissions_params).each do |submission|
submission[:submitters].each_with_index do |submitter, index|
_, new_attachments = normalize_submitter_params!(submitter, template, index)
attachments.push(*new_attachments)
end
end
[submissions_params, attachments]
end
def normalize_submitter_params!(submitter_params, template, index = nil, for_submitter: nil)
default_values = submitter_params[:values] || {}
submitter_params[:fields]&.each do |f|
default_values[f[:name]] = f[:default_value] if f[:default_value].present?
end
return submitter_params if default_values.blank?
values, new_attachments =
Submitters::NormalizeValues.call(template,
default_values,
submitter_name: submitter_params[:role] ||
template.submitters.dig(index, 'name'),
for_submitter:,
throw_errors: true)
submitter_params[:values] = values
[submitter_params, new_attachments]
end
def save_default_value_attachments!(attachments, submitters)
return if attachments.blank?
attachments_index = attachments.index_by(&:uuid)
submitters.each do |submitter|
submitter.values.to_a.each do |_, value|
attachment = attachments_index[value]
next unless attachment
attachment.record = submitter
attachment.save!
end
end
end
end
end
+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
+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
+34 -16
View File
@@ -9,25 +9,25 @@ module Submitters
module_function
def call(template, values, submitter_name)
submitter =
template.submitters.find { |e| e['name'] == submitter_name } ||
raise(UnknownSubmitterName, "Unknown submitter: #{submitter_name}")
fields = template.fields.select { |e| e['submitter_uuid'] == submitter['uuid'] }
def call(template, values, submitter_name: nil, for_submitter: nil, throw_errors: false)
fields = fetch_fields(template, submitter_name:, for_submitter:)
fields_uuid_index = fields.index_by { |e| e['uuid'] }
fields_name_index = build_fields_index(fields)
attachments = []
normalized_values = values.to_h.to_h do |key, value|
normalized_values = values.to_h.filter_map do |key, value|
if fields_uuid_index[key].blank?
key = fields_name_index[key]&.dig('uuid') || raise(UnknownFieldName, "Unknown field: #{key}")
key = fields_name_index[key]&.dig('uuid')
raise(UnknownFieldName, "Unknown field: #{key}") if key.blank? && throw_errors
end
next if key.blank?
if fields_uuid_index[key]['type'].in?(%w[initials signature image file])
new_value, new_attachments = normalize_attachment_value(value, template.account)
new_value, new_attachments = normalize_attachment_value(value, template.account, for_submitter)
attachments.push(*new_attachments)
@@ -35,32 +35,50 @@ module Submitters
end
[key, value]
end
end.to_h
[normalized_values, attachments]
end
def fetch_fields(template, submitter_name: nil, for_submitter: nil)
if submitter_name
submitter =
template.submitters.find { |e| e['name'] == submitter_name } ||
raise(UnknownSubmitterName, "Unknown submitter: #{submitter_name}")
end
fields = for_submitter&.submission&.template_fields || template.fields
fields.select { |e| e['submitter_uuid'] == (for_submitter&.uuid || submitter['uuid']) }
end
def build_fields_index(fields)
fields.index_by { |e| e['name'] }.merge(fields.index_by { |e| e['name'].to_s.parameterize.underscore })
end
def normalize_attachment_value(value, account)
def normalize_attachment_value(value, account, for_submitter = nil)
if value.is_a?(Array)
new_attachments = value.map { |v| build_attachment(v, account) }
new_attachments = value.map { |v| find_or_build_attachment(v, account, for_submitter) }
[new_attachments.map(&:uuid), new_attachments]
else
new_attachment = build_attachment(value, account)
new_attachment = find_or_build_attachment(value, account, for_submitter)
[new_attachment.uuid, new_attachment]
end
end
def build_attachment(value, account)
ActiveStorage::Attachment.new(
blob: find_or_create_blobs(account, value),
def find_or_build_attachment(value, account, for_submitter = nil)
blob = find_or_create_blobs(account, value)
attachment = for_submitter.attachments.find_by(blob_id: blob.id) if for_submitter
attachment ||= ActiveStorage::Attachment.new(
blob:,
name: 'attachments'
)
attachment
end
def find_or_create_blobs(account, url)
+2 -4
View File
@@ -21,9 +21,7 @@ module Submitters
submitter.submission.save!
return submitter unless submitter.completed_at?
ProcessSubmitterCompletionJob.perform_later(submitter)
ProcessSubmitterCompletionJob.perform_later(submitter) if submitter.completed_at?
submitter
end
@@ -101,7 +99,7 @@ module Submitters
def replace_default_variables(value, attrs, template, with_time: false)
return if value.blank?
value.gsub(VARIABLE_REGEXP) do |e|
value.to_s.gsub(VARIABLE_REGEXP) do |e|
case key = ::Regexp.last_match(1)
when 'time'
if with_time
+36
View File
@@ -1,6 +1,26 @@
# frozen_string_literal: true
module TimeUtils
MONTH_FORMATS = {
'M' => '%-m',
'MM' => '%m',
'MMM' => '%b',
'MMMM' => '%B'
}.freeze
DAY_FORMATS = {
'D' => '%-d',
'DD' => '%d'
}.freeze
YEAR_FORMATS = {
'YYYY' => '%Y',
'YY' => '%y'
}.freeze
DEFAULT_DATE_FORMAT_US = 'MM/DD/YYYY'
DEFAULT_DATE_FORMAT = 'DD/MM/YYYY'
module_function
def timezone_abbr(timezone, time = Time.current)
@@ -10,4 +30,20 @@ module TimeUtils
tz_info.abbreviation(time)
end
def format_date_string(string, format, locale)
date = Date.parse(string)
format ||= locale.to_s.ends_with?('US') ? DEFAULT_DATE_FORMAT_US : DEFAULT_DATE_FORMAT
i18n_format = format.sub(/D+/, DAY_FORMATS[format[/D+/]])
.sub(/M+/, MONTH_FORMATS[format[/M+/]])
.sub(/Y+/, YEAR_FORMATS[format[/Y+/]])
I18n.l(date, format: i18n_format, locale:)
rescue Date::Error => e
Rollbar.error(e) if defined?(Rollbar)
string
end
end
+13 -4
View File
@@ -4,12 +4,21 @@ module Users
module_function
def from_omniauth(oauth)
user = User.find_by(email: oauth.info.email)
user = User.find_by(email: oauth.info.email.to_s.downcase)
return user if user
User.new(email: oauth.info.email,
first_name: oauth.extra.id_info.given_name,
last_name: oauth.extra.id_info.family_name)
case oauth['provider'].to_s
when 'google_oauth2'
User.new(email: oauth.info.email,
first_name: oauth.extra.id_info.given_name,
last_name: oauth.extra.id_info.family_name)
when 'microsoft_office365'
User.new(email: oauth.info.email,
first_name: oauth.info.first_name,
last_name: oauth.info.last_name)
when 'github'
User.new(email: oauth.info.email, first_name: oauth.info.name)
end
end
end
+2 -3
View File
@@ -18,7 +18,6 @@ RSpec.describe 'Template' do
expect(page).to have_content(template.name)
expect(page).to have_content('There are no Submissions')
expect(page).to have_content('Send an invitation to fill and complete the form')
expect(page).to have_link('Add Recipients', href: new_template_submission_path(template))
expect(page).to have_link('Sign it Yourself')
end
end
@@ -43,10 +42,10 @@ RSpec.describe 'Template' do
visit template_path(template)
end
it 'removes a template' do
it 'archives a template' do
expect do
accept_confirm('Are you sure?') do
click_button 'Remove'
click_button 'Archive'
end
end.to change { Template.active.count }.by(-1)