mirror of
https://github.com/docusealco/docuseal
synced 2026-11-04 06:42:32 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c8fae78d1 | ||
|
|
441c1d7044 | ||
|
|
203f4633f7 | ||
|
|
e558f4d9fb | ||
|
|
990e4020e9 | ||
|
|
1ad3e45519 | ||
|
|
442ced8381 | ||
|
|
ba46202a4d | ||
|
|
1c77a1ee90 | ||
|
|
ea74b59535 | ||
|
|
0e9343d9e2 | ||
|
|
af4cf7257f | ||
|
|
c8d4ee58f8 | ||
|
|
65e57c22e5 | ||
|
|
7351ae0a77 | ||
|
|
f1e0d5d137 | ||
|
|
8a1b71688c | ||
|
|
8ef1c309e0 | ||
|
|
a32dcc0dc5 | ||
|
|
16a2fde4b7 | ||
|
|
7856f7a134 | ||
|
|
c545924fe9 | ||
|
|
b1510bb88c | ||
|
|
31d2024ce5 | ||
|
|
23c49f8706 | ||
|
|
62eb0cda6b | ||
|
|
9f48c6e625 | ||
|
|
83ce401cf2 | ||
|
|
541b025ad0 | ||
|
|
5fd018df46 | ||
|
|
2822b0086a | ||
|
|
e27606f443 | ||
|
|
6c95cd5945 | ||
|
|
d723146060 | ||
|
|
3b03e61ae3 | ||
|
|
172513b96c | ||
|
|
68be42a48d | ||
|
|
0370e363bc | ||
|
|
ced88476cc | ||
|
|
c5d71505ef | ||
|
|
489f9859be | ||
|
|
a87ca65839 | ||
|
|
fa78d0976d | ||
|
|
4aaab2563b | ||
|
|
e073cf2c27 | ||
|
|
173d406b1f | ||
|
|
3bcbad6f04 | ||
|
|
e67854fe56 | ||
|
|
7510e0cb6b | ||
|
|
30c913b54f | ||
|
|
2cda9cd736 | ||
|
|
342d29f272 |
No files matched your search
+1
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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: '')
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
:key="areaIndex"
|
||||
>
|
||||
<Teleport
|
||||
v-if="findPageElementForArea(area)"
|
||||
:to="findPageElementForArea(area)"
|
||||
>
|
||||
<FieldArea
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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.
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 |
@@ -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,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 %>
|
||||
@@ -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,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>
|
||||
@@ -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' %>
|
||||
@@ -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) %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) %>
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) %>
|
||||
|
||||
@@ -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()]">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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
@@ -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
@@ -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' %>
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,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 =
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user