mirror of
https://github.com/docusealco/docuseal
synced 2026-11-04 14:42:32 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dd5bbe25a | ||
|
|
3975c963c5 | ||
|
|
1f41807412 | ||
|
|
872fbbc875 | ||
|
|
b98a874e20 | ||
|
|
50e123c221 | ||
|
|
7f97bfb3bd | ||
|
|
784665b549 | ||
|
|
c651709e45 | ||
|
|
881a2acbfc | ||
|
|
c49cb4b0c8 | ||
|
|
17b8354c40 | ||
|
|
dc7593c70c | ||
|
|
d2edb2fb69 | ||
|
|
154a30b60b | ||
|
|
392c5c1c54 | ||
|
|
cad0d0f371 | ||
|
|
cb879738c0 | ||
|
|
abfa64140f | ||
|
|
db1e2b9aa2 | ||
|
|
c1ab4fafb2 | ||
|
|
7ba3a604bd | ||
|
|
cf85bea0df | ||
|
|
5643094a7a | ||
|
|
380f553a17 | ||
|
|
f8712a9da9 | ||
|
|
ef8c3cfe7f | ||
|
|
9bcf5aacc0 | ||
|
|
54592e7264 | ||
|
|
f1f4de53c6 | ||
|
|
ee350324e5 | ||
|
|
dc02964d9b | ||
|
|
0424b93fe7 | ||
|
|
905e0b22ed | ||
|
|
d757f06ff4 | ||
|
|
132999176c | ||
|
|
2f74caf16a | ||
|
|
41d3eaf582 | ||
|
|
907aaa9c30 | ||
|
|
7bb241815f | ||
|
|
84e879bad0 | ||
|
|
a1a0205d33 | ||
|
|
6f8e88cdbd | ||
|
|
2f1843151d | ||
|
|
f89c50d096 | ||
|
|
ef618f8f49 | ||
|
|
b3662ddea8 | ||
|
|
53fa35fd01 | ||
|
|
7f6c0af0e8 | ||
|
|
3faec9d174 |
No files matched your search
@@ -138,4 +138,5 @@ jobs:
|
||||
run: |
|
||||
bundle exec rake db:create
|
||||
bundle exec rake db:migrate
|
||||
bundle exec rake assets:precompile
|
||||
bundle exec rspec
|
||||
@@ -7,9 +7,13 @@ ruby '3.2.2'
|
||||
gem 'aws-sdk-s3', require: false
|
||||
gem 'azure-storage-blob', require: false
|
||||
gem 'bootsnap', require: false
|
||||
gem 'cancancan'
|
||||
gem 'devise'
|
||||
gem 'devise-two-factor'
|
||||
gem 'dotenv', require: false
|
||||
gem 'email_typo'
|
||||
gem 'faraday'
|
||||
gem 'faraday-follow_redirects'
|
||||
gem 'google-cloud-storage', require: false
|
||||
gem 'hexapdf'
|
||||
gem 'image_processing'
|
||||
@@ -28,6 +32,8 @@ gem 'rails'
|
||||
gem 'rails_autolink'
|
||||
gem 'rails-i18n'
|
||||
gem 'rollbar', require: ENV.key?('ROLLBAR_ACCESS_TOKEN')
|
||||
gem 'rotp'
|
||||
gem 'rqrcode'
|
||||
gem 'ruby-vips'
|
||||
gem 'rubyXL'
|
||||
gem 'shakapacker'
|
||||
|
||||
@@ -114,6 +114,7 @@ GEM
|
||||
bullet (7.0.7)
|
||||
activesupport (>= 3.0.0)
|
||||
uniform_notifier (~> 1.11)
|
||||
cancancan (3.5.0)
|
||||
capybara (3.39.2)
|
||||
addressable
|
||||
matrix
|
||||
@@ -123,6 +124,7 @@ GEM
|
||||
rack-test (>= 0.6.3)
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
chunky_png (1.4.0)
|
||||
cmdparse (3.0.7)
|
||||
coderay (1.1.3)
|
||||
concurrent-ruby (1.2.2)
|
||||
@@ -146,11 +148,17 @@ GEM
|
||||
railties (>= 4.1.0)
|
||||
responders
|
||||
warden (~> 1.2.3)
|
||||
devise-two-factor (5.0.0)
|
||||
activesupport (~> 7.0)
|
||||
devise (~> 4.0)
|
||||
railties (~> 7.0)
|
||||
rotp (~> 6.0)
|
||||
diff-lcs (1.5.0)
|
||||
digest-crc (0.6.5)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
docile (1.4.0)
|
||||
dotenv (2.8.1)
|
||||
email_typo (0.2.3)
|
||||
erb_lint (0.4.0)
|
||||
activesupport
|
||||
better_html (>= 2.0.1)
|
||||
@@ -181,6 +189,8 @@ GEM
|
||||
faraday-em_http (1.0.0)
|
||||
faraday-em_synchrony (1.0.0)
|
||||
faraday-excon (1.1.0)
|
||||
faraday-follow_redirects (0.3.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-httpclient (1.0.1)
|
||||
faraday-multipart (1.0.4)
|
||||
multipart-post (~> 2)
|
||||
@@ -423,6 +433,11 @@ GEM
|
||||
retriable (3.1.2)
|
||||
rexml (3.2.6)
|
||||
rollbar (3.4.0)
|
||||
rotp (6.3.0)
|
||||
rqrcode (2.2.0)
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 1.0)
|
||||
rqrcode_core (1.2.0)
|
||||
rspec-core (3.12.2)
|
||||
rspec-support (~> 3.12.0)
|
||||
rspec-expectations (3.12.3)
|
||||
@@ -555,15 +570,19 @@ DEPENDENCIES
|
||||
better_html
|
||||
bootsnap
|
||||
bullet
|
||||
cancancan
|
||||
capybara
|
||||
cuprite
|
||||
debug
|
||||
devise
|
||||
devise-two-factor
|
||||
dotenv
|
||||
email_typo
|
||||
erb_lint
|
||||
factory_bot_rails
|
||||
faker
|
||||
faraday
|
||||
faraday-follow_redirects
|
||||
google-cloud-storage
|
||||
hexapdf
|
||||
image_processing
|
||||
@@ -584,6 +603,8 @@ DEPENDENCIES
|
||||
rails-i18n
|
||||
rails_autolink
|
||||
rollbar
|
||||
rotp
|
||||
rqrcode
|
||||
rspec-rails
|
||||
rubocop
|
||||
rubocop-performance
|
||||
|
||||
@@ -52,8 +52,8 @@ DocuSeal is an open source platform that provides secure and efficient digital d
|
||||
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.app/template/IGoDnc?referralCode=ruU7JR)|
|
||||
|**DigitalOcean**|**Render**|
|
||||
| [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [<img alt="Deploy to Render" src="https://render.com/images/deploy-to-render-button.svg" height="40">](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render)
|
||||
|**Koyeb**| |
|
||||
| [<img alt="Deploy on Koyeb" src="https://www.koyeb.com/static/images/deploy/button.svg" height="40">](https://app.koyeb.com/deploy?name=docuseal&type=docker&image=docker.io/docuseal/docuseal&env[PORT]=8000&env[DATABASE_URL]=CHANGE_ME&env[SECRET_KEY_BASE]=CHANGE_ME&ports=8000;http;/) | |
|
||||
|**Koyeb**|**Elestio**|
|
||||
| [<img alt="Deploy on Koyeb" src="https://www.koyeb.com/static/images/deploy/button.svg" height="40">](https://app.koyeb.com/deploy?name=docuseal&type=docker&image=docker.io/docuseal/docuseal&env[PORT]=8000&env[DATABASE_URL]=CHANGE_ME&env[SECRET_KEY_BASE]=CHANGE_ME&ports=8000;http;/) | [<img alt="Deploy on Elestio" src="https://pub-da36157c854648669813f3f76c526c2b.r2.dev/deploy-on-elestio-black.png">](https://dash.elest.io/deploy?soft=DocuSeal&id=339) |
|
||||
|
||||
|
||||
#### Docker
|
||||
|
||||
@@ -9,6 +9,9 @@ class AccountsController < ApplicationController
|
||||
'de-DE' => 'German (Germany)'
|
||||
}.freeze
|
||||
|
||||
before_action :load_account
|
||||
authorize_resource :account
|
||||
|
||||
def show; end
|
||||
|
||||
def update
|
||||
@@ -35,6 +38,10 @@ class AccountsController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def load_account
|
||||
@account = current_account
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.require(:account).permit(:name, :timezone, :locale)
|
||||
end
|
||||
|
||||
@@ -3,11 +3,36 @@
|
||||
module Api
|
||||
class ApiBaseController < ActionController::API
|
||||
include ActiveStorage::SetCurrent
|
||||
include Pagy::Backend
|
||||
|
||||
DEFAULT_LIMIT = 10
|
||||
MAX_LIMIT = 100
|
||||
|
||||
wrap_parameters false
|
||||
|
||||
before_action :authenticate_user!
|
||||
check_authorization
|
||||
|
||||
if Rails.env.production?
|
||||
rescue_from CanCan::AccessDenied do |e|
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
|
||||
render json: { error: e.message }, status: :forbidden
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def paginate(relation)
|
||||
result = relation.order(id: :desc)
|
||||
.limit([params[:limit] || DEFAULT_LIMIT, MAX_LIMIT].min)
|
||||
|
||||
result = result.where('id < ?', params[:after]) if params[:after].present?
|
||||
result = result.where('id > ?', params[:before]) if params[:before].present?
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def current_account
|
||||
current_user&.account
|
||||
end
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
module Api
|
||||
class AttachmentsController < ApiBaseController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
def create
|
||||
submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||
|
||||
@@ -2,74 +2,150 @@
|
||||
|
||||
module Api
|
||||
class SubmissionsController < ApiBaseController
|
||||
UnknownFieldName = Class.new(StandardError)
|
||||
UnknownSubmitterName = Class.new(StandardError)
|
||||
load_and_authorize_resource :template, only: :create
|
||||
load_and_authorize_resource :submission, only: %i[show index]
|
||||
|
||||
before_action only: :create do
|
||||
authorize!(:create, Submission)
|
||||
end
|
||||
|
||||
def index
|
||||
submissions = Submissions.search(@submissions, params[:q])
|
||||
submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present?
|
||||
|
||||
submissions = paginate(submissions.preload(:created_by_user, :template, :submitters))
|
||||
|
||||
render json: {
|
||||
data: submissions.as_json(serialize_params),
|
||||
pagination: {
|
||||
count: submissions.size,
|
||||
next: submissions.last&.id,
|
||||
prev: submissions.first&.id
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def show
|
||||
serialized_subbmitters =
|
||||
@submission.submitters.preload(documents_attachments: :blob, attachments_attachments: :blob).map do |submitter|
|
||||
Submissions::EnsureResultGenerated.call(submitter) if submitter.completed_at?
|
||||
|
||||
Submitters::SerializeForApi.call(submitter)
|
||||
end
|
||||
|
||||
json = @submission.as_json(
|
||||
serialize_params.deep_merge(
|
||||
include: {
|
||||
submission_events: {
|
||||
only: %i[id submitter_id event_type event_timestamp]
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
json[:submitters] = serialized_subbmitters
|
||||
|
||||
render json:
|
||||
end
|
||||
|
||||
def create
|
||||
template = current_account.templates.find(params[:template_id])
|
||||
is_send_email = !params[:send_email].in?(['false', false])
|
||||
|
||||
submissions =
|
||||
if (emails = (params[:emails] || params[:email]).presence)
|
||||
Submissions.create_from_emails(template:,
|
||||
if (emails = (params[:emails] || params[:email]).presence) && params[:submission].blank?
|
||||
Submissions.create_from_emails(template: @template,
|
||||
user: current_user,
|
||||
source: :api,
|
||||
mark_as_sent: params[:send_email] != 'false',
|
||||
mark_as_sent: is_send_email,
|
||||
emails:)
|
||||
else
|
||||
submissions_attrs = normalize_submissions_params!(submissions_params[:submission], template)
|
||||
submissions_attrs, attachments = normalize_submissions_params!(submissions_params[:submission], @template)
|
||||
|
||||
Submissions.create_from_submitters(
|
||||
template:,
|
||||
template: @template,
|
||||
user: current_user,
|
||||
source: :api,
|
||||
mark_as_sent: params[:send_email] != 'false',
|
||||
mark_as_sent: is_send_email,
|
||||
submitters_order: params[:submitters_order] || 'preserved',
|
||||
submissions_attrs:
|
||||
)
|
||||
end
|
||||
|
||||
Submissions.send_signature_requests(submissions, send_email: params[:send_email] != 'false')
|
||||
Submissions.send_signature_requests(submissions, send_email: is_send_email)
|
||||
|
||||
render json: submissions.flat_map(&:submitters)
|
||||
rescue UnknownFieldName, UnknownSubmitterName => e
|
||||
submitters = submissions.flat_map(&:submitters)
|
||||
|
||||
save_default_value_attachments!(attachments, submitters)
|
||||
|
||||
render json: submitters
|
||||
rescue Submitters::NormalizeValues::UnknownFieldName, Submitters::NormalizeValues::UnknownSubmitterName => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def destroy
|
||||
@submission.update!(deleted_at: Time.current)
|
||||
|
||||
render json: @submission.as_json(only: %i[id deleted_at])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def serialize_params
|
||||
{
|
||||
only: %i[id source submitters_order created_at updated_at],
|
||||
include: {
|
||||
submitters: { only: %i[id slug uuid name email phone
|
||||
completed_at opened_at sent_at
|
||||
created_at updated_at],
|
||||
methods: %i[status] },
|
||||
template: { only: %i[id name created_at updated_at] },
|
||||
created_by_user: { only: %i[id email first_name last_name] }
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def submissions_params
|
||||
params.permit(submission: [{ submitters: [[:uuid, :name, :email, :role, :phone, { values: {} }]] }])
|
||||
params.permit(submission: [{
|
||||
submitters: [[:uuid, :name, :email, :role, :completed, :phone, { values: {} }]]
|
||||
}])
|
||||
end
|
||||
|
||||
def normalize_submissions_params!(submissions_params, template)
|
||||
submissions_params.each do |submission|
|
||||
attachments = []
|
||||
|
||||
Array.wrap(submissions_params).each do |submission|
|
||||
submission[:submitters].each_with_index do |submitter, index|
|
||||
next if submitter[:values].blank?
|
||||
|
||||
submitter[:values] =
|
||||
normalize_submitter_values(template,
|
||||
submitter[:values],
|
||||
submitter[:role] || template.submitters[index]['name'])
|
||||
values, new_attachments =
|
||||
Submitters::NormalizeValues.call(template,
|
||||
submitter[:values],
|
||||
submitter[:role] || template.submitters[index]['name'])
|
||||
|
||||
attachments.push(*new_attachments)
|
||||
|
||||
submitter[:values] = values
|
||||
end
|
||||
end
|
||||
|
||||
submissions_params
|
||||
[submissions_params, attachments]
|
||||
end
|
||||
|
||||
def normalize_submitter_values(template, values, submitter_name)
|
||||
submitter =
|
||||
template.submitters.find { |e| e['name'] == submitter_name } ||
|
||||
raise(UnknownSubmitterName, "Unknown submitter: #{submitter_name}")
|
||||
def save_default_value_attachments!(attachments, submitters)
|
||||
return if attachments.blank?
|
||||
|
||||
fields = template.fields.select { |e| e['submitter_uuid'] == submitter['uuid'] }
|
||||
attachments_index = attachments.index_by(&:uuid)
|
||||
|
||||
fields_uuid_index = fields.index_by { |e| e['uuid'] }
|
||||
fields_name_index = fields.index_by { |e| e['name'] }
|
||||
submitters.each do |submitter|
|
||||
submitter.values.to_a.each do |_, value|
|
||||
attachment = attachments_index[value]
|
||||
|
||||
values.transform_keys do |key|
|
||||
next key if fields_uuid_index[key].present?
|
||||
next unless attachment
|
||||
|
||||
fields_name_index[key]&.dig('uuid') || raise(UnknownFieldName, "Unknown field: #{key}")
|
||||
attachment.record = submitter
|
||||
|
||||
attachment.save!
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
module Api
|
||||
class SubmitterEmailClicksController < ApiBaseController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
def create
|
||||
submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
module Api
|
||||
class SubmitterFormViewsController < ApiBaseController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
def create
|
||||
submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||
|
||||
SubmissionEvents.create_with_tracking_data(submitter, 'view_form', request)
|
||||
|
||||
SendFormViewedWebhookRequestJob.perform_later(submitter)
|
||||
|
||||
render json: {}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
class SubmittersAutocompleteController < ApiBaseController
|
||||
load_and_authorize_resource :submitter, parent: false
|
||||
|
||||
SELECT_COLUMNS = %w[email phone name].freeze
|
||||
LIMIT = 100
|
||||
|
||||
def index
|
||||
submitters = search_submitters(@submitters)
|
||||
|
||||
values = submitters.limit(LIMIT).group(SELECT_COLUMNS.join(', ')).pluck(SELECT_COLUMNS.join(', '))
|
||||
|
||||
attrs = values.map { |row| SELECT_COLUMNS.zip(row).to_h }
|
||||
attrs = attrs.uniq { |e| e[params[:field]] } if params[:field].present?
|
||||
|
||||
render json: attrs
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def search_submitters(submitters)
|
||||
if SELECT_COLUMNS.include?(params[:field])
|
||||
column = Submitter.arel_table[params[:field].to_sym]
|
||||
|
||||
term = "%#{params[:q].downcase}%"
|
||||
|
||||
submitters.where(column.lower.matches(term))
|
||||
else
|
||||
Submitters.search(submitters, params[:q])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
class SubmittersController < ApiBaseController
|
||||
load_and_authorize_resource :submitter
|
||||
|
||||
def show
|
||||
Submissions::EnsureResultGenerated.call(@submitter) if @submitter.completed_at?
|
||||
|
||||
render json: Submitters::SerializeForApi.call(@submitter, with_template: true, with_events: true)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
class TemplateFoldersAutocompleteController < ApiBaseController
|
||||
load_and_authorize_resource :template_folder, parent: false
|
||||
|
||||
LIMIT = 100
|
||||
|
||||
def index
|
||||
template_folders = @template_folders.joins(:templates).where(templates: { deleted_at: nil }).distinct
|
||||
template_folders = TemplateFolders.search(template_folders, params[:q]).limit(LIMIT)
|
||||
|
||||
render json: template_folders.as_json(only: %i[name deleted_at])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,25 +2,54 @@
|
||||
|
||||
module Api
|
||||
class TemplatesController < ApiBaseController
|
||||
before_action :load_template, only: %i[show update]
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def index
|
||||
render json: current_account.templates
|
||||
templates = Templates.search(@templates, params[:q])
|
||||
|
||||
templates = params[:archived] ? templates.archived : templates.active
|
||||
|
||||
templates = paginate(templates.preload(:author, documents_attachments: :blob))
|
||||
|
||||
render json: {
|
||||
data: templates.as_json(serialize_params),
|
||||
pagination: {
|
||||
count: templates.size,
|
||||
next: templates.last&.id,
|
||||
prev: templates.first&.id
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def show
|
||||
render json: @template.as_json(include: { author: { only: %i[id email first_name last_name] },
|
||||
documents: { only: %i[id uuid], methods: %i[url filename] } })
|
||||
render json: @template.as_json(serialize_params)
|
||||
end
|
||||
|
||||
def update
|
||||
if (folder_name = params.dig(:template, :folder_name))
|
||||
@template.folder = TemplateFolders.find_or_create_by_name(current_user, folder_name)
|
||||
end
|
||||
|
||||
@template.update!(template_params)
|
||||
|
||||
render :ok
|
||||
render json: @template.as_json(only: %i[id updated_at])
|
||||
end
|
||||
|
||||
def destroy
|
||||
@template.update!(deleted_at: Time.current)
|
||||
|
||||
render json: @template.as_json(only: %i[id deleted_at])
|
||||
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
|
||||
|
||||
def template_params
|
||||
params.require(:template).permit(:name,
|
||||
schema: [%i[attachment_uuid name]],
|
||||
@@ -28,9 +57,5 @@ module Api
|
||||
fields: [[:uuid, :submitter_uuid, :name, :type, :required,
|
||||
{ options: [], areas: [%i[x y w h cell_w attachment_uuid page]] }]])
|
||||
end
|
||||
|
||||
def load_template
|
||||
@template = current_account.templates.find(params[:id])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
module Api
|
||||
class TemplatesDocumentsController < ApiBaseController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def create
|
||||
return head :unprocessable_entity if params[:blobs].blank? && params[:files].blank?
|
||||
|
||||
@template = current_account.templates.find(params[:template_id])
|
||||
|
||||
documents = Templates::CreateAttachments.call(@template, params)
|
||||
|
||||
schema = documents.map do |doc|
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ApiSettingsController < ApplicationController
|
||||
def index; end
|
||||
def index
|
||||
authorize!(:read, current_user.access_token)
|
||||
end
|
||||
end
|
||||
@@ -4,6 +4,8 @@ class ApplicationController < ActionController::Base
|
||||
include ActiveStorage::SetCurrent
|
||||
include Pagy::Backend
|
||||
|
||||
check_authorization unless: :devise_controller?
|
||||
|
||||
before_action :sign_in_for_demo, if: -> { Docuseal.demo? }
|
||||
before_action :maybe_redirect_to_setup, unless: :signed_in?
|
||||
before_action :authenticate_user!, unless: :devise_controller?
|
||||
@@ -16,6 +18,14 @@ class ApplicationController < ActionController::Base
|
||||
redirect_to request.path
|
||||
end
|
||||
|
||||
if Rails.env.production?
|
||||
rescue_from CanCan::AccessDenied do |e|
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
|
||||
redirect_back(fallback_location: root_path, alert: e.message)
|
||||
end
|
||||
end
|
||||
|
||||
def default_url_options
|
||||
Docuseal.default_url_options
|
||||
end
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
class ConsoleRedirectController < ApplicationController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
def index
|
||||
return redirect_to(new_user_session_path({ redir: params[:redir] }.compact)) if current_user.blank?
|
||||
|
||||
@@ -3,12 +3,68 @@
|
||||
class DashboardController < ApplicationController
|
||||
skip_before_action :authenticate_user!, only: %i[index]
|
||||
|
||||
before_action :maybe_redirect_product_url
|
||||
before_action :maybe_render_landing
|
||||
|
||||
load_and_authorize_resource :template_folder, parent: false
|
||||
load_and_authorize_resource :template, parent: false
|
||||
|
||||
SHOW_TEMPLATES_FOLDERS_THRESHOLD = 9
|
||||
TEMPLATES_PER_PAGE = 12
|
||||
FOLDERS_PER_PAGE = 18
|
||||
|
||||
def index
|
||||
return redirect_to Docuseal::PRODUCT_URL, allow_other_host: true if Docuseal.multitenant? && !signed_in?
|
||||
return render 'pages/landing' unless signed_in?
|
||||
@template_folders = filter_template_folders(@template_folders)
|
||||
|
||||
templates = current_account.templates.active.preload(:author).order(id: :desc)
|
||||
@pagy, @template_folders = pagy(
|
||||
@template_folders,
|
||||
items: FOLDERS_PER_PAGE,
|
||||
page: @template_folders.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD ? params[:page] : 1
|
||||
)
|
||||
|
||||
@pagy, @templates = pagy(templates, items: 12)
|
||||
if @pagy.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD
|
||||
@templates = @templates.none
|
||||
else
|
||||
@template_folders = @template_folders.reject { |e| e.name == TemplateFolder::DEFAULT_NAME }
|
||||
@templates = filter_templates(@templates)
|
||||
|
||||
items =
|
||||
if @template_folders.size < 4
|
||||
TEMPLATES_PER_PAGE
|
||||
else
|
||||
(@template_folders.size < 7 ? 9 : 6)
|
||||
end
|
||||
|
||||
@pagy, @templates = pagy(@templates, items:)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def filter_template_folders(template_folders)
|
||||
rel = template_folders.joins(:active_templates)
|
||||
.order(id: :desc)
|
||||
.distinct
|
||||
|
||||
TemplateFolders.search(rel, params[:q])
|
||||
end
|
||||
|
||||
def filter_templates(templates)
|
||||
rel = templates.active.preload(:author).order(id: :desc)
|
||||
rel = rel.where(folder_id: current_account.default_template_folder.id) if params[:q].blank?
|
||||
|
||||
Templates.search(rel, params[:q])
|
||||
end
|
||||
|
||||
def maybe_redirect_product_url
|
||||
return if !Docuseal.multitenant? || signed_in?
|
||||
|
||||
redirect_to Docuseal::PRODUCT_URL, allow_other_host: true
|
||||
end
|
||||
|
||||
def maybe_render_landing
|
||||
return if signed_in?
|
||||
|
||||
render 'pages/landing'
|
||||
end
|
||||
end
|
||||
+3
-1
@@ -1,7 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class EmailSettingsController < ApplicationController
|
||||
class EmailSmtpSettingsController < ApplicationController
|
||||
before_action :load_encrypted_config
|
||||
authorize_resource :encrypted_config, only: :index
|
||||
authorize_resource :encrypted_config, parent: false, only: :create
|
||||
|
||||
def index; end
|
||||
|
||||
@@ -11,9 +11,12 @@ class EsignSettingsController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
before_action :load_encrypted_config
|
||||
authorize_resource :encrypted_config, parent: false, only: %i[new create]
|
||||
authorize_resource :encrypted_config, only: %i[update destroy show]
|
||||
|
||||
def show
|
||||
cert_data = EncryptedConfig.find_by(account: current_account,
|
||||
key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || {}
|
||||
cert_data = @encrypted_config.value || {}
|
||||
|
||||
default_pkcs = GenerateCertificate.load_pkcs(cert_data) if cert_data['cert'].present?
|
||||
|
||||
@@ -42,10 +45,7 @@ class EsignSettingsController < ApplicationController
|
||||
def create
|
||||
@cert_record = CertFormRecord.new(**cert_params)
|
||||
|
||||
cert_configs = EncryptedConfig.find_or_initialize_by(account: current_account,
|
||||
key: EncryptedConfig::ESIGN_CERTS_KEY)
|
||||
|
||||
if (cert_configs.value && cert_configs.value['custom']&.any? { |e| e['name'] == @cert_record.name }) ||
|
||||
if (@encrypted_config.value && @encrypted_config.value['custom']&.any? { |e| e['name'] == @cert_record.name }) ||
|
||||
@cert_record.name == DEFAULT_CERT_NAME
|
||||
|
||||
@cert_record.errors.add(:name, 'already exists')
|
||||
@@ -54,7 +54,7 @@ class EsignSettingsController < ApplicationController
|
||||
status: :unprocessable_entity
|
||||
end
|
||||
|
||||
save_new_cert!(cert_configs, @cert_record)
|
||||
save_new_cert!(@encrypted_config, @cert_record)
|
||||
|
||||
redirect_to settings_esign_path, notice: 'Certificate has been successfully added!'
|
||||
rescue OpenSSL::PKCS12::PKCS12Error
|
||||
@@ -64,29 +64,31 @@ class EsignSettingsController < ApplicationController
|
||||
end
|
||||
|
||||
def update
|
||||
cert_configs = EncryptedConfig.find_by(account: current_account, key: EncryptedConfig::ESIGN_CERTS_KEY)
|
||||
@encrypted_config.value['custom'].each { |e| e['status'] = 'validate' }
|
||||
|
||||
cert_configs.value['custom'].each { |e| e['status'] = 'validate' }
|
||||
custom_cert_data = cert_configs.value['custom'].find { |e| e['name'] == params[:name] }
|
||||
custom_cert_data = @encrypted_config.value['custom'].find { |e| e['name'] == params[:name] }
|
||||
custom_cert_data['status'] = 'default' if custom_cert_data
|
||||
|
||||
cert_configs.save!
|
||||
@encrypted_config.save!
|
||||
|
||||
redirect_to settings_esign_path, notice: 'Default certificate has been selected'
|
||||
end
|
||||
|
||||
def destroy
|
||||
cert_configs = EncryptedConfig.find_by(account: current_account, key: EncryptedConfig::ESIGN_CERTS_KEY)
|
||||
@encrypted_config.value['custom'].reject! { |e| e['name'] == params[:name] }
|
||||
|
||||
cert_configs.value['custom'].reject! { |e| e['name'] == params[:name] }
|
||||
|
||||
cert_configs.save!
|
||||
@encrypted_config.save!
|
||||
|
||||
redirect_to settings_esign_path, notice: 'Certificate has been removed'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_encrypted_config
|
||||
@encrypted_config = EncryptedConfig.find_or_initialize_by(account: current_account,
|
||||
key: EncryptedConfig::ESIGN_CERTS_KEY)
|
||||
end
|
||||
|
||||
def save_new_cert!(cert_configs, cert_record)
|
||||
pkcs = OpenSSL::PKCS12.new(cert_record.file.read, cert_record.password)
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class MfaSetupController < ApplicationController
|
||||
before_action do
|
||||
authorize!(:update, current_user)
|
||||
end
|
||||
|
||||
def new
|
||||
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
|
||||
|
||||
def edit; end
|
||||
|
||||
def create
|
||||
if current_user.validate_and_consume_otp!(params[:otp_attempt])
|
||||
current_user.otp_required_for_login = true
|
||||
current_user.save!
|
||||
|
||||
redirect_to settings_profile_index_path, notice: '2FA has been configured'
|
||||
else
|
||||
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal::PRODUCT_NAME)
|
||||
|
||||
@error_message = 'Code is invalid'
|
||||
|
||||
render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/new'), status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
if current_user.validate_and_consume_otp!(params[:otp_attempt])
|
||||
current_user.update!(otp_required_for_login: false, otp_secret: nil)
|
||||
|
||||
redirect_to settings_profile_index_path, notice: '2FA has been removed'
|
||||
else
|
||||
@error_message = 'Code is invalid'
|
||||
|
||||
render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/edit'), status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class NewslettersController < ApplicationController
|
||||
skip_authorization_check
|
||||
|
||||
def show; end
|
||||
|
||||
def update
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class NotificationsSettingsController < ApplicationController
|
||||
before_action :load_bcc_config, only: :index
|
||||
before_action :load_reminder_config, only: :index
|
||||
authorize_resource :bcc_config, only: :index
|
||||
authorize_resource :reminder_config, only: :index
|
||||
|
||||
before_action :build_account_config, only: :create
|
||||
authorize_resource :account_config, only: :create
|
||||
|
||||
def index; end
|
||||
|
||||
def create
|
||||
if @account_config.save
|
||||
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
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_account_config
|
||||
@account_config =
|
||||
AccountConfig.find_or_initialize_by(account: current_account, key: email_config_params[:key])
|
||||
|
||||
@account_config.assign_attributes(email_config_params)
|
||||
end
|
||||
|
||||
def load_bcc_config
|
||||
@bcc_config =
|
||||
AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::BCC_EMAILS)
|
||||
end
|
||||
|
||||
def load_reminder_config
|
||||
@reminder_config =
|
||||
AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::SUBMITTER_REMAILERS)
|
||||
end
|
||||
|
||||
def email_config_params
|
||||
params.require(:account_config).permit!.tap do |attrs|
|
||||
attrs[:key] = nil unless attrs[:key].in?([AccountConfig::BCC_EMAILS, AccountConfig::SUBMITTER_REMAILERS])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,12 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class PersonalizationSettingsController < ApplicationController
|
||||
def show; end
|
||||
def show
|
||||
authorize!(:read, AccountConfig)
|
||||
end
|
||||
|
||||
def create
|
||||
account_config =
|
||||
current_account.account_configs.find_or_initialize_by(key: encrypted_config_params[:key])
|
||||
|
||||
authorize!(:create, account_config)
|
||||
|
||||
account_config.update!(encrypted_config_params)
|
||||
|
||||
redirect_back(fallback_location: settings_personalization_path, notice: 'Settings have been saved.')
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ProfileController < ApplicationController
|
||||
before_action do
|
||||
authorize!(:manage, current_user)
|
||||
end
|
||||
|
||||
def index; end
|
||||
|
||||
def update_contact
|
||||
|
||||
@@ -5,6 +5,7 @@ class SendSubmissionEmailController < ApplicationController
|
||||
|
||||
skip_before_action :authenticate_user!
|
||||
skip_before_action :verify_authenticity_token
|
||||
skip_authorization_check
|
||||
|
||||
def success; end
|
||||
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SessionsController < Devise::SessionsController
|
||||
before_action :configure_permitted_parameters
|
||||
|
||||
def create
|
||||
if Docuseal.multitenant? && !User.exists?(email: sign_in_params[:email])
|
||||
return redirect_to new_registration_path(sign_up: true, user: sign_in_params.slice(:email)),
|
||||
notice: 'Create a new account'
|
||||
end
|
||||
|
||||
if User.exists?(email: sign_in_params[:email], otp_required_for_login: true) && sign_in_params[:otp_attempt].blank?
|
||||
return render :otp, locals: { resource: User.new(sign_in_params) }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
@@ -22,6 +28,10 @@ class SessionsController < Devise::SessionsController
|
||||
super
|
||||
end
|
||||
|
||||
def configure_permitted_parameters
|
||||
devise_parameter_sanitizer.permit(:sign_in, keys: [:otp_attempt])
|
||||
end
|
||||
|
||||
def require_no_authentication
|
||||
super
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
class SetupController < ApplicationController
|
||||
skip_before_action :maybe_redirect_to_setup
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
before_action :redirect_to_root_if_signed, if: :signed_in?
|
||||
before_action :ensure_first_user_not_created!
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SmsSettingsController < ApplicationController
|
||||
before_action :load_encrypted_config
|
||||
authorize_resource :encrypted_config, only: :index
|
||||
authorize_resource :encrypted_config, parent: false, except: :index
|
||||
|
||||
def index; end
|
||||
|
||||
private
|
||||
|
||||
def load_encrypted_config
|
||||
@encrypted_config =
|
||||
EncryptedConfig.find_or_initialize_by(account: current_account, key: 'sms_configs')
|
||||
end
|
||||
end
|
||||
@@ -4,6 +4,7 @@ class StartFormController < ApplicationController
|
||||
layout 'form'
|
||||
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
before_action :load_template
|
||||
|
||||
@@ -20,12 +21,13 @@ class StartFormController < ApplicationController
|
||||
else
|
||||
@submitter.assign_attributes(
|
||||
uuid: @template.submitters.first['uuid'],
|
||||
opened_at: Time.current,
|
||||
ip: request.remote_ip,
|
||||
ua: request.user_agent
|
||||
)
|
||||
|
||||
@submitter.submission ||= Submission.new(template: @template, source: :link)
|
||||
@submitter.submission ||= Submission.new(template: @template,
|
||||
template_submitters: @template.submitters,
|
||||
source: :link)
|
||||
|
||||
if @submitter.save
|
||||
redirect_to submit_form_path(@submitter.slug)
|
||||
@@ -44,7 +46,9 @@ class StartFormController < ApplicationController
|
||||
private
|
||||
|
||||
def submitter_params
|
||||
params.require(:submitter).permit(:email)
|
||||
params.require(:submitter).permit(:email).tap do |attrs|
|
||||
attrs[:email] = Submissions.normalize_email(attrs[:email])
|
||||
end
|
||||
end
|
||||
|
||||
def load_template
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
class StorageSettingsController < ApplicationController
|
||||
before_action :load_encrypted_config
|
||||
authorize_resource :encrypted_config, only: :index
|
||||
authorize_resource :encrypted_config, parent: false, only: :create
|
||||
|
||||
def index; end
|
||||
|
||||
|
||||
@@ -2,19 +2,26 @@
|
||||
|
||||
class SubmissionsController < ApplicationController
|
||||
before_action :load_template, only: %i[new create]
|
||||
authorize_resource :template, only: %i[new create]
|
||||
|
||||
load_and_authorize_resource :submission, only: %i[show destroy]
|
||||
|
||||
def show
|
||||
@submission =
|
||||
Submission.joins(:template).where(template: { account_id: current_account.id })
|
||||
.preload(:template, template_schema_documents: [:blob, { preview_images_attachments: :blob }])
|
||||
.find(params[:id])
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@submission],
|
||||
associations: [:template, { template_schema_documents: [:blob, { preview_images_attachments: :blob }] }]
|
||||
).call
|
||||
|
||||
render :show, layout: 'plain'
|
||||
end
|
||||
|
||||
def new; end
|
||||
def new
|
||||
authorize!(:new, Submission)
|
||||
end
|
||||
|
||||
def create
|
||||
authorize!(:create, Submission)
|
||||
|
||||
submissions =
|
||||
if params[:emails].present?
|
||||
Submissions.create_from_emails(template: @template,
|
||||
@@ -37,12 +44,9 @@ class SubmissionsController < ApplicationController
|
||||
end
|
||||
|
||||
def destroy
|
||||
submission = Submission.joins(:template).where(template: { account_id: current_account.id })
|
||||
.find(params[:id])
|
||||
@submission.update!(deleted_at: Time.current)
|
||||
|
||||
submission.update!(deleted_at: Time.current)
|
||||
|
||||
redirect_back(fallback_location: template_path(submission.template), notice: 'Submission has been archived')
|
||||
redirect_back(fallback_location: template_path(@submission.template), notice: 'Submission has been archived')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -4,6 +4,7 @@ class SubmissionsDebugController < ApplicationController
|
||||
layout 'plain'
|
||||
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
def index
|
||||
@submitter = Submitter.preload({ attachments_attachments: :blob },
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
class SubmissionsDownloadController < ApplicationController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
def index
|
||||
submitter = Submitter.find_by(slug: params[:submitter_slug])
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmissionsExportController < ApplicationController
|
||||
before_action :load_template
|
||||
load_and_authorize_resource :template
|
||||
load_and_authorize_resource :submission, through: :template, parent: false, only: :index
|
||||
|
||||
def index
|
||||
submissions = @template.submissions.active
|
||||
.preload(submitters: { documents_attachments: :blob,
|
||||
attachments_attachments: :blob })
|
||||
.order(id: :asc)
|
||||
submissions = @submissions.active
|
||||
.preload(submitters: { documents_attachments: :blob,
|
||||
attachments_attachments: :blob })
|
||||
.order(id: :asc)
|
||||
|
||||
if params[:format] == 'csv'
|
||||
send_data Submissions::GenerateExportFiles.call(submissions, format: params[:format]),
|
||||
@@ -19,10 +20,4 @@ class SubmissionsExportController < ApplicationController
|
||||
end
|
||||
|
||||
def new; end
|
||||
|
||||
private
|
||||
|
||||
def load_template
|
||||
@template = current_account.templates.find(params[:template_id])
|
||||
end
|
||||
end
|
||||
@@ -4,6 +4,7 @@ class SubmitFormController < ApplicationController
|
||||
layout 'form'
|
||||
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
def show
|
||||
@submitter =
|
||||
@@ -14,6 +15,8 @@ class SubmitFormController < ApplicationController
|
||||
|
||||
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
|
||||
|
||||
Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user)
|
||||
|
||||
cookies[:submitter_sid] = @submitter.signed_id
|
||||
end
|
||||
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmittersSendEmailController < ApplicationController
|
||||
load_and_authorize_resource :submitter, id_param: :submitter_slug, find_by: :slug
|
||||
|
||||
def create
|
||||
submitter = Submitter.joins(:template)
|
||||
.where(template: { account_id: current_account.id })
|
||||
.find_by!(slug: params[:submitter_slug])
|
||||
SubmitterMailer.invitation_email(@submitter).deliver_later!
|
||||
|
||||
SubmitterMailer.invitation_email(submitter).deliver_later!
|
||||
SubmissionEvent.create!(submitter: @submitter, event_type: 'send_email')
|
||||
|
||||
SubmissionEvent.create!(submitter:, event_type: 'send_email')
|
||||
@submitter.sent_at ||= Time.current
|
||||
@submitter.save!
|
||||
|
||||
submitter.sent_at ||= Time.current
|
||||
submitter.save!
|
||||
|
||||
redirect_back(fallback_location: submission_path(submitter.submission), notice: 'Email has been sent')
|
||||
redirect_back(fallback_location: submission_path(@submitter.submission), notice: 'Email has been sent')
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,29 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplateFoldersController < ApplicationController
|
||||
load_and_authorize_resource :template_folder
|
||||
|
||||
def show
|
||||
@templates = @template_folder.templates.active.preload(:author).order(id: :desc)
|
||||
@templates = Templates.search(@templates, params[:q])
|
||||
|
||||
@pagy, @templates = pagy(@templates, items: 12)
|
||||
end
|
||||
|
||||
def edit; end
|
||||
|
||||
def update
|
||||
if @template_folder != current_account.default_template_folder &&
|
||||
@template_folder.update(template_folder_params)
|
||||
redirect_to folder_path(@template_folder), notice: 'Folder name has been updated'
|
||||
else
|
||||
redirect_to folder_path(@template_folder), alert: 'Unable to rename folder'
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def template_folder_params
|
||||
params.require(:template_folder).permit(:name)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplatesArchivedController < ApplicationController
|
||||
load_and_authorize_resource :template, parent: false
|
||||
|
||||
def index
|
||||
@templates = @templates.where.not(deleted_at: nil).preload(:author, :folder).order(id: :desc)
|
||||
@templates = Templates.search(@templates, params[:q])
|
||||
|
||||
@pagy, @templates = pagy(@templates, items: 12)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplatesArchivedSubmissionsController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
load_and_authorize_resource :submission, through: :template, parent: false
|
||||
|
||||
def index
|
||||
@submissions = @submissions.where.not(deleted_at: nil)
|
||||
@submissions = Submissions.search(@submissions, params[:q])
|
||||
|
||||
@pagy, @submissions = pagy(@submissions.preload(:submitters).order(id: :desc))
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_to root_path
|
||||
end
|
||||
end
|
||||
@@ -1,31 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplatesController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
before_action :load_base_template, only: %i[new create]
|
||||
|
||||
def show
|
||||
@template = current_account.templates.active.find(params[:id])
|
||||
submissions = @template.submissions
|
||||
submissions = submissions.active if @template.deleted_at.blank?
|
||||
submissions = Submissions.search(submissions, params[:q])
|
||||
|
||||
@pagy, @submissions = pagy(@template.submissions.active.preload(:submitters).order(id: :desc))
|
||||
@pagy, @submissions = pagy(submissions.preload(:submitters).order(id: :desc))
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_to root_path
|
||||
end
|
||||
|
||||
def new
|
||||
@template = current_account.templates.new
|
||||
@template.name = "#{@base_template.name} (Clone)" if @base_template
|
||||
end
|
||||
|
||||
def edit
|
||||
@template = current_account.templates.preload(schema_documents: { preview_images_attachments: :blob })
|
||||
.find(params[:id])
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@template],
|
||||
associations: [schema_documents: { preview_images_attachments: :blob }]
|
||||
).call
|
||||
|
||||
render :edit, layout: 'plain'
|
||||
end
|
||||
|
||||
def create
|
||||
@template = current_account.templates.new(template_params)
|
||||
@template.account = current_account
|
||||
@template.author = current_user
|
||||
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
|
||||
@template.assign_attributes(@base_template.slice(:fields, :schema, :submitters)) if @base_template
|
||||
|
||||
if @template.save
|
||||
@@ -38,7 +44,6 @@ class TemplatesController < ApplicationController
|
||||
end
|
||||
|
||||
def destroy
|
||||
@template = current_account.templates.find(params[:id])
|
||||
@template.update!(deleted_at: Time.current)
|
||||
|
||||
redirect_back(fallback_location: root_path, notice: 'Template has been archived.')
|
||||
@@ -53,8 +58,6 @@ class TemplatesController < ApplicationController
|
||||
def load_base_template
|
||||
return if params[:base_template_id].blank?
|
||||
|
||||
@base_template = current_account.templates
|
||||
.preload(documents_attachments: :preview_images_attachments)
|
||||
.find_by(id: params[:base_template_id])
|
||||
@base_template = current_account.templates.find_by(id: params[:base_template_id])
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplatesFoldersController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def edit; end
|
||||
|
||||
def update
|
||||
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:name])
|
||||
|
||||
if @template.save
|
||||
redirect_back(fallback_location: template_path(@template), notice: 'Document template has been moved')
|
||||
else
|
||||
redirect_back(fallback_location: template_path(@template), notice: 'Unable to move template into folder')
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def template_folder_params
|
||||
params.require(:template_folder).permit(:name)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplatesRestoreController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def create
|
||||
@template.update!(deleted_at: nil)
|
||||
|
||||
redirect_to template_path(@template), notice: 'Template has been unarchived'
|
||||
end
|
||||
end
|
||||
@@ -1,18 +1,22 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplatesUploadsController < ApplicationController
|
||||
load_and_authorize_resource :template, parent: false
|
||||
|
||||
def create
|
||||
template = current_account.templates.new(author: current_user)
|
||||
template.name = File.basename(params[:files].first.original_filename, '.*')
|
||||
@template.account = current_account
|
||||
@template.author = current_user
|
||||
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
|
||||
@template.name = File.basename(params[:files].first.original_filename, '.*')
|
||||
|
||||
template.save!
|
||||
@template.save!
|
||||
|
||||
documents = Templates::CreateAttachments.call(template, params)
|
||||
documents = Templates::CreateAttachments.call(@template, params)
|
||||
|
||||
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
|
||||
|
||||
template.update!(schema:)
|
||||
@template.update!(schema:)
|
||||
|
||||
redirect_to edit_template_path(template)
|
||||
redirect_to edit_template_path(@template)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class UserSignaturesController < ApplicationController
|
||||
before_action :load_user_config
|
||||
authorize_resource :user_config
|
||||
|
||||
def edit; end
|
||||
|
||||
def update
|
||||
file = params[:file]
|
||||
|
||||
return redirect_to settings_profile_index_path, notice: 'Unable to save signature' if file.blank?
|
||||
|
||||
blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
|
||||
filename: file.original_filename,
|
||||
content_type: file.content_type)
|
||||
|
||||
attachment = ActiveStorage::Attachment.create!(
|
||||
blob:,
|
||||
name: 'signature',
|
||||
record: current_user
|
||||
)
|
||||
|
||||
if @user_config.update(value: attachment.uuid)
|
||||
redirect_to settings_profile_index_path, notice: 'Signature has been saved'
|
||||
else
|
||||
redirect_to settings_profile_index_path, notice: 'Unable to save signature'
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_user_config
|
||||
@user_config =
|
||||
UserConfig.find_or_initialize_by(user: current_user, key: UserConfig::SIGNATURE_KEY)
|
||||
end
|
||||
end
|
||||
@@ -1,26 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class UsersController < ApplicationController
|
||||
before_action :load_user, only: %i[edit update destroy]
|
||||
load_and_authorize_resource :user, only: %i[index edit new update destroy]
|
||||
|
||||
before_action :build_user, only: :create
|
||||
authorize_resource :user, only: :create
|
||||
|
||||
def index
|
||||
@pagy, @users = pagy(current_account.users.active.order(id: :desc))
|
||||
@pagy, @users = pagy(@users.active.order(id: :desc))
|
||||
end
|
||||
|
||||
def new
|
||||
@user = current_account.users.new
|
||||
end
|
||||
def new; end
|
||||
|
||||
def edit; end
|
||||
|
||||
def create
|
||||
@user = current_account.users.find_by(email: user_params[:email])&.tap do |user|
|
||||
user.assign_attributes(user_params)
|
||||
user.deleted_at = nil
|
||||
end
|
||||
|
||||
@user ||= current_account.users.new(user_params)
|
||||
|
||||
if @user.save
|
||||
UserMailer.invitation_email(@user).deliver_later!
|
||||
|
||||
@@ -33,7 +27,7 @@ class UsersController < ApplicationController
|
||||
def update
|
||||
return redirect_to settings_users_path, notice: 'Unable to update user.' if Docuseal.demo?
|
||||
|
||||
if @user.update(user_params.compact_blank)
|
||||
if @user.update(user_params.compact_blank.except(current_user == @user ? :role : nil))
|
||||
redirect_to settings_users_path, notice: 'User has been updated'
|
||||
else
|
||||
render turbo_stream: turbo_stream.replace(:modal, template: 'users/edit'), status: :unprocessable_entity
|
||||
@@ -52,11 +46,18 @@ class UsersController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def load_user
|
||||
@user = current_account.users.find(params[:id])
|
||||
def build_user
|
||||
@user = current_account.users.find_by(email: user_params[:email])&.tap do |user|
|
||||
user.assign_attributes(user_params)
|
||||
user.deleted_at = nil
|
||||
end
|
||||
|
||||
@user ||= current_account.users.new(user_params)
|
||||
|
||||
@user
|
||||
end
|
||||
|
||||
def user_params
|
||||
params.require(:user).permit(:email, :first_name, :last_name, :password)
|
||||
params.require(:user).permit(:email, :first_name, :last_name, :password, :role)
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class VerifyPdfSignatureController < ApplicationController
|
||||
skip_authorization_check
|
||||
|
||||
def create
|
||||
pdfs =
|
||||
params[:files].map do |file|
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class WebhookSettingsController < ApplicationController
|
||||
def show
|
||||
@encrypted_config =
|
||||
current_account.encrypted_configs.find_or_initialize_by(key: EncryptedConfig::WEBHOOK_URL_KEY)
|
||||
end
|
||||
before_action :load_encrypted_config
|
||||
authorize_resource :encrypted_config, parent: false
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@encrypted_config =
|
||||
current_account.encrypted_configs.find_or_initialize_by(key: EncryptedConfig::WEBHOOK_URL_KEY)
|
||||
|
||||
@encrypted_config.update!(encrypted_config_params)
|
||||
|
||||
redirect_back(fallback_location: settings_webhooks_path, notice: 'Webhook URL has been saved.')
|
||||
@@ -18,13 +15,18 @@ class WebhookSettingsController < ApplicationController
|
||||
def update
|
||||
submitter = current_account.submitters.where.not(completed_at: nil).order(:id).last
|
||||
|
||||
SendWebhookRequestJob.perform_later(submitter)
|
||||
SendFormCompletedWebhookRequestJob.perform_later(submitter)
|
||||
|
||||
redirect_back(fallback_location: settings_webhooks_path, notice: 'Webhook request has been sent.')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_encrypted_config
|
||||
@encrypted_config =
|
||||
current_account.encrypted_configs.find_or_initialize_by(key: EncryptedConfig::WEBHOOK_URL_KEY)
|
||||
end
|
||||
|
||||
def encrypted_config_params
|
||||
params.require(:encrypted_config).permit(:value)
|
||||
end
|
||||
|
||||
@@ -15,6 +15,9 @@ import DownloadButton from './elements/download_button'
|
||||
import SetOriginUrl from './elements/set_origin_url'
|
||||
import SetTimezone from './elements/set_timezone'
|
||||
import AutoresizeTextarea from './elements/autoresize_textarea'
|
||||
import SubmittersAutocomplete from './elements/submitter_autocomplete'
|
||||
import FolderAutocomplete from './elements/folder_autocomplete'
|
||||
import SignatureForm from './elements/signature_form'
|
||||
|
||||
import * as TurboInstantClick from './lib/turbo_instant_click'
|
||||
|
||||
@@ -43,6 +46,9 @@ window.customElements.define('download-button', DownloadButton)
|
||||
window.customElements.define('set-origin-url', SetOriginUrl)
|
||||
window.customElements.define('set-timezone', SetTimezone)
|
||||
window.customElements.define('autoresize-textarea', AutoresizeTextarea)
|
||||
window.customElements.define('submitters-autocomplete', SubmittersAutocomplete)
|
||||
window.customElements.define('folder-autocomplete', FolderAutocomplete)
|
||||
window.customElements.define('signature-form', SignatureForm)
|
||||
|
||||
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
|
||||
document.addEventListener('turbo:submit-end', async (event) => {
|
||||
|
||||
@@ -74,3 +74,54 @@ button[disabled] .enabled {
|
||||
.base-select {
|
||||
@apply select base-input w-full font-normal;
|
||||
}
|
||||
|
||||
.tooltip-bottom-end:before {
|
||||
transform: translateX(-95%);
|
||||
top: var(--tooltip-offset);
|
||||
left: 100%;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
.tooltip-bottom-end:after {
|
||||
transform: translateX(-25%);
|
||||
border-color: transparent transparent var(--tooltip-color) transparent;
|
||||
top: var(--tooltip-tail-offset);
|
||||
left: 50%;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
.autocomplete {
|
||||
background: white;
|
||||
z-index: 1000;
|
||||
font: 14px/22px "-apple-system", BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
overflow: auto;
|
||||
box-sizing: border-box;
|
||||
@apply border border-base-300 mt-1 rounded-md;
|
||||
}
|
||||
|
||||
.autocomplete * {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.autocomplete > div {
|
||||
@apply px-2 py-1 font-normal text-sm;
|
||||
}
|
||||
|
||||
.autocomplete .group {
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
.autocomplete > div:hover:not(.group),
|
||||
.autocomplete > div.selected {
|
||||
@apply bg-base-300;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.input-outlined {
|
||||
outline-style: solid;
|
||||
outline-width: 1px;
|
||||
outline-offset: 3px;
|
||||
outline-color: hsl(var(--bc) / 0.2);
|
||||
}
|
||||
@@ -91,11 +91,13 @@ export default actionable(targetable(class extends HTMLElement {
|
||||
this.append(input)
|
||||
})
|
||||
|
||||
if (this.dataset.submitOnUpload) {
|
||||
if (this.dataset.submitOnUpload === 'true') {
|
||||
this.closest('form').querySelector('button[type="submit"]').click()
|
||||
}
|
||||
}).finally(() => {
|
||||
this.toggleLoading()
|
||||
if (this.dataset.submitOnUpload !== 'true') {
|
||||
this.toggleLoading()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
if (this.dataset.submitOnUpload) {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import autocomplete from 'autocompleter'
|
||||
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
autocomplete({
|
||||
input: this.input,
|
||||
preventSubmit: this.dataset.submitOnSelect === 'true' ? 0 : 1,
|
||||
minLength: 0,
|
||||
showOnFocus: true,
|
||||
onSelect: this.onSelect,
|
||||
render: this.render,
|
||||
fetch: this.fetch
|
||||
})
|
||||
}
|
||||
|
||||
onSelect = (item) => {
|
||||
this.input.value = item.name
|
||||
}
|
||||
|
||||
fetch = (text, resolve) => {
|
||||
const queryParams = new URLSearchParams({ q: text })
|
||||
|
||||
fetch('/api/template_folders_autocomplete?' + queryParams).then(async (resp) => {
|
||||
const items = await resp.json()
|
||||
|
||||
resolve(items)
|
||||
}).catch(() => {
|
||||
resolve([])
|
||||
})
|
||||
}
|
||||
|
||||
render = (item) => {
|
||||
const div = document.createElement('div')
|
||||
|
||||
div.textContent = item.name
|
||||
|
||||
return div
|
||||
}
|
||||
|
||||
get input () {
|
||||
return this.querySelector('input')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { target, targetable } from '@github/catalyst/lib/targetable'
|
||||
import { cropCanvasAndExportToPNG } from '../submission_form/crop_canvas'
|
||||
|
||||
export default targetable(class extends HTMLElement {
|
||||
static [target.static] = ['canvas', 'input', 'clear', 'button']
|
||||
|
||||
async connectedCallback () {
|
||||
this.canvas.width = this.canvas.parentNode.parentNode.clientWidth
|
||||
this.canvas.height = this.canvas.parentNode.parentNode.clientWidth / 3
|
||||
|
||||
const { default: SignaturePad } = await import('signature_pad')
|
||||
|
||||
this.pad = new SignaturePad(this.canvas)
|
||||
|
||||
this.clear.addEventListener('click', (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
this.pad.clear()
|
||||
})
|
||||
|
||||
this.button.addEventListener('click', (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
this.button.disabled = true
|
||||
|
||||
this.submit()
|
||||
})
|
||||
}
|
||||
|
||||
async submit () {
|
||||
const blob = await cropCanvasAndExportToPNG(this.canvas)
|
||||
const file = new File([blob], 'signature.png', { type: 'image/png' })
|
||||
|
||||
const dataTransfer = new DataTransfer()
|
||||
|
||||
dataTransfer.items.add(file)
|
||||
|
||||
this.input.files = dataTransfer.files
|
||||
|
||||
if (this.input.webkitEntries.length) {
|
||||
this.input.dataset.file = `${dataTransfer.files[0].name}`
|
||||
}
|
||||
|
||||
this.closest('form').requestSubmit()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import autocomplete from 'autocompleter'
|
||||
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
autocomplete({
|
||||
input: this.input,
|
||||
preventSubmit: 1,
|
||||
minLength: 1,
|
||||
showOnFocus: true,
|
||||
onSelect: this.onSelect,
|
||||
render: this.render,
|
||||
fetch: this.fetch
|
||||
})
|
||||
}
|
||||
|
||||
onSelect = (item) => {
|
||||
const fields = ['email', 'name', 'phone']
|
||||
const submitterItemEl = this.closest('submitter-item')
|
||||
|
||||
fields.forEach((field) => {
|
||||
const input = submitterItemEl.querySelector(`submitters-autocomplete[data-field="${field}"] input`)
|
||||
const textarea = submitterItemEl.querySelector(`submitters-autocomplete[data-field="${field}"] textarea`)
|
||||
|
||||
if (input && item[field]) {
|
||||
input.value = item[field]
|
||||
}
|
||||
|
||||
if (textarea && item[field]) {
|
||||
textarea.value = textarea.value.replace(/[^;,\s]+$/, item[field] + ' ')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fetch = (text, resolve) => {
|
||||
const q = text.split(/[;,\s]+/).pop().trim()
|
||||
|
||||
if (q) {
|
||||
const queryParams = new URLSearchParams({ q, field: this.dataset.field })
|
||||
|
||||
fetch('/api/submitters_autocomplete?' + queryParams).then(async (resp) => {
|
||||
const items = await resp.json()
|
||||
|
||||
if (q.length < 3) {
|
||||
resolve(items.filter((e) => e[this.dataset.field].startsWith(q)))
|
||||
} else {
|
||||
resolve(items)
|
||||
}
|
||||
}).catch(() => {
|
||||
resolve([])
|
||||
})
|
||||
} else {
|
||||
resolve([])
|
||||
}
|
||||
}
|
||||
|
||||
render = (item) => {
|
||||
const div = document.createElement('div')
|
||||
|
||||
div.textContent = item[this.dataset.field]
|
||||
|
||||
return div
|
||||
}
|
||||
|
||||
get input () {
|
||||
return this.querySelector('input') || this.querySelector('textarea')
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,12 @@ window.customElements.define('submission-form', class extends HTMLElement {
|
||||
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,
|
||||
values: reactive(JSON.parse(this.dataset.values)),
|
||||
completedButton: JSON.parse(this.dataset.completedButton),
|
||||
attachments: reactive(JSON.parse(this.dataset.attachments)),
|
||||
fields: JSON.parse(this.dataset.fields)
|
||||
})
|
||||
|
||||
@@ -47,6 +47,11 @@
|
||||
class="object-contain mx-auto"
|
||||
:src="signature.url"
|
||||
>
|
||||
<img
|
||||
v-else-if="field.type === 'initials' && initials"
|
||||
class="object-contain mx-auto"
|
||||
:src="initials.url"
|
||||
>
|
||||
<div
|
||||
v-else-if="field.type === 'file'"
|
||||
class="px-0.5 flex flex-col justify-center"
|
||||
@@ -113,7 +118,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck } from '@tabler/icons-vue'
|
||||
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'FieldArea',
|
||||
@@ -175,6 +180,7 @@ export default {
|
||||
signature: 'Signature',
|
||||
date: 'Date',
|
||||
image: 'Image',
|
||||
initials: 'Initials',
|
||||
file: 'File',
|
||||
select: 'Select',
|
||||
checkbox: 'Checkbox',
|
||||
@@ -189,6 +195,7 @@ export default {
|
||||
signature: IconWritingSign,
|
||||
date: IconCalendarEvent,
|
||||
image: IconPhoto,
|
||||
initials: IconLetterCaseUpper,
|
||||
file: IconPaperclip,
|
||||
select: IconSelect,
|
||||
checkbox: IconCheckbox,
|
||||
@@ -212,6 +219,13 @@ export default {
|
||||
return null
|
||||
}
|
||||
},
|
||||
initials () {
|
||||
if (this.field.type === 'initials') {
|
||||
return this.attachmentsIndex[this.modelValue]
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
},
|
||||
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))
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="!isWebView"
|
||||
class="base-button flex items-center space-x-1 w-full"
|
||||
:disabled="isDownloading"
|
||||
@click.prevent="download"
|
||||
@@ -51,6 +52,15 @@
|
||||
Star on Github
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
v-if="completedButton.url"
|
||||
:href="completedButton.url"
|
||||
class="white-button flex items-center space-x-1 w-full"
|
||||
>
|
||||
<span>
|
||||
{{ completedButton.title || 'Back to Website' }}
|
||||
</span>
|
||||
</a>
|
||||
<a
|
||||
v-if="isDemo"
|
||||
href="https://docuseal.co/sign_up"
|
||||
@@ -114,6 +124,11 @@ export default {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
completedButton: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
data () {
|
||||
@@ -122,6 +137,11 @@ export default {
|
||||
isDownloading: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isWebView () {
|
||||
return /webview|wv|ip((?!.*Safari)|(?=.*like Safari))/i.test(window.navigator.userAgent)
|
||||
}
|
||||
},
|
||||
async mounted () {
|
||||
if (this.withConfetti) {
|
||||
const { default: confetti } = await import('canvas-confetti')
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
function cropCanvasAndExportToPNG (canvas) {
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
const width = canvas.width
|
||||
const height = canvas.height
|
||||
|
||||
let topmost = height
|
||||
let bottommost = 0
|
||||
let leftmost = width
|
||||
let rightmost = 0
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, width, height)
|
||||
const pixels = imageData.data
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const pixelIndex = (y * width + x) * 4
|
||||
const alpha = pixels[pixelIndex + 3]
|
||||
if (alpha !== 0) {
|
||||
topmost = Math.min(topmost, y)
|
||||
bottommost = Math.max(bottommost, y)
|
||||
leftmost = Math.min(leftmost, x)
|
||||
rightmost = Math.max(rightmost, x)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const croppedWidth = rightmost - leftmost + 1
|
||||
const croppedHeight = bottommost - topmost + 1
|
||||
|
||||
const croppedCanvas = document.createElement('canvas')
|
||||
croppedCanvas.width = croppedWidth
|
||||
croppedCanvas.height = croppedHeight
|
||||
const croppedCtx = croppedCanvas.getContext('2d')
|
||||
|
||||
croppedCtx.drawImage(canvas, leftmost, topmost, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
croppedCanvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(blob)
|
||||
} else {
|
||||
reject(new Error('Failed to create a PNG blob.'))
|
||||
}
|
||||
}, 'image/png')
|
||||
})
|
||||
}
|
||||
|
||||
export { cropCanvasAndExportToPNG }
|
||||
@@ -256,6 +256,19 @@
|
||||
@start="$refs.areas.scrollIntoField(currentField)"
|
||||
@minimize="isFormVisible = false"
|
||||
/>
|
||||
<InitialsStep
|
||||
v-else-if="currentField.type === 'initials'"
|
||||
ref="currentStep"
|
||||
v-model="values[currentField.uuid]"
|
||||
:field="currentField"
|
||||
:is-direct-upload="isDirectUpload"
|
||||
:attachments-index="attachmentsIndex"
|
||||
:submitter-slug="submitterSlug"
|
||||
@attached="attachments.push($event)"
|
||||
@start="$refs.areas.scrollIntoField(currentField)"
|
||||
@focus="$refs.areas.scrollIntoField(currentField)"
|
||||
@minimize="isFormVisible = false"
|
||||
/>
|
||||
<AttachmentStep
|
||||
v-else-if="currentField.type === 'file'"
|
||||
v-model="values[currentField.uuid]"
|
||||
@@ -304,6 +317,7 @@
|
||||
v-else
|
||||
:is-demo="isDemo"
|
||||
:attribution="attribution"
|
||||
:completed-button="completedButton"
|
||||
:with-confetti="withConfetti"
|
||||
:can-send-email="canSendEmail && !!submitter.email"
|
||||
:submitter-slug="submitterSlug"
|
||||
@@ -328,6 +342,7 @@
|
||||
import FieldAreas from './areas'
|
||||
import ImageStep from './image_step'
|
||||
import SignatureStep from './signature_step'
|
||||
import InitialsStep from './initials_step'
|
||||
import AttachmentStep from './attachment_step'
|
||||
import MultiSelectStep from './multi_select_step'
|
||||
import PhoneStep from './phone_step'
|
||||
@@ -342,6 +357,7 @@ export default {
|
||||
ImageStep,
|
||||
SignatureStep,
|
||||
AttachmentStep,
|
||||
InitialsStep,
|
||||
MultiSelectStep,
|
||||
IconInnerShadowTop,
|
||||
IconArrowsDiagonal,
|
||||
@@ -400,6 +416,11 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
goToLast: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
isDemo: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -414,6 +435,11 @@ export default {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
completedButton: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
data () {
|
||||
@@ -422,6 +448,7 @@ export default {
|
||||
isFormVisible: true,
|
||||
currentStep: 0,
|
||||
isSubmitting: false,
|
||||
submittedValues: {},
|
||||
recalculateButtonDisabledKey: ''
|
||||
}
|
||||
},
|
||||
@@ -439,7 +466,8 @@ export default {
|
||||
if (this.recalculateButtonDisabledKey) {
|
||||
return this.isSubmitting ||
|
||||
(this.currentField.required && ['image', 'file'].includes(this.currentField.type) && !this.values[this.currentField.uuid]?.length) ||
|
||||
(this.currentField.required && this.currentField.type === 'signature' && !this.values[this.currentField.uuid]?.length && this.$refs.currentStep && !this.$refs.currentStep.isSignatureStarted)
|
||||
(this.currentField.required && this.currentField.type === 'signature' && !this.values[this.currentField.uuid]?.length && this.$refs.currentStep && !this.$refs.currentStep.isSignatureStarted) ||
|
||||
(this.currentField.required && this.currentField.type === 'initials' && !this.values[this.currentField.uuid]?.length && this.$refs.currentStep && !this.$refs.currentStep.isInitialsStarted)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
@@ -472,10 +500,14 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.currentStep = Math.min(
|
||||
this.stepFields.indexOf([...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.values[f.uuid]))) + 1,
|
||||
this.stepFields.length - 1
|
||||
)
|
||||
this.submittedValues = JSON.parse(JSON.stringify(this.values))
|
||||
|
||||
if (this.goToLast) {
|
||||
this.currentStep = Math.min(
|
||||
this.stepFields.indexOf([...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.values[f.uuid]))) + 1,
|
||||
this.stepFields.length - 1
|
||||
)
|
||||
}
|
||||
|
||||
if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
|
||||
this.$nextTick(() => {
|
||||
@@ -494,7 +526,10 @@ export default {
|
||||
this.$nextTick(() => {
|
||||
this.recalculateButtonDisabledKey = Math.random()
|
||||
|
||||
this.maybeTrackEmailClick().finally(() => {
|
||||
Promise.all([
|
||||
this.maybeTrackEmailClick(),
|
||||
this.maybeTrackSmsClick()
|
||||
]).finally(() => {
|
||||
this.trackViewForm()
|
||||
})
|
||||
})
|
||||
@@ -525,6 +560,30 @@ export default {
|
||||
return Promise.resolve({})
|
||||
}
|
||||
},
|
||||
maybeTrackSmsClick () {
|
||||
const queryParams = new URLSearchParams(window.location.search)
|
||||
|
||||
if (queryParams.has('c')) {
|
||||
const c = queryParams.get('c')
|
||||
|
||||
queryParams.delete('c')
|
||||
const newUrl = [window.location.pathname, queryParams.toString()].filter(Boolean).join('?')
|
||||
window.history.replaceState({}, document.title, newUrl)
|
||||
|
||||
return fetch(this.baseUrl + '/api/submitter_sms_clicks', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
c,
|
||||
submitter_slug: this.submitterSlug
|
||||
})
|
||||
})
|
||||
} else {
|
||||
return Promise.resolve({})
|
||||
}
|
||||
},
|
||||
trackViewForm () {
|
||||
fetch(this.baseUrl + '/api/submitter_form_views', {
|
||||
method: 'POST',
|
||||
@@ -566,14 +625,18 @@ export default {
|
||||
async submitStep () {
|
||||
this.isSubmitting = true
|
||||
|
||||
const stepPromise = ['signature', 'phone'].includes(this.currentField.type)
|
||||
const stepPromise = ['signature', 'phone', 'initials'].includes(this.currentField.type)
|
||||
? this.$refs.currentStep.submit
|
||||
: () => Promise.resolve({})
|
||||
|
||||
stepPromise().then(async () => {
|
||||
const emptyRequiredField = this.stepFields.find((fields, index) => {
|
||||
return index < this.currentStep && fields[0].required && fields[0].type === 'phone' && !this.submittedValues[fields[0].uuid]
|
||||
})
|
||||
|
||||
const formData = new FormData(this.$refs.form)
|
||||
|
||||
if (this.currentStep === this.stepFields.length - 1) {
|
||||
if (this.currentStep === this.stepFields.length - 1 && !emptyRequiredField) {
|
||||
formData.append('completed', 'true')
|
||||
}
|
||||
|
||||
@@ -586,10 +649,12 @@ export default {
|
||||
return Promise.reject(new Error(data.error))
|
||||
}
|
||||
|
||||
const nextStep = this.stepFields[this.currentStep + 1]
|
||||
this.submittedValues[this.currentField.uuid] = this.values[this.currentField.uuid]
|
||||
|
||||
const nextStep = emptyRequiredField || this.stepFields[this.currentStep + 1]
|
||||
|
||||
if (nextStep) {
|
||||
this.goToStep(this.stepFields[this.currentStep + 1], true)
|
||||
this.goToStep(nextStep, true)
|
||||
} else {
|
||||
this.isCompleted = true
|
||||
}
|
||||
|
||||
@@ -10,6 +10,13 @@ const en = {
|
||||
or_drag_and_drop_files: 'or drag and drop files',
|
||||
send_copy_via_email: 'Send copy via email',
|
||||
download: 'Download',
|
||||
signature: 'Signature',
|
||||
initials: 'Initials',
|
||||
clear: 'Clear',
|
||||
redraw: 'Redraw',
|
||||
draw_initials: 'Draw initials',
|
||||
type_signature_here: 'Type signature here',
|
||||
type_initial_here: 'Type initials here',
|
||||
form_has_been_completed: 'Form has been completed!',
|
||||
create_a_free_account: 'Create a Free Account',
|
||||
signed_with: 'Signed with',
|
||||
@@ -36,6 +43,13 @@ const es = {
|
||||
or_drag_and_drop_files: 'o arrastra y suelta archivos',
|
||||
send_copy_via_email: 'Enviar copia por correo electrónico',
|
||||
download: 'Descargar',
|
||||
signature: 'Firma',
|
||||
initials: 'Iniciales',
|
||||
clear: 'Borrar',
|
||||
redraw: 'Redibujar',
|
||||
draw_initials: 'Dibujar iniciales',
|
||||
type_signature_here: 'Escribe la firma aquí',
|
||||
type_initial_here: 'Escribe las iniciales aquí',
|
||||
form_has_been_completed: '¡El formulario ha sido completado!',
|
||||
create_a_free_account: 'Crear una Cuenta Gratuita',
|
||||
signed_with: 'Firmado con',
|
||||
@@ -62,6 +76,13 @@ const it = {
|
||||
or_drag_and_drop_files: 'oppure trascina e rilascia i file',
|
||||
send_copy_via_email: 'Invia copia via email',
|
||||
download: 'Scarica',
|
||||
signature: 'Firma',
|
||||
initials: 'Iniziali',
|
||||
clear: 'Cancella',
|
||||
redraw: 'Ridisegna',
|
||||
draw_initials: 'Disegna iniziali',
|
||||
type_signature_here: 'Scrivi la firma qui',
|
||||
type_initial_here: 'Scrivi le iniziali qui',
|
||||
form_has_been_completed: 'Il modulo è stato completato!',
|
||||
create_a_free_account: 'Crea un Account Gratuito',
|
||||
signed_with: 'Firmato con',
|
||||
@@ -88,6 +109,13 @@ const de = {
|
||||
or_drag_and_drop_files: 'oder Dateien hierher ziehen und ablegen',
|
||||
send_copy_via_email: 'Kopie per E-Mail senden',
|
||||
download: 'Herunterladen',
|
||||
signature: 'Unterschrift',
|
||||
initials: 'Initialen',
|
||||
clear: 'Löschen',
|
||||
redraw: 'Neu zeichnen',
|
||||
draw_initials: 'Initialen zeichnen',
|
||||
type_signature_here: 'Unterschrift hier eingeben',
|
||||
type_initial_here: 'Initialen hier eingeben',
|
||||
form_has_been_completed: 'Formular wurde ausgefüllt!',
|
||||
create_a_free_account: 'Kostenloses Konto erstellen',
|
||||
signed_with: 'Unterschrieben mit',
|
||||
@@ -114,6 +142,13 @@ const fr = {
|
||||
or_drag_and_drop_files: 'ou faites glisser-déposer les fichiers',
|
||||
send_copy_via_email: 'Envoyer une copie par e-mail',
|
||||
download: 'Télécharger',
|
||||
signature: 'Signature',
|
||||
initials: 'Initiales',
|
||||
clear: 'Effacer',
|
||||
redraw: 'Redessiner',
|
||||
draw_initials: 'Dessiner les initiales',
|
||||
type_signature_here: 'Tapez la signature ici',
|
||||
type_initial_here: 'Tapez les initiales ici',
|
||||
form_has_been_completed: 'Le formulaire a été complété !',
|
||||
create_a_free_account: 'Créer un Compte Gratuit',
|
||||
signed_with: 'Signé avec',
|
||||
@@ -140,6 +175,13 @@ const pl = {
|
||||
or_drag_and_drop_files: 'lub przeciągnij i upuść pliki',
|
||||
send_copy_via_email: 'Wyślij kopię drogą mailową',
|
||||
download: 'Pobierz',
|
||||
signature: 'Podpis',
|
||||
initials: 'Inicjały',
|
||||
clear: 'Wyczyść',
|
||||
redraw: 'Przerysuj',
|
||||
draw_initials: 'Narysuj inicjały',
|
||||
type_signature_here: 'Wpisz podpis tutaj',
|
||||
type_initial_here: 'Wpisz inicjały tutaj',
|
||||
form_has_been_completed: 'Formularz został wypełniony!',
|
||||
create_a_free_account: 'Utwórz darmowe konto',
|
||||
signed_with: 'Podpisane za pomocą',
|
||||
@@ -166,6 +208,13 @@ const uk = {
|
||||
or_drag_and_drop_files: 'або перетягніть файли сюди',
|
||||
send_copy_via_email: 'Надіслати копію електронною поштою',
|
||||
download: 'Завантажити',
|
||||
signature: 'Підпис',
|
||||
initials: 'Ініціали',
|
||||
clear: 'Очистити',
|
||||
redraw: 'Перемалювати',
|
||||
draw_initials: 'Намалювати ініціали',
|
||||
type_signature_here: 'Введіть підпис тут',
|
||||
type_initial_here: 'Введіть ініціали тут',
|
||||
form_has_been_completed: 'Форму заповнено!',
|
||||
create_a_free_account: 'Створити безкоштовний обліковий запис',
|
||||
signed_with: 'Підписано за допомогою',
|
||||
@@ -192,6 +241,13 @@ const cs = {
|
||||
or_drag_and_drop_files: 'nebo přetáhněte soubory sem',
|
||||
send_copy_via_email: 'Odeslat kopii e-mailem',
|
||||
download: 'Stáhnout',
|
||||
signature: 'Podpis',
|
||||
initials: 'Iniciály',
|
||||
clear: 'Smazat',
|
||||
redraw: 'Překreslit',
|
||||
draw_initials: 'Nakreslit iniciály',
|
||||
type_signature_here: 'Sem zadejte podpis',
|
||||
type_initial_here: 'Sem zadejte iniciály',
|
||||
form_has_been_completed: 'Formulář byl dokončen!',
|
||||
create_a_free_account: 'Vytvořit bezplatný účet',
|
||||
signed_with: 'Podepsáno pomocí',
|
||||
@@ -218,6 +274,13 @@ const pt = {
|
||||
or_drag_and_drop_files: 'ou arraste e solte arquivos',
|
||||
send_copy_via_email: 'Enviar cópia por e-mail',
|
||||
download: 'Baixar',
|
||||
signature: 'Assinatura',
|
||||
initials: 'Iniciais',
|
||||
clear: 'Limpar',
|
||||
redraw: 'Redesenhar',
|
||||
draw_initials: 'Desenhar iniciais',
|
||||
type_signature_here: 'Digite a assinatura aqui',
|
||||
type_initial_here: 'Digite as iniciais aqui',
|
||||
form_has_been_completed: 'O formulário foi concluído!',
|
||||
create_a_free_account: 'Criar uma Conta Gratuita',
|
||||
signed_with: 'Assinado com',
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex justify-between items-center w-full mb-2">
|
||||
<label
|
||||
class="label text-2xl"
|
||||
>{{ field.name || t('initials') }}</label>
|
||||
<div class="space-x-2 flex">
|
||||
<span
|
||||
class="tooltip"
|
||||
:data-tip="t('draw_initials')"
|
||||
>
|
||||
<button
|
||||
id="type_text_button"
|
||||
class="btn btn-sm btn-circle"
|
||||
:class="{ 'btn-neutral': isDrawInitials, 'btn-outline': !isDrawInitials }"
|
||||
@click.prevent="toggleTextInput"
|
||||
>
|
||||
<IconSignature :width="16" />
|
||||
</button>
|
||||
</span>
|
||||
<button
|
||||
v-if="modelValue"
|
||||
class="btn btn-outline btn-sm"
|
||||
@click.prevent="remove"
|
||||
>
|
||||
<IconReload :width="16" />
|
||||
{{ t('clear') }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-outline btn-sm"
|
||||
@click.prevent="clear"
|
||||
>
|
||||
<IconReload :width="16" />
|
||||
{{ t('clear') }}
|
||||
</button>
|
||||
<button
|
||||
title="Minimize"
|
||||
class="py-1.5 inline md:hidden"
|
||||
@click.prevent="$emit('minimize')"
|
||||
>
|
||||
<IconArrowsDiagonalMinimize2
|
||||
:width="20"
|
||||
:height="20"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
:value="modelValue"
|
||||
type="hidden"
|
||||
:name="`values[${field.uuid}]`"
|
||||
>
|
||||
<img
|
||||
v-if="modelValue"
|
||||
:src="attachmentsIndex[modelValue].url"
|
||||
class="mx-auto bg-white border border-base-300 rounded max-h-72"
|
||||
>
|
||||
<canvas
|
||||
v-show="!modelValue"
|
||||
ref="canvas"
|
||||
class="bg-white border border-base-300 rounded"
|
||||
/>
|
||||
<input
|
||||
v-if="!isDrawInitials"
|
||||
id="initials_text_input"
|
||||
ref="textInput"
|
||||
class="base-input !text-2xl w-full mt-6 text-center"
|
||||
:required="field.required && !isInitialsStarted"
|
||||
:placeholder="`${t('type_initial_here')}...`"
|
||||
type="text"
|
||||
@focus="$emit('focus')"
|
||||
@input="updateWrittenInitials"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { cropCanvasAndExportToPNG } from './crop_canvas'
|
||||
import { IconReload, IconSignature, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'InitialsStep',
|
||||
components: {
|
||||
IconReload,
|
||||
IconSignature,
|
||||
IconArrowsDiagonalMinimize2
|
||||
},
|
||||
inject: ['baseUrl', 't'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
submitterSlug: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
isDirectUpload: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false
|
||||
},
|
||||
attachmentsIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
emits: ['attached', 'update:model-value', 'start', 'minimize', 'focus'],
|
||||
data () {
|
||||
return {
|
||||
isInitialsStarted: false,
|
||||
isDrawInitials: false
|
||||
}
|
||||
},
|
||||
async mounted () {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.canvas) {
|
||||
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth
|
||||
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth / 5
|
||||
}
|
||||
|
||||
this.$refs.textInput?.focus()
|
||||
})
|
||||
|
||||
if (this.isDirectUpload) {
|
||||
import('@rails/activestorage')
|
||||
}
|
||||
|
||||
const { default: SignaturePad } = await import('signature_pad')
|
||||
|
||||
if (this.$refs.canvas) {
|
||||
this.pad = new SignaturePad(this.$refs.canvas)
|
||||
|
||||
this.pad.addEventListener('beginStroke', () => {
|
||||
this.isInitialsStarted = true
|
||||
|
||||
this.$emit('start')
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
remove () {
|
||||
this.$emit('update:model-value', '')
|
||||
},
|
||||
clear () {
|
||||
this.pad.clear()
|
||||
|
||||
this.isInitialsStarted = false
|
||||
|
||||
if (this.$refs.textInput) {
|
||||
this.$refs.textInput.value = ''
|
||||
}
|
||||
},
|
||||
updateWrittenInitials (e) {
|
||||
this.isInitialsStarted = true
|
||||
|
||||
const canvas = this.$refs.canvas
|
||||
const context = canvas.getContext('2d')
|
||||
|
||||
const fontFamily = 'Arial'
|
||||
const fontSize = '44px'
|
||||
const fontStyle = 'italic'
|
||||
const fontWeight = ''
|
||||
|
||||
context.font = fontStyle + ' ' + fontWeight + ' ' + fontSize + ' ' + fontFamily
|
||||
context.textAlign = 'center'
|
||||
|
||||
context.clearRect(0, 0, canvas.width, canvas.height)
|
||||
context.fillText(e.target.value, canvas.width / 2, canvas.height / 2 + 11)
|
||||
},
|
||||
toggleTextInput () {
|
||||
this.remove()
|
||||
this.clear()
|
||||
this.isDrawInitials = !this.isDrawInitials
|
||||
|
||||
if (!this.isDrawInitials) {
|
||||
this.$nextTick(() => {
|
||||
this.$refs.textInput.focus()
|
||||
|
||||
this.$emit('start')
|
||||
})
|
||||
}
|
||||
},
|
||||
async submit () {
|
||||
if (this.modelValue) {
|
||||
return Promise.resolve({})
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => {
|
||||
const file = new File([blob], 'initials.png', { type: 'image/png' })
|
||||
|
||||
if (this.isDirectUpload) {
|
||||
const { DirectUpload } = await import('@rails/activestorage')
|
||||
|
||||
new DirectUpload(
|
||||
file,
|
||||
'/direct_uploads'
|
||||
).create((_error, data) => {
|
||||
fetch(this.baseUrl + '/api/attachments', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
submitter_slug: this.submitterSlug,
|
||||
blob_signed_id: data.signed_id,
|
||||
name: 'attachments'
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}).then((resp) => resp.json()).then((attachment) => {
|
||||
this.$emit('update:model-value', attachment.uuid)
|
||||
this.$emit('attached', attachment)
|
||||
|
||||
return resolve(attachment)
|
||||
})
|
||||
})
|
||||
} else {
|
||||
const formData = new FormData()
|
||||
|
||||
formData.append('file', file)
|
||||
formData.append('submitter_slug', this.submitterSlug)
|
||||
formData.append('name', 'attachments')
|
||||
|
||||
return fetch(this.baseUrl + '/api/attachments', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}).then((resp) => resp.json()).then((attachment) => {
|
||||
this.$emit('attached', attachment)
|
||||
this.$emit('update:model-value', attachment.uuid)
|
||||
|
||||
return resolve(attachment)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="flex justify-between items-center w-full mb-2">
|
||||
<label
|
||||
class="label text-2xl"
|
||||
>{{ field.name || 'Signature' }}</label>
|
||||
>{{ field.name || t('signature') }}</label>
|
||||
<div class="space-x-2 flex">
|
||||
<span
|
||||
class="tooltip"
|
||||
@@ -40,7 +40,7 @@
|
||||
@click.prevent="remove"
|
||||
>
|
||||
<IconReload :width="16" />
|
||||
Redraw
|
||||
{{ t('redraw') }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@@ -48,7 +48,7 @@
|
||||
@click.prevent="clear"
|
||||
>
|
||||
<IconReload :width="16" />
|
||||
Clear
|
||||
{{ t('clear') }}
|
||||
</button>
|
||||
<button
|
||||
title="Minimize"
|
||||
@@ -83,7 +83,7 @@
|
||||
ref="textInput"
|
||||
class="base-input !text-2xl w-full mt-6"
|
||||
:required="field.required"
|
||||
:placeholder="`Type signature here...`"
|
||||
:placeholder="`${t('type_signature_here')}...`"
|
||||
type="text"
|
||||
@input="updateWrittenSignature"
|
||||
>
|
||||
@@ -92,6 +92,7 @@
|
||||
|
||||
<script>
|
||||
import { IconReload, IconCamera, IconTextSize, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
|
||||
import { cropCanvasAndExportToPNG } from './crop_canvas'
|
||||
|
||||
export default {
|
||||
name: 'SignatureStep',
|
||||
@@ -101,7 +102,7 @@ export default {
|
||||
IconTextSize,
|
||||
IconArrowsDiagonalMinimize2
|
||||
},
|
||||
inject: ['baseUrl'],
|
||||
inject: ['baseUrl', 't'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
@@ -136,8 +137,10 @@ export default {
|
||||
},
|
||||
async mounted () {
|
||||
this.$nextTick(() => {
|
||||
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth
|
||||
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth / 3
|
||||
if (this.$refs.canvas) {
|
||||
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth
|
||||
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth / 3
|
||||
}
|
||||
})
|
||||
|
||||
if (this.isDirectUpload) {
|
||||
@@ -146,13 +149,15 @@ export default {
|
||||
|
||||
const { default: SignaturePad } = await import('signature_pad')
|
||||
|
||||
this.pad = new SignaturePad(this.$refs.canvas)
|
||||
if (this.$refs.canvas) {
|
||||
this.pad = new SignaturePad(this.$refs.canvas)
|
||||
|
||||
this.pad.addEventListener('beginStroke', () => {
|
||||
this.isSignatureStarted = true
|
||||
this.pad.addEventListener('beginStroke', () => {
|
||||
this.isSignatureStarted = true
|
||||
|
||||
this.$emit('start')
|
||||
})
|
||||
this.$emit('start')
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
remove () {
|
||||
@@ -244,60 +249,13 @@ export default {
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
},
|
||||
cropCanvasAndExportToPNG (canvas) {
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
const width = canvas.width
|
||||
const height = canvas.height
|
||||
|
||||
let topmost = height
|
||||
let bottommost = 0
|
||||
let leftmost = width
|
||||
let rightmost = 0
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, width, height)
|
||||
const pixels = imageData.data
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const pixelIndex = (y * width + x) * 4
|
||||
const alpha = pixels[pixelIndex + 3]
|
||||
if (alpha !== 0) {
|
||||
topmost = Math.min(topmost, y)
|
||||
bottommost = Math.max(bottommost, y)
|
||||
leftmost = Math.min(leftmost, x)
|
||||
rightmost = Math.max(rightmost, x)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const croppedWidth = rightmost - leftmost + 1
|
||||
const croppedHeight = bottommost - topmost + 1
|
||||
|
||||
const croppedCanvas = document.createElement('canvas')
|
||||
croppedCanvas.width = croppedWidth
|
||||
croppedCanvas.height = croppedHeight
|
||||
const croppedCtx = croppedCanvas.getContext('2d')
|
||||
|
||||
croppedCtx.drawImage(canvas, leftmost, topmost, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
croppedCanvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(blob)
|
||||
} else {
|
||||
reject(new Error('Failed to create a PNG blob.'))
|
||||
}
|
||||
}, 'image/png')
|
||||
})
|
||||
},
|
||||
async submit () {
|
||||
if (this.modelValue) {
|
||||
return Promise.resolve({})
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => {
|
||||
cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => {
|
||||
const file = new File([blob], 'signature.png', { type: 'image/png' })
|
||||
|
||||
if (this.isDirectUpload) {
|
||||
|
||||
@@ -6,12 +6,11 @@
|
||||
<div class="flex justify-between py-1.5 items-center pr-4">
|
||||
<div class="flex space-x-3">
|
||||
<a
|
||||
v-if="withLogoLink"
|
||||
v-if="withLogo"
|
||||
href="/"
|
||||
>
|
||||
<Logo />
|
||||
</a>
|
||||
<Logo v-else />
|
||||
<Contenteditable
|
||||
:model-value="template.name"
|
||||
class="text-3xl font-semibold focus:text-clip"
|
||||
@@ -28,7 +27,7 @@
|
||||
<a
|
||||
:href="`/templates/${template.id}/submissions/new`"
|
||||
data-turbo-frame="modal"
|
||||
class="btn btn-primary"
|
||||
class="btn btn-primary text-base"
|
||||
>
|
||||
<IconUsersPlus
|
||||
width="20"
|
||||
@@ -46,12 +45,12 @@
|
||||
>
|
||||
<IconInnerShadowTop
|
||||
v-if="isSaving"
|
||||
width="20"
|
||||
width="22"
|
||||
class="animate-spin"
|
||||
/>
|
||||
<IconDeviceFloppy
|
||||
v-else
|
||||
width="20"
|
||||
width="22"
|
||||
/>
|
||||
<span class="hidden md:inline">
|
||||
Save
|
||||
@@ -269,7 +268,7 @@ export default {
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
withLogoLink: {
|
||||
withLogo: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
@@ -330,16 +329,21 @@ export default {
|
||||
this.selectedSubmitter = this.template.submitters[0]
|
||||
},
|
||||
mounted () {
|
||||
this.undoStack = [JSON.stringify(this.template)]
|
||||
this.redoStack = []
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.onWindowResize()
|
||||
})
|
||||
|
||||
document.addEventListener('keyup', this.onKeyUp)
|
||||
window.addEventListener('keydown', this.onKeyDown)
|
||||
|
||||
window.addEventListener('resize', this.onWindowResize)
|
||||
},
|
||||
unmounted () {
|
||||
document.removeEventListener('keyup', this.onKeyUp)
|
||||
window.removeEventListener('keydown', this.onKeyDown)
|
||||
|
||||
window.removeEventListener('resize', this.onWindowResize)
|
||||
},
|
||||
@@ -347,6 +351,36 @@ export default {
|
||||
this.documentRefs = []
|
||||
},
|
||||
methods: {
|
||||
undo () {
|
||||
if (this.undoStack.length > 1) {
|
||||
this.undoStack.pop()
|
||||
const stringData = this.undoStack[this.undoStack.length - 1]
|
||||
const currentStringData = JSON.stringify(this.template)
|
||||
|
||||
if (stringData && stringData !== currentStringData) {
|
||||
this.redoStack.push(currentStringData)
|
||||
|
||||
Object.assign(this.template, JSON.parse(stringData))
|
||||
|
||||
this.save()
|
||||
}
|
||||
}
|
||||
},
|
||||
redo () {
|
||||
const stringData = this.redoStack.pop()
|
||||
this.lastRedoData = stringData
|
||||
const currentStringData = JSON.stringify(this.template)
|
||||
|
||||
if (stringData && stringData !== currentStringData) {
|
||||
if (this.undoStack[this.undoStack.length - 1] !== currentStringData) {
|
||||
this.undoStack.push(currentStringData)
|
||||
}
|
||||
|
||||
Object.assign(this.template, JSON.parse(stringData))
|
||||
|
||||
this.save()
|
||||
}
|
||||
},
|
||||
onWindowResize (e) {
|
||||
const breakpointLg = 1024
|
||||
|
||||
@@ -374,6 +408,19 @@ export default {
|
||||
this.selectedAreaRef.value = null
|
||||
}
|
||||
},
|
||||
onKeyDown (event) {
|
||||
if ((event.metaKey && event.shiftKey && event.key === 'z') || (event.ctrlKey && event.key === 'Z')) {
|
||||
event.stopImmediatePropagation()
|
||||
event.preventDefault()
|
||||
|
||||
this.redo()
|
||||
} else if ((event.ctrlKey || event.metaKey) && event.key === 'z') {
|
||||
event.stopImmediatePropagation()
|
||||
event.preventDefault()
|
||||
|
||||
this.undo()
|
||||
}
|
||||
},
|
||||
removeArea (area) {
|
||||
const field = this.template.fields.find((f) => f.areas?.includes(area))
|
||||
|
||||
@@ -385,12 +432,27 @@ export default {
|
||||
|
||||
this.save()
|
||||
},
|
||||
pushUndo () {
|
||||
const stringData = JSON.stringify(this.template)
|
||||
|
||||
if (this.undoStack[this.undoStack.length - 1] !== stringData) {
|
||||
this.undoStack.push(stringData)
|
||||
|
||||
if (this.lastRedoData !== stringData) {
|
||||
this.redoStack = []
|
||||
}
|
||||
}
|
||||
},
|
||||
onDraw (area) {
|
||||
if (this.drawField) {
|
||||
this.drawField.areas ||= []
|
||||
this.drawField.areas.push(area)
|
||||
|
||||
this.drawField = null
|
||||
|
||||
this.selectedAreaRef.value = area
|
||||
|
||||
this.save()
|
||||
} else {
|
||||
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
|
||||
const pageMask = documentRef.pageRefs[area.page].$refs.mask
|
||||
@@ -401,33 +463,37 @@ export default {
|
||||
const previousField = [...this.template.fields].reverse().find((f) => f.type === type)
|
||||
const previousArea = previousField?.areas?.[previousField.areas.length - 1]
|
||||
|
||||
const areaW = previousArea?.w || (30 / pageMask.clientWidth)
|
||||
const areaH = previousArea?.h || (30 / pageMask.clientHeight)
|
||||
if (previousArea || area.w) {
|
||||
const areaW = previousArea?.w || (30 / pageMask.clientWidth)
|
||||
const areaH = previousArea?.h || (30 / pageMask.clientHeight)
|
||||
|
||||
if ((pageMask.clientWidth * area.w) < 5) {
|
||||
area.x = area.x - (areaW / 2)
|
||||
area.y = area.y - (areaH / 2)
|
||||
if ((pageMask.clientWidth * area.w) < 5) {
|
||||
area.x = area.x - (areaW / 2)
|
||||
area.y = area.y - (areaH / 2)
|
||||
}
|
||||
|
||||
area.w = areaW
|
||||
area.h = areaH
|
||||
}
|
||||
}
|
||||
|
||||
if (area.w) {
|
||||
const field = {
|
||||
name: '',
|
||||
uuid: v4(),
|
||||
required: type !== 'checkbox',
|
||||
type,
|
||||
submitter_uuid: this.selectedSubmitter.uuid,
|
||||
areas: [area]
|
||||
}
|
||||
|
||||
area.w = areaW
|
||||
area.h = areaH
|
||||
}
|
||||
this.template.fields.push(field)
|
||||
|
||||
const field = {
|
||||
name: '',
|
||||
uuid: v4(),
|
||||
required: type !== 'checkbox',
|
||||
type,
|
||||
submitter_uuid: this.selectedSubmitter.uuid,
|
||||
areas: [area]
|
||||
}
|
||||
this.selectedAreaRef.value = area
|
||||
|
||||
this.template.fields.push(field)
|
||||
this.save()
|
||||
}
|
||||
}
|
||||
|
||||
this.selectedAreaRef.value = area
|
||||
|
||||
this.save()
|
||||
},
|
||||
onDropfield (area) {
|
||||
const field = {
|
||||
@@ -473,6 +539,11 @@ export default {
|
||||
w: area.maskW / 5 / area.maskW,
|
||||
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH) / 2
|
||||
}
|
||||
} else if (this.dragFieldType === 'initials') {
|
||||
baseArea = {
|
||||
w: area.maskW / 10 / area.maskW,
|
||||
h: area.maskW / 35 / area.maskW
|
||||
}
|
||||
} else {
|
||||
baseArea = {
|
||||
w: area.maskW / 5 / area.maskW,
|
||||
@@ -584,6 +655,8 @@ export default {
|
||||
this.$el.closest('template-builder').dataset.template = JSON.stringify(this.template)
|
||||
}
|
||||
|
||||
this.pushUndo()
|
||||
|
||||
return this.baseFetch(`/api/templates/${this.template.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
:class="{ 'ml-1': !withRequired, 'absolute': !iconInline, 'inline align-bottom': iconInline }"
|
||||
:width="iconWidth"
|
||||
:stroke-width="iconStrokeWidth"
|
||||
@click="focusContenteditable"
|
||||
@click="[focusContenteditable(), selectOnEditClick && selectContent()]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -64,6 +64,11 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
selectOnEditClick: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
iconStrokeWidth: {
|
||||
type: Number,
|
||||
required: false,
|
||||
@@ -85,6 +90,19 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
selectContent () {
|
||||
const el = this.$refs.contenteditable
|
||||
|
||||
const range = document.createRange()
|
||||
|
||||
range.selectNodeContents(el)
|
||||
|
||||
const sel = window.getSelection()
|
||||
|
||||
sel.removeAllRanges()
|
||||
|
||||
sel.addRange(range)
|
||||
},
|
||||
onBlur (e) {
|
||||
setTimeout(() => {
|
||||
this.value = this.$refs.contenteditable.innerText.trim() || this.modelValue
|
||||
|
||||
@@ -99,6 +99,19 @@
|
||||
Draw New Area
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells'].includes(field.type)">
|
||||
<a
|
||||
href="#"
|
||||
class="text-sm py-1 px-2"
|
||||
@click.prevent="copyToAllPages(field)"
|
||||
>
|
||||
<IconCopy
|
||||
:width="20"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
Copy to All Pages
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</span>
|
||||
<button
|
||||
@@ -183,7 +196,7 @@
|
||||
<script>
|
||||
import Contenteditable from './contenteditable'
|
||||
import FieldType from './field_type'
|
||||
import { IconShape, IconNewSection, IconTrashX } from '@tabler/icons-vue'
|
||||
import { IconShape, IconNewSection, IconTrashX, IconCopy } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'TemplateField',
|
||||
@@ -192,6 +205,7 @@ export default {
|
||||
IconShape,
|
||||
IconNewSection,
|
||||
IconTrashX,
|
||||
IconCopy,
|
||||
FieldType
|
||||
},
|
||||
inject: ['template', 'save'],
|
||||
@@ -221,6 +235,23 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
copyToAllPages (field) {
|
||||
const areaString = JSON.stringify(field.areas[0])
|
||||
|
||||
this.template.documents.forEach((attachment) => {
|
||||
attachment.preview_images.forEach((page) => {
|
||||
if (!field.areas.find((area) => area.attachment_uuid === attachment.uuid && area.page === parseInt(page.filename))) {
|
||||
field.areas.push({ ...JSON.parse(areaString), page: parseInt(page.filename) })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.$emit('scroll-to', this.field.areas[this.field.areas.length - 1])
|
||||
})
|
||||
|
||||
this.save()
|
||||
},
|
||||
onNameFocus (e) {
|
||||
this.isNameFocus = true
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
v-model="selectedSubmitter.name"
|
||||
class="cursor-text"
|
||||
:icon-inline="true"
|
||||
:select-on-edit-click="true"
|
||||
:icon-width="18"
|
||||
@update:model-value="$emit('name-change', selectedSubmitter)"
|
||||
/>
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck } from '@tabler/icons-vue'
|
||||
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'FiledTypeDropdown',
|
||||
@@ -75,6 +75,7 @@ export default {
|
||||
return {
|
||||
text: 'Text',
|
||||
signature: 'Signature',
|
||||
initials: 'Initials',
|
||||
date: 'Date',
|
||||
image: 'Image',
|
||||
file: 'File',
|
||||
@@ -90,14 +91,15 @@ export default {
|
||||
return {
|
||||
text: IconTextSize,
|
||||
signature: IconWritingSign,
|
||||
initials: IconLetterCaseUpper,
|
||||
date: IconCalendarEvent,
|
||||
image: IconPhoto,
|
||||
file: IconPaperclip,
|
||||
select: IconSelect,
|
||||
checkbox: IconCheckbox,
|
||||
cells: IconColumns3,
|
||||
multiple: IconChecks,
|
||||
radio: IconCircleDot,
|
||||
multiple: IconChecks,
|
||||
cells: IconColumns3,
|
||||
phone: IconPhoneCheck
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
</button>
|
||||
<div
|
||||
v-else
|
||||
class="tooltip flex"
|
||||
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."
|
||||
>
|
||||
<a
|
||||
|
||||
@@ -11,7 +11,7 @@ class ProcessSubmitterCompletionJob < ApplicationJob
|
||||
Submissions::EnsureResultGenerated.call(submitter)
|
||||
|
||||
if submitter.account.encrypted_configs.exists?(key: EncryptedConfig::WEBHOOK_URL_KEY)
|
||||
SendWebhookRequestJob.perform_later(submitter)
|
||||
SendFormCompletedWebhookRequestJob.perform_later(submitter)
|
||||
end
|
||||
|
||||
return unless is_all_completed
|
||||
@@ -26,7 +26,8 @@ class ProcessSubmitterCompletionJob < ApplicationJob
|
||||
user = submitter.submission.created_by_user || submitter.template.author
|
||||
|
||||
if submitter.template.account.users.exists?(id: user.id)
|
||||
bcc = submitter.submission.template.account.account_configs.find_by(key: 'bcc_emails')&.value
|
||||
bcc = submitter.submission.template.account.account_configs
|
||||
.find_by(key: AccountConfig::BCC_EMAILS)&.value
|
||||
|
||||
SubmitterMailer.completed_email(submitter, user, bcc:).deliver_later!
|
||||
end
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SendWebhookRequestJob < ApplicationJob
|
||||
class SendFormCompletedWebhookRequestJob < ApplicationJob
|
||||
USER_AGENT = 'DocuSeal.co Webhook'
|
||||
|
||||
def perform(submitter)
|
||||
@@ -14,7 +14,7 @@ class SendWebhookRequestJob < ApplicationJob
|
||||
|
||||
Faraday.post(config.value,
|
||||
{
|
||||
event_type: 'form.submitted',
|
||||
event_type: 'form.completed',
|
||||
timestamp: Time.current.iso8601,
|
||||
data: Submitters::SerializeForWebhook.call(submitter)
|
||||
}.to_json,
|
||||
@@ -0,0 +1,22 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SendFormStartedWebhookRequestJob < ApplicationJob
|
||||
USER_AGENT = 'DocuSeal.co Webhook'
|
||||
|
||||
def perform(submitter)
|
||||
config = submitter.submission.account.encrypted_configs.find_by(key: EncryptedConfig::WEBHOOK_URL_KEY)
|
||||
|
||||
return if config.blank? || config.value.blank?
|
||||
|
||||
ActiveStorage::Current.url_options = Docuseal.default_url_options
|
||||
|
||||
Faraday.post(config.value,
|
||||
{
|
||||
event_type: 'form.started',
|
||||
timestamp: Time.current.iso8601,
|
||||
data: Submitters::SerializeForWebhook.call(submitter)
|
||||
}.to_json,
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => USER_AGENT)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,22 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SendFormViewedWebhookRequestJob < ApplicationJob
|
||||
USER_AGENT = 'DocuSeal.co Webhook'
|
||||
|
||||
def perform(submitter)
|
||||
config = submitter.submission.account.encrypted_configs.find_by(key: EncryptedConfig::WEBHOOK_URL_KEY)
|
||||
|
||||
return if config.blank? || config.value.blank?
|
||||
|
||||
ActiveStorage::Current.url_options = Docuseal.default_url_options
|
||||
|
||||
Faraday.post(config.value,
|
||||
{
|
||||
event_type: 'form.viewed',
|
||||
timestamp: Time.current.iso8601,
|
||||
data: Submitters::SerializeForWebhook.call(submitter)
|
||||
}.to_json,
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => USER_AGENT)
|
||||
end
|
||||
end
|
||||
@@ -1,8 +1,10 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SendSubmitterInvitationEmailJob < ApplicationJob
|
||||
def perform(submitter)
|
||||
SubmitterMailer.invitation_email(submitter).deliver_now!
|
||||
def perform(params = {})
|
||||
submitter = Submitter.find(params['submitter_id'])
|
||||
|
||||
SubmitterMailer.invitation_email(submitter, subject: params['subject'], body: params['body']).deliver_now!
|
||||
|
||||
SubmissionEvent.create!(submitter:, event_type: 'send_email')
|
||||
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmitterMailer < ApplicationMailer
|
||||
DEFAULT_MESSAGE = %(You have been invited to submit the "%<name>s" form:)
|
||||
|
||||
def invitation_email(submitter, message: '')
|
||||
def invitation_email(submitter, body: nil, subject: nil)
|
||||
@current_account = submitter.submission.template.account
|
||||
@submitter = submitter
|
||||
@message = message.presence || format(DEFAULT_MESSAGE, name: submitter.submission.template.name)
|
||||
@body = body.presence
|
||||
|
||||
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY)
|
||||
|
||||
subject =
|
||||
if @email_config
|
||||
ReplaceEmailVariables.call(@email_config.value['subject'], submitter:)
|
||||
if @email_config || subject.present?
|
||||
ReplaceEmailVariables.call(subject.presence || @email_config.value['subject'], submitter:)
|
||||
else
|
||||
'You have been invited to submit a form'
|
||||
end
|
||||
|
||||
@@ -16,6 +16,9 @@ class Account < ApplicationRecord
|
||||
has_many :encrypted_configs, dependent: :destroy
|
||||
has_many :account_configs, dependent: :destroy
|
||||
has_many :templates, dependent: :destroy
|
||||
has_many :template_folders, dependent: :destroy
|
||||
has_one :default_template_folder, -> { where(name: TemplateFolder::DEFAULT_NAME) },
|
||||
class_name: 'TemplateFolder', dependent: :destroy, inverse_of: :account
|
||||
has_many :submissions, through: :templates
|
||||
has_many :submitters, through: :submissions
|
||||
has_many :active_users, -> { active }, dependent: :destroy,
|
||||
@@ -23,4 +26,9 @@ class Account < ApplicationRecord
|
||||
|
||||
attribute :timezone, :string, default: 'UTC'
|
||||
attribute :locale, :string, default: 'en-US'
|
||||
|
||||
def default_template_folder
|
||||
super || build_default_template_folder(name: TemplateFolder::DEFAULT_NAME,
|
||||
author_id: users.minimum(:id)).tap(&:save!)
|
||||
end
|
||||
end
|
||||
@@ -24,6 +24,9 @@ class AccountConfig < ApplicationRecord
|
||||
SUBMITTER_INVITATION_EMAIL_KEY = 'submitter_invitation_email'
|
||||
SUBMITTER_COMPLETED_EMAIL_KEY = 'submitter_completed_email'
|
||||
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY = 'submitter_documents_copy_email'
|
||||
BCC_EMAILS = 'bcc_emails'
|
||||
SUBMITTER_REMAILERS = 'submitter_reminders'
|
||||
FORM_COMPLETED_BUTTON_KEY = 'form_completed_button'
|
||||
|
||||
DEFAULT_VALUES = {
|
||||
SUBMITTER_INVITATION_EMAIL_KEY => {
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
#
|
||||
class SubmissionEvent < ApplicationRecord
|
||||
belongs_to :submission
|
||||
has_one :account, through: :submission
|
||||
belongs_to :submitter, optional: true
|
||||
|
||||
attribute :data, :string, default: -> { {} }
|
||||
@@ -36,10 +37,12 @@ class SubmissionEvent < ApplicationRecord
|
||||
|
||||
enum :event_type, {
|
||||
send_email: 'send_email',
|
||||
send_reminder_email: 'send_reminder_email',
|
||||
send_sms: 'send_sms',
|
||||
open_email: 'open_email',
|
||||
click_email: 'click_email',
|
||||
click_sms: 'click_sms',
|
||||
phone_verified: 'phone_verified',
|
||||
start_form: 'start_form',
|
||||
view_form: 'view_form',
|
||||
complete_form: 'complete_form'
|
||||
|
||||
@@ -59,7 +59,7 @@ class Submitter < ApplicationRecord
|
||||
end
|
||||
|
||||
def friendly_name
|
||||
if name.present?
|
||||
if name.present? && email.present? && email.exclude?(',')
|
||||
"#{name} <#{email}>"
|
||||
else
|
||||
email
|
||||
|
||||
@@ -16,23 +16,29 @@
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# author_id :bigint not null
|
||||
# folder_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_templates_on_account_id (account_id)
|
||||
# index_templates_on_author_id (author_id)
|
||||
# index_templates_on_folder_id (folder_id)
|
||||
# index_templates_on_slug (slug) UNIQUE
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (account_id => accounts.id)
|
||||
# fk_rails_... (author_id => users.id)
|
||||
# fk_rails_... (folder_id => template_folders.id)
|
||||
#
|
||||
class Template < ApplicationRecord
|
||||
DEFAULT_SUBMITTER_NAME = 'First Submitter'
|
||||
|
||||
belongs_to :author, class_name: 'User'
|
||||
belongs_to :account
|
||||
belongs_to :folder, class_name: 'TemplateFolder'
|
||||
|
||||
before_validation :maybe_set_default_folder, on: :create
|
||||
|
||||
attribute :fields, :string, default: -> { [] }
|
||||
attribute :schema, :string, default: -> { [] }
|
||||
@@ -52,4 +58,11 @@ class Template < ApplicationRecord
|
||||
has_many :submissions, dependent: :destroy
|
||||
|
||||
scope :active, -> { where(deleted_at: nil) }
|
||||
scope :archived, -> { where.not(deleted_at: nil) }
|
||||
|
||||
private
|
||||
|
||||
def maybe_set_default_folder
|
||||
self.folder ||= account.default_template_folder
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,40 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: template_folders
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# deleted_at :datetime
|
||||
# name :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# author_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_template_folders_on_account_id (account_id)
|
||||
# index_template_folders_on_author_id (author_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (account_id => accounts.id)
|
||||
# fk_rails_... (author_id => users.id)
|
||||
#
|
||||
class TemplateFolder < ApplicationRecord
|
||||
DEFAULT_NAME = 'Default'
|
||||
|
||||
belongs_to :author, class_name: 'User'
|
||||
belongs_to :account
|
||||
|
||||
has_many :templates, dependent: :destroy, foreign_key: :folder_id, inverse_of: :folder
|
||||
has_many :active_templates, -> { where(deleted_at: nil) },
|
||||
class_name: 'Template', dependent: :destroy, foreign_key: :folder_id, inverse_of: :folder
|
||||
|
||||
scope :active, -> { where(deleted_at: nil) }
|
||||
|
||||
def default?
|
||||
name == DEFAULT_NAME
|
||||
end
|
||||
end
|
||||
+6
-2
@@ -5,6 +5,7 @@
|
||||
# Table name: users
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# consumed_timestep :integer
|
||||
# current_sign_in_at :datetime
|
||||
# current_sign_in_ip :string
|
||||
# deleted_at :datetime
|
||||
@@ -16,6 +17,8 @@
|
||||
# last_sign_in_at :datetime
|
||||
# last_sign_in_ip :string
|
||||
# locked_at :datetime
|
||||
# otp_required_for_login :boolean default(FALSE), not null
|
||||
# otp_secret :string
|
||||
# remember_created_at :datetime
|
||||
# reset_password_sent_at :datetime
|
||||
# reset_password_token :string
|
||||
@@ -44,13 +47,14 @@ class User < ApplicationRecord
|
||||
ADMIN_ROLE = 'admin'
|
||||
].freeze
|
||||
|
||||
EMAIL_REGEXP = /[^@,\s]+@[^@,\s]+/
|
||||
EMAIL_REGEXP = /[^@;,<>\s]+@[^@;,<>\s]+/
|
||||
|
||||
belongs_to :account
|
||||
has_one :access_token, dependent: :destroy
|
||||
has_many :templates, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
|
||||
has_many :user_configs, dependent: :destroy
|
||||
|
||||
devise :database_authenticatable, :recoverable, :rememberable, :validatable, :trackable
|
||||
devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable
|
||||
devise :registerable, :omniauthable, omniauth_providers: [:google_oauth2] if Docuseal.multitenant?
|
||||
|
||||
attribute :role, :string, default: ADMIN_ROLE
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: user_configs
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# key :string not null
|
||||
# value :text not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# user_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_user_configs_on_user_id (user_id)
|
||||
# index_user_configs_on_user_id_and_key (user_id,key) UNIQUE
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (user_id => users.id)
|
||||
#
|
||||
class UserConfig < ApplicationRecord
|
||||
SIGNATURE_KEY = 'signature'
|
||||
|
||||
belongs_to :user
|
||||
|
||||
serialize :value, JSON
|
||||
end
|
||||
@@ -29,9 +29,11 @@
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<div class="form-control pt-2">
|
||||
<%= f.button button_title(title: 'Update', disabled_with: 'Updating'), class: 'base-button' %>
|
||||
</div>
|
||||
<% if can?(:update, current_account) %>
|
||||
<div class="form-control pt-2">
|
||||
<%= f.button button_title(title: 'Update', disabled_with: 'Updating'), class: 'base-button' %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="w-0 md:w-52"></div>
|
||||
|
||||
@@ -12,32 +12,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4 mt-4">
|
||||
<div class="collapse collapse-plus bg-base-200 px-1">
|
||||
<input type="checkbox">
|
||||
<div class="collapse-title text-xl font-medium">
|
||||
<div>
|
||||
Request signature, single submitter
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<div class="badge badge-warning badge-lg">POST</div>
|
||||
<div class="badge badge-primary badge-lg"><%= api_submissions_path %></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse-content" style="display: inherit">
|
||||
<div class="mockup-code overflow-hidden">
|
||||
<% text = capture do %>curl --location '<%= api_submissions_url %>' \
|
||||
--header 'X-Auth-Token: <%= current_user.access_token.token %>' \
|
||||
--data-raw '{
|
||||
"template_id": <%= current_account.templates.last&.id || 1 %>,
|
||||
"emails": "<%= current_user.email.sub('@', '+test@') %>, <%= current_user.email.sub('@', '+test2@') %>"
|
||||
}'<% end.to_str %>
|
||||
<span class="top-0 right-0 absolute">
|
||||
<%= render 'shared/clipboard_copy', icon: 'copy', text:, class: 'btn btn-ghost text-white', icon_class: 'w-6 h-6 text-white', copy_title: 'Copy', copied_title: 'Copied' %>
|
||||
</span>
|
||||
<pre data-prefix="$"><code class="overflow-hidden w-full"><%= text %></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse collapse-plus bg-base-200 px-1">
|
||||
<input type="checkbox">
|
||||
<div class="collapse-title text-xl font-medium">
|
||||
@@ -66,7 +40,7 @@
|
||||
"Form Text Field Name": "Default Value"
|
||||
}
|
||||
},
|
||||
{ "name": "Second Submitter", "email": "<%= current_user.email.sub('@', '+test2@') %>" }
|
||||
{ "role": "Second Submitter", "email": "<%= current_user.email.sub('@', '+test2@') %>" }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -78,6 +52,32 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse collapse-plus bg-base-200 px-1">
|
||||
<input type="checkbox">
|
||||
<div class="collapse-title text-xl font-medium">
|
||||
<div>
|
||||
Request signature, single submitter
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<div class="badge badge-warning badge-lg">POST</div>
|
||||
<div class="badge badge-primary badge-lg"><%= api_submissions_emails_path %></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse-content" style="display: inherit">
|
||||
<div class="mockup-code overflow-hidden">
|
||||
<% text = capture do %>curl --location '<%= api_submissions_emails_url %>' \
|
||||
--header 'X-Auth-Token: <%= current_user.access_token.token %>' \
|
||||
--data-raw '{
|
||||
"template_id": <%= current_account.templates.last&.id || 1 %>,
|
||||
"emails": "<%= current_user.email.sub('@', '+test@') %>, <%= current_user.email.sub('@', '+test2@') %>"
|
||||
}'<% end.to_str %>
|
||||
<span class="top-0 right-0 absolute">
|
||||
<%= render 'shared/clipboard_copy', icon: 'copy', text:, class: 'btn btn-ghost text-white', icon_class: 'w-6 h-6 text-white', copy_title: 'Copy', copied_title: 'Copied' %>
|
||||
</span>
|
||||
<pre data-prefix="$"><code class="overflow-hidden w-full"><%= text %></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="collapse collapse-plus bg-base-200 px-1">
|
||||
<input type="checkbox">
|
||||
<div class="collapse-title text-xl font-medium">
|
||||
@@ -101,5 +101,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<%= link_to 'Open Full API Reference', "#{Docuseal::PRODUCT_URL}/docs/api", class: 'btn btn-warning text-base mt-4 px-8', target: '_blank', rel: 'noopener' %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,49 +1,83 @@
|
||||
<% if Docuseal.demo? %><%= render 'shared/demo_alert' %><% end %>
|
||||
<% if @templates.any? %>
|
||||
<% if @pagy.count > 0 || params[:q].present? || @template_folders.present? %>
|
||||
<div class="flex justify-between mb-4 items-center">
|
||||
<h1 class="text-4xl font-bold"><span class="hidden md:inline">Document</span> Templates</h1>
|
||||
<div class="flex space-x-2">
|
||||
<%= render 'templates/upload_button' %>
|
||||
<%= link_to new_template_path, class: 'btn btn-primary text-base btn-md gap-2', data: { turbo_frame: :modal } do %>
|
||||
<%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %>
|
||||
<span class="hidden md:block">Create</span>
|
||||
<% if params[:q].present? || @pagy.pages > 1 || @template_folders.present? %>
|
||||
<%= render 'shared/search_input' %>
|
||||
<% 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 %>
|
||||
<%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %>
|
||||
<span class="hidden md:block">Create</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<% @templates.each do |template| %>
|
||||
<div class="h-36 relative group">
|
||||
<a href="<%= template_path(template) %>" class="flex h-full flex-col justify-between rounded-2xl pt-6 px-7 w-full bg-base-200 peer">
|
||||
<div class="pb-4 text-xl font-semibold" style="overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;">
|
||||
<%= template.name %>
|
||||
</div>
|
||||
<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>
|
||||
</p>
|
||||
<p class="flex items-center space-x-1 text-xs text-base-content/60">
|
||||
<%= svg_icon('calendar', class: 'w-4 h-4') %>
|
||||
<span><%= l(template.created_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %></span>
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<div class="absolute top-0 bottom-0 w-0 pt-7 space-y-1.5 hidden md:group-hover:block" style="right: 40px">
|
||||
<a href="<%= edit_template_path(template) %>" class="btn btn-xs hover:btn-outline bg-base-200 btn-circle">
|
||||
<%= svg_icon('pencil', class: 'w-4 h-4') %>
|
||||
</a>
|
||||
<a href="<%= new_template_path(base_template_id: template.id) %>" data-turbo-frame="modal" class="btn btn-xs hover:btn-outline bg-base-200 btn-circle">
|
||||
<%= svg_icon('copy', class: 'w-4 h-4') %>
|
||||
</a>
|
||||
<%= button_to template_path(template), data: { turbo_confirm: 'Are you sure?' }, method: :delete, class: 'btn btn-xs hover:btn-outline bg-base-200 btn-circle' do %>
|
||||
<%= svg_icon('trash', class: 'w-4 h-4 enabled') %>
|
||||
<%= svg_icon('loader', class: 'w-4 h-4 animate-spin disabled') %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% view_archived_html = capture do %>
|
||||
<% if current_account.templates.where.not(deleted_at: nil).exists? %>
|
||||
<div>
|
||||
<a href="<%= templates_archived_index_path %>" class="link text-sm">View Archived</a>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= render 'shared/pagination', pagy: @pagy, items_name: 'templates' %>
|
||||
<% end %>
|
||||
<% if @template_folders.present? %>
|
||||
<div class="grid gap-4 md:grid-cols-3 <%= 'mb-6' if @templates.present? %>">
|
||||
<%= render partial: 'template_folders/folder', collection: @template_folders, as: :folder %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if @templates.present? %>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<%= render partial: 'templates/template', collection: @templates %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if params[:q].blank? && @pagy.pages == 1 && ((@template_folders.size < 10 && @templates.size.zero?) || (@template_folders.size < 7 && @templates.size < 4) || (@template_folders.size < 4 && @templates.size < 7)) %>
|
||||
<%= form_for '', url: templates_upload_path, method: :post, class: 'mt-8 block', html: { enctype: 'multipart/form-data' } do %>
|
||||
<button type="submit" class="hidden"></button>
|
||||
<file-dropzone data-submit-on-upload="true" class="w-full">
|
||||
<label for="file_dropzone_input" class="w-full block h-52 relative hover:bg-base-200/30 rounded-xl border border-2 border-base-300 border-dashed">
|
||||
<div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<span data-target="file-dropzone.icon" class="flex flex-col items-center">
|
||||
<span>
|
||||
<%= svg_icon('cloud_upload', class: 'w-10 h-10') %>
|
||||
</span>
|
||||
<div class="font-medium mb-1">
|
||||
Upload New Document
|
||||
</div>
|
||||
<div class="text-xs">
|
||||
<span class="font-medium">Click to upload</span> or drag and drop
|
||||
</div>
|
||||
</span>
|
||||
<span data-target="file-dropzone.loading" class="flex flex-col items-center hidden">
|
||||
<%= svg_icon('loader', class: 'w-10 h-10 animate-spin') %>
|
||||
<div class="font-medium mb-1">
|
||||
Uploading...
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<input id="file_dropzone_input" name="files[]" class="hidden" data-action="change:file-dropzone#onSelectFiles" data-target="file-dropzone.input" type="file" accept="image/*, application/pdf<%= ', .docx, .doc, .xlsx, .xls' if Docuseal.multitenant? %>" multiple>
|
||||
</div>
|
||||
</label>
|
||||
</file-dropzone>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if @templates.present? || params[:q].blank? %>
|
||||
<% if @pagy.pages > 1 %>
|
||||
<%= render 'shared/pagination', pagy: @pagy, items_name: 'templates', left_additional_html: view_archived_html %>
|
||||
<% else %>
|
||||
<div class="mt-2">
|
||||
<%= view_archived_html %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% elsif params[:q].present? %>
|
||||
<div class="text-center">
|
||||
<div class="mt-16 text-3xl font-semibold">
|
||||
Templates not Found
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="card bg-base-200 h-96 mb-2">
|
||||
<div class="card-body text-center">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="max-w-xl mx-auto px-2">
|
||||
<h1 class="text-4xl font-bold text-center my-8">Change your password</h1>
|
||||
<div class="max-w-lg mx-auto px-2">
|
||||
<h1 class="text-4xl font-bold text-center mt-8">Change your password</h1>
|
||||
<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put, class: 'space-y-6' }) do |f| %>
|
||||
<div class="space-y-2">
|
||||
<%= render 'devise/shared/error_messages', resource: %>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="max-w-xl mx-auto px-2">
|
||||
<h1 class="text-4xl font-bold text-center my-8">Forgot your password?</h1>
|
||||
<div class="max-w-lg mx-auto px-2">
|
||||
<h1 class="text-4xl font-bold text-center mt-8">Forgot your password?</h1>
|
||||
<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post, class: 'space-y-6' }) do |f| %>
|
||||
<div class="space-y-2">
|
||||
<%= render 'devise/shared/error_messages', resource: %>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="max-w-xl mx-auto px-2">
|
||||
<h1 class="text-3xl font-bold text-center my-8">Profile Details</h1>
|
||||
<div class="max-w-lg mx-auto px-2">
|
||||
<h1 class="text-3xl font-bold text-center mt-8">Profile Details</h1>
|
||||
<%= form_for('', as: resource_name, html: { class: 'space-y-6' }, data: { turbo: params[:redir].blank? }, url: new_registration_path) do |f| %>
|
||||
<% if params[:redir].present? %>
|
||||
<%= hidden_field_tag :redir, params[:redir] %>
|
||||
@@ -21,7 +21,7 @@
|
||||
<%= f.fields_for resource do |ff| %>
|
||||
<div class="form-control <%= 'hidden' if (params[:oauth_callback] || params[:sign_up]) && resource.email? %>">
|
||||
<%= ff.label :email, class: 'label' %>
|
||||
<%= ff.email_field :email, required: true, class: 'base-input' %>
|
||||
<%= ff.email_field :email, value: EmailTypo.call(resource.email), required: true, class: 'base-input' %>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= f.fields_for resource.account do |ff| %>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="max-w-xl mx-auto px-2">
|
||||
<div class="max-w-lg mx-auto px-2">
|
||||
<%= render 'devise/shared/select_server' if Docuseal.multitenant? %>
|
||||
<h1 class="text-3xl font-bold text-center my-8">Create Free Account</h1>
|
||||
<h1 class="text-3xl font-bold text-center mt-8 mb-6">Create Free Account</h1>
|
||||
<%= form_for(User.new, html: { class: 'space-y-6' }, url: new_registration_path, data: { turbo: params[:redir].blank? }, method: :get) do |f| %>
|
||||
<% if params[:redir].present? %>
|
||||
<%= hidden_field_tag :redir, params[:redir] %>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="max-w-xl mx-auto px-2">
|
||||
<div class="max-w-lg mx-auto px-2">
|
||||
<%= render 'devise/shared/select_server' if Docuseal.multitenant? %>
|
||||
<h1 class="text-4xl font-bold text-center my-8">Log In</h1>
|
||||
<h1 class="text-4xl font-bold text-center mt-8">Log In</h1>
|
||||
<%= form_for(resource, as: resource_name, html: { class: 'space-y-6' }, data: { turbo: params[:redir].blank? }, url: session_path(resource_name)) do |f| %>
|
||||
<% if params[:redir].present? %>
|
||||
<%= hidden_field_tag :redir, params[:redir] %>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<div class="max-w-lg mx-auto px-2">
|
||||
<h1 class="text-4xl font-bold text-center mt-8">Log In</h1>
|
||||
<%= form_for(resource, as: resource_name, html: { class: 'space-y-6' }, data: { turbo: params[:redir].blank? }, url: session_path(resource_name)) do |f| %>
|
||||
<%= f.hidden_field :email %>
|
||||
<%= f.hidden_field :password %>
|
||||
<% if params[:redir].present? %>
|
||||
<%= hidden_field_tag :redir, params[:redir] %>
|
||||
<% end %>
|
||||
<div class="space-y-2">
|
||||
<div class="form-control">
|
||||
<%= f.label :otp_attempt, 'Two-Factor Code', class: 'label' %>
|
||||
<%= f.text_field :otp_attempt, autofocus: true, placeholder: 'XXX-XXX', required: true, class: 'base-input' %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<%= f.button button_title(title: 'Log In', disabled_with: 'Logging In'), class: 'base-button' %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
+2
-2
@@ -18,11 +18,11 @@
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<%= ff.label :username, class: 'label' %>
|
||||
<%= ff.text_field :username, value: value['username'], required: true, class: 'base-input' %>
|
||||
<%= ff.text_field :username, value: value['username'], class: 'base-input' %>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<%= ff.label :password, class: 'label' %>
|
||||
<%= ff.password_field :password, value: value['password'], required: true, class: 'base-input' %>
|
||||
<%= ff.password_field :password, value: value['password'], class: 'base-input' %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
@@ -14,7 +14,7 @@
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<%= f.label :password, 'Password (optional)', class: 'label' %>
|
||||
<%= f.text_field :password, class: 'base-input' %>
|
||||
<%= f.password_field :password, class: 'base-input' %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control pt-2">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="flex flex-wrap space-y-4 md:flex-nowrap md:space-y-0 md:space-x-10">
|
||||
<div class="flex-wrap space-y-4 md:flex md:flex-nowrap md:space-y-0 md:space-x-10">
|
||||
<%= render 'shared/settings_nav' %>
|
||||
<div class="flex-grow">
|
||||
<div class="md:flex-grow">
|
||||
<div class="max-w-xl">
|
||||
<h1 class="text-4xl font-bold mb-4">PDF Signature</h1>
|
||||
<div id="result">
|
||||
@@ -40,9 +40,11 @@
|
||||
</div>
|
||||
<div class="flex justify-between items-end mb-4 mt-8">
|
||||
<h2 class="text-3xl font-bold">Signing Certificates</h2>
|
||||
<%= link_to new_settings_esign_path, class: 'btn btn-primary btn-md', data: { turbo_frame: 'modal' } do %>
|
||||
<%= svg_icon('plus', class: 'w-6 h-6') %>
|
||||
<span>Upload Cert</span>
|
||||
<% if can?(:create, @encrypted_config) %>
|
||||
<%= link_to new_settings_esign_path, class: 'btn btn-primary btn-md', data: { turbo_frame: 'modal' } do %>
|
||||
<%= svg_icon('plus', class: 'w-6 h-6') %>
|
||||
<span>Upload Cert</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= render 'alert' %>
|
||||
@@ -77,14 +79,14 @@
|
||||
<span class="badge badge-lg badge-info badge-outline">
|
||||
<%= item['status'] %>
|
||||
</span>
|
||||
<% else %>
|
||||
<% elsif can?(:update, @encrypted_config) %>
|
||||
<%= button_to settings_esign_path, method: :put, params: { name: item['name'] }, class: 'btn btn-outline btn-neutral btn-xs whitespace-nowrap', title: 'Delete', data: { turbo_confirm: 'Are you sure?' } do %>
|
||||
Make Default
|
||||
<% end %>
|
||||
<% end %>
|
||||
</td>
|
||||
<td>
|
||||
<% if item['name'] != EsignSettingsController::DEFAULT_CERT_NAME && item['status'] != 'default' %>
|
||||
<% if item['name'] != EsignSettingsController::DEFAULT_CERT_NAME && item['status'] != 'default' && can?(:destroy, @encrypted_config) %>
|
||||
<%= button_to settings_esign_path, params: { name: item['name'] }, method: :delete, class: 'btn btn-outline btn-error btn-xs', title: 'Delete', data: { turbo_confirm: 'Are you sure?' } do %>
|
||||
Remove
|
||||
<% end %>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" 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>
|
||||
<path d="M17 7l-10 10"></path>
|
||||
<path d="M8 7l9 0l0 9"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 354 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" 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>
|
||||
<path d="M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" 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>
|
||||
<path d="M13 19h-8a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2h4l3 3h7a2 2 0 0 1 2 2v4"></path>
|
||||
<path d="M16 22l5 -5"></path>
|
||||
<path d="M21 21.5v-4.5h-4.5"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 448 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" 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>
|
||||
<path d="M4 20h4l10.5 -10.5a2.828 2.828 0 1 0 -4 -4l-10.5 10.5v4"></path>
|
||||
<path d="M13.5 6.5l4 4"></path>
|
||||
<path d="M16 22l5 -5"></path>
|
||||
<path d="M21 21.5v-4.5h-4.5"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 469 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M19.95 11a8 8 0 1 0 -.5 4m.5 5v-5h-5"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 345 B |
Loaded 100 of 180 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user