Compare commits

...
56 Commits
Author SHA1 Message Date
Alex Turchyn 9d9e134bb4 use default date format in pdf result 2023-09-14 10:35:43 +03:00
Alex Turchyn ec4f399fcc add template upload button 2023-09-14 10:35:43 +03:00
Alex Turchyn effa7b0fc7 add audit log button 2023-09-14 10:35:43 +03:00
Oleksandr Turchyn 016d9de87a Merge pull request #105 from imchairmanm/add_koyeb_target
add Koyeb to README as deployment target
2023-09-14 01:25:46 +03:00
Justin Ellingwood 71ff064356 add Koyeb to README as deployment target 2023-09-13 13:52:31 +01:00
Alex Turchyn 9100d48cc3 fix audit trail generation 2023-09-12 22:36:04 +03:00
Alex Turchyn 21b207cb1a fix submissions api 2023-09-11 09:49:59 +03:00
Alex Turchyn 3aa703032f adjust audit trail 2023-09-11 01:24:43 +03:00
Alex Turchyn fcfd2e13bd track form open after link click 2023-09-10 20:46:45 +03:00
Alex Turchyn cf6cacdc92 add audit trail 2023-09-10 20:46:44 +03:00
Alex Turchyn 8cabe31827 add submission tracking events 2023-09-10 17:49:10 +03:00
Alex Turchyn 62cea05084 add preserve submitters order option 2023-09-10 11:24:28 +03:00
Alex Turchyn 07e49f9f07 adjust default email 2023-09-09 23:17:41 +03:00
Alex Turchyn baad784818 send docs copy only if email present 2023-09-09 14:00:14 +03:00
Alex Turchyn 6eecd3afa4 fix email template submitters list 2023-09-09 11:44:04 +03:00
Alex Turchyn 8e90100871 fix submission download 2023-09-09 02:00:11 +03:00
Alex Turchyn 12c5138364 adjust add recipients view 2023-09-09 01:25:44 +03:00
Alex Turchyn 80ad166fd9 automatically send emails when all parties completed the form 2023-09-09 00:43:32 +03:00
Alex Turchyn 2fab6984ea fix form button loading indicator 2023-09-06 22:21:15 +03:00
Alex Turchyn 422639114b add bcc emails config 2023-09-06 21:41:23 +03:00
Alex Turchyn fca441878a send completed notification emails to a single user 2023-09-06 20:10:55 +03:00
Alex Turchyn 76578077e8 fix pdf rendering with rotate prop 2023-09-06 17:27:54 +03:00
Alex Turchyn bbd3618f0e add js alert translation 2023-09-05 22:28:50 +03:00
Alex Turchyn 47ed8db955 fix typo 2023-09-05 21:53:17 +03:00
Alex Turchyn fec6e2e35d add portuguese form translations 2023-09-05 21:49:12 +03:00
Alex Turchyn 7c5c8779ec fix form translations 2023-09-05 21:39:04 +03:00
Alex Turchyn 37ed110ad4 better submitter select icon 2023-09-05 20:50:25 +03:00
Alex Turchyn 2ad5f8a316 handle unsupported file types 2023-09-05 20:40:52 +03:00
Alex Turchyn e7594d81b6 fix signature step next button disabled 2023-09-05 01:35:48 +03:00
Alex Turchyn e7c5f14860 fix webhook serializer 2023-09-05 01:09:46 +03:00
Alex Turchyn c38830dd1c add phone type placeholder 2023-09-05 01:00:21 +03:00
Alex Turchyn 2cbf429293 better if readability 2023-09-04 19:06:16 +03:00
Alex Turchyn 517c292c17 add submitter name email variable 2023-09-04 10:08:32 +03:00
Alex Turchyn 14bd039a88 fix email template variable 2023-09-04 02:12:03 +03:00
Alex Turchyn 8c8c34cb75 add server switch 2023-09-04 01:39:16 +03:00
Alex Turchyn 0cc53066eb refactor 2023-09-03 20:44:54 +03:00
Alex Turchyn 3d13d28e05 add czech form translations 2023-09-03 19:07:18 +03:00
Alex Turchyn a80e20234f add name and phone to submitter details 2023-09-03 19:07:18 +03:00
Alex Turchyn b1b46cf998 use proxy urls for webhook attachments 2023-09-01 19:08:35 +03:00
Alex Turchyn 52ef0ae929 make pdf links clickable 2023-09-01 10:08:27 +03:00
Alex Turchyn c4daf8ed49 fix pdf signature validation and certs 2023-08-31 02:10:10 +03:00
Alex Turchyn c098e61947 fix redirect 2023-08-31 02:10:10 +03:00
Alex Turchyn ceb052b158 start translating the form 2023-08-31 02:10:10 +03:00
Alex Turchyn 61b79d5b24 fix submissions api 2023-08-31 02:10:10 +03:00
Alex Turchyn 5a9cffc6fc fix subdomain urls 2023-08-31 02:10:10 +03:00
Alex Turchyn 08447ec4cf fix builder field button bg color 2023-08-31 02:10:10 +03:00
Alex Turchyn d3a5f36555 add ability to use own signing cert 2023-08-31 02:10:09 +03:00
Alex Turchyn c9f39e7b1a fix sign up redirect 2023-08-27 23:34:00 +03:00
Alex Turchyn 1060aa84ba adjust placeholder 2023-08-27 23:33:55 +03:00
Alex Turchyn b6f68ddde3 remove redundant validation 2023-08-27 16:08:25 +03:00
Alex Turchyn 12dd96e34c bring back sign up path 2023-08-26 01:24:14 +03:00
Alex Turchyn 52ba415429 use active job test queue adapter in rspec 2023-08-26 01:20:43 +03:00
Alex Turchyn 601c984f3f do not display registration routes for signed up users 2023-08-25 23:54:55 +03:00
Alex Turchyn 9d90304d9a fix builder width handler 2023-08-25 23:44:14 +03:00
Alex Turchyn 788493c0a6 support UTF-8 text via ttf 2023-08-25 00:28:02 +03:00
Alex Turchyn e1342860c4 add redirect handler 2023-08-24 00:50:57 +03:00
127 changed files with 2553 additions and 674 deletions

No files matched your search

+3
View File
@@ -26,6 +26,9 @@ Style/Documentation:
Lint/MissingSuper: Lint/MissingSuper:
Enabled: false Enabled: false
Metrics/ParameterLists:
Max: 10
Metrics/MethodLength: Metrics/MethodLength:
Max: 25 Max: 25
Exclude: Exclude:
+1 -1
View File
@@ -31,7 +31,7 @@ ENV BUNDLE_WITHOUT="development:test"
WORKDIR /app WORKDIR /app
RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler vips-heif libc6-compat ttf-freefont RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler vips-heif libc6-compat ttf-freefont ttf-liberation && cp /usr/share/fonts/liberation/LiberationSans-Regular.ttf /usr/share/fonts/liberation/LiberationSans-Bold.ttf / && apk del ttf-liberation
COPY ./Gemfile ./Gemfile.lock ./ COPY ./Gemfile ./Gemfile.lock ./
+1
View File
@@ -20,6 +20,7 @@ gem 'oj'
gem 'omniauth-google-oauth2' gem 'omniauth-google-oauth2'
gem 'omniauth-rails_csrf_protection' gem 'omniauth-rails_csrf_protection'
gem 'pagy' gem 'pagy'
gem 'pdf-reader'
gem 'pg', require: false gem 'pg', require: false
gem 'premailer-rails' gem 'premailer-rails'
gem 'puma' gem 'puma'
+12
View File
@@ -1,6 +1,7 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
Ascii85 (1.1.0)
actioncable (7.0.7) actioncable (7.0.7)
actionpack (= 7.0.7) actionpack (= 7.0.7)
activesupport (= 7.0.7) activesupport (= 7.0.7)
@@ -68,6 +69,7 @@ GEM
tzinfo (~> 2.0) tzinfo (~> 2.0)
addressable (2.8.5) addressable (2.8.5)
public_suffix (>= 2.0.2, < 6.0) public_suffix (>= 2.0.2, < 6.0)
afm (0.2.2)
annotate (3.2.0) annotate (3.2.0)
activerecord (>= 3.2, < 8.0) activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0) rake (>= 10.4, < 14.0)
@@ -233,6 +235,7 @@ GEM
os (>= 0.9, < 2.0) os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a) signet (>= 0.16, < 2.a)
hashdiff (1.0.1) hashdiff (1.0.1)
hashery (2.1.2)
hashie (5.0.0) hashie (5.0.0)
hexapdf (0.33.0) hexapdf (0.33.0)
cmdparse (~> 3.0, >= 3.0.3) cmdparse (~> 3.0, >= 3.0.3)
@@ -335,6 +338,12 @@ GEM
parser (3.2.2.3) parser (3.2.2.3)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
pdf-reader (2.11.0)
Ascii85 (~> 1.0)
afm (~> 0.2.1)
hashery (~> 2.0)
ruby-rc4
ttfunk
pg (1.5.3) pg (1.5.3)
premailer (1.21.0) premailer (1.21.0)
addressable addressable
@@ -461,6 +470,7 @@ GEM
rubocop-capybara (~> 2.17) rubocop-capybara (~> 2.17)
rubocop-factory_bot (~> 2.22) rubocop-factory_bot (~> 2.22)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
ruby-rc4 (0.1.5)
ruby-vips (2.1.4) ruby-vips (2.1.4)
ffi (~> 1.12) ffi (~> 1.12)
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
@@ -502,6 +512,7 @@ GEM
thor (1.2.2) thor (1.2.2)
timeout (0.4.0) timeout (0.4.0)
trailblazer-option (0.1.2) trailblazer-option (0.1.2)
ttfunk (1.7.0)
turbo-rails (1.4.0) turbo-rails (1.4.0)
actionpack (>= 6.0.0) actionpack (>= 6.0.0)
activejob (>= 6.0.0) activejob (>= 6.0.0)
@@ -564,6 +575,7 @@ DEPENDENCIES
omniauth-google-oauth2 omniauth-google-oauth2
omniauth-rails_csrf_protection omniauth-rails_csrf_protection
pagy pagy
pdf-reader
pg pg
premailer-rails premailer-rails
pry-rails pry-rails
+2
View File
@@ -52,6 +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)| | [<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**| |**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) | [<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;/) | |
#### Docker #### Docker
+16 -20
View File
@@ -9,41 +9,36 @@ module Api
template = current_account.templates.find(params[:template_id]) template = current_account.templates.find(params[:template_id])
submissions = submissions =
if params[:emails].present? if (emails = (params[:emails] || params[:email]).presence)
Submissions.create_from_emails(template:, Submissions.create_from_emails(template:,
user: current_user, user: current_user,
source: :api, source: :api,
send_email: params[:send_email] != 'false', mark_as_sent: params[:send_email] != 'false',
emails: params[:emails] || params[:email]) emails:)
else else
submissions_attrs = normalize_submissions_params!(submissions_params[:submission], template) submissions_attrs = normalize_submissions_params!(submissions_params[:submission], template)
Submissions.create_from_submitters(template:, Submissions.create_from_submitters(
user: current_user, template:,
source: :api, user: current_user,
send_email: params[:send_email] != 'false', source: :api,
submissions_attrs:) mark_as_sent: params[:send_email] != 'false',
submitters_order: params[:submitters_order] || 'preserved',
submissions_attrs:
)
end end
submitters = submissions.flat_map(&:submitters) Submissions.send_signature_requests(submissions, send_email: params[:send_email] != 'false')
send_invitation_emails(submitters) if params[:send_email] != 'false' render json: submissions.flat_map(&:submitters)
render json: submitters
rescue UnknownFieldName, UnknownSubmitterName => e rescue UnknownFieldName, UnknownSubmitterName => e
render json: { error: e.message }, status: :unprocessable_entity render json: { error: e.message }, status: :unprocessable_entity
end end
private private
def send_invitation_emails(submitters)
submitters.each do |submitter|
SubmitterMailer.invitation_email(submitter, message: params[:message]).deliver_later!
end
end
def submissions_params def submissions_params
params.permit(submission: [{ submitters: [[:uuid, :name, :email, { values: {} }]] }]) params.permit(submission: [{ submitters: [[:uuid, :name, :email, :role, :phone, { values: {} }]] }])
end end
def normalize_submissions_params!(submissions_params, template) def normalize_submissions_params!(submissions_params, template)
@@ -53,7 +48,8 @@ module Api
submitter[:values] = submitter[:values] =
normalize_submitter_values(template, normalize_submitter_values(template,
submitter[:values], submitter[:name] || template.submitters[index]['name']) submitter[:values],
submitter[:role] || template.submitters[index]['name'])
end end
end end
@@ -0,0 +1,17 @@
# frozen_string_literal: true
module Api
class SubmitterEmailClicksController < ApiBaseController
skip_before_action :authenticate_user!
def create
submitter = Submitter.find_by!(slug: params[:submitter_slug])
if params[:t] == SubmissionEvents.build_tracking_param(submitter, 'click_email')
SubmissionEvents.create_with_tracking_data(submitter, 'click_email', request)
end
render json: {}
end
end
end
@@ -0,0 +1,15 @@
# frozen_string_literal: true
module Api
class SubmitterFormViewsController < ApiBaseController
skip_before_action :authenticate_user!
def create
submitter = Submitter.find_by!(slug: params[:submitter_slug])
SubmissionEvents.create_with_tracking_data(submitter, 'view_form', request)
render json: {}
end
end
end
@@ -7,12 +7,7 @@ module Api
@template = current_account.templates.find(params[:template_id]) @template = current_account.templates.find(params[:template_id])
documents = documents = Templates::CreateAttachments.call(@template, params)
find_or_create_blobs.map do |blob|
document = @template.documents.create!(blob:)
Templates::ProcessDocument.call(document)
end
schema = documents.map do |doc| schema = documents.map do |doc|
{ attachment_uuid: doc.uuid, name: doc.filename.base } { attachment_uuid: doc.uuid, name: doc.filename.base }
@@ -27,19 +22,5 @@ module Api
) )
} }
end end
private
def find_or_create_blobs
blobs = params[:blobs]&.map do |attrs|
ActiveStorage::Blob.find_signed(attrs[:signed_id])
end
blobs || params[:files].map do |file|
ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename,
content_type: file.content_type)
end
end
end end
end end
@@ -1,11 +1,17 @@
# frozen_string_literal: true # frozen_string_literal: true
class ConsoleRedirectController < ApplicationController class ConsoleRedirectController < ApplicationController
skip_before_action :authenticate_user!
def index def index
return redirect_to(new_user_session_path({ redir: params[:redir] }.compact)) if current_user.blank?
auth = JsonWebToken.encode(uuid: current_user.uuid, auth = JsonWebToken.encode(uuid: current_user.uuid,
scope: :console, scope: :console,
exp: 1.minute.from_now.to_i) exp: 1.minute.from_now.to_i)
redirect_to("#{Docuseal::CONSOLE_URL}?#{{ auth: }.to_query}", allow_other_host: true) path = Addressable::URI.parse(params[:redir]).path if params[:redir].to_s.starts_with?(Docuseal::CONSOLE_URL)
redirect_to("#{Docuseal::CONSOLE_URL}#{path}?#{{ auth: }.to_query}", allow_other_host: true)
end end
end end
+2 -2
View File
@@ -6,7 +6,7 @@ class EmailSettingsController < ApplicationController
def index; end def index; end
def create def create
if @encrypted_config.update(storage_configs) if @encrypted_config.update(email_configs)
SettingsMailer.smtp_successful_setup(@encrypted_config.value['from_email']).deliver_now! SettingsMailer.smtp_successful_setup(@encrypted_config.value['from_email']).deliver_now!
redirect_to settings_email_index_path, notice: 'Changes have been saved' redirect_to settings_email_index_path, notice: 'Changes have been saved'
@@ -26,7 +26,7 @@ class EmailSettingsController < ApplicationController
EncryptedConfig.find_or_initialize_by(account: current_account, key: EncryptedConfig::EMAIL_SMTP_KEY) EncryptedConfig.find_or_initialize_by(account: current_account, key: EncryptedConfig::EMAIL_SMTP_KEY)
end end
def storage_configs def email_configs
params.require(:encrypted_config).permit(value: {}).tap do |e| params.require(:encrypted_config).permit(value: {}).tap do |e|
e[:value].compact_blank! e[:value].compact_blank!
end end
+102 -10
View File
@@ -1,19 +1,111 @@
# frozen_string_literal: true # frozen_string_literal: true
class EsignSettingsController < ApplicationController class EsignSettingsController < ApplicationController
DEFAULT_CERT_NAME = 'DocuSeal Self-Host Autogenerated'
CertFormRecord = Struct.new(:name, :file, :password, keyword_init: true) do
include ActiveModel::Validations
def to_key
[]
end
end
def show
cert_data = EncryptedConfig.find_by(account: current_account,
key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || {}
default_pkcs = GenerateCertificate.load_pkcs(cert_data) if cert_data['cert'].present?
custom_pkcs_list = (cert_data['custom'] || []).map do |e|
{ 'pkcs' => OpenSSL::PKCS12.new(Base64.urlsafe_decode64(e['data']), e['password'].to_s),
'name' => e['name'],
'status' => e['status'] }
end
@pkcs_list = [
if default_pkcs
{
'pkcs' => default_pkcs,
'name' => DEFAULT_CERT_NAME,
'status' => custom_pkcs_list.any? { |e| e['status'] == 'default' } ? 'validate' : 'default'
}
end,
*custom_pkcs_list
].compact.reverse
end
def new
@cert_record = CertFormRecord.new
end
def create def create
pdfs = @cert_record = CertFormRecord.new(**cert_params)
params[:files].map do |file|
HexaPDF::Document.new(io: file.open)
end
certs = Accounts.load_signing_certs(current_account) cert_configs = EncryptedConfig.find_or_initialize_by(account: current_account,
key: EncryptedConfig::ESIGN_CERTS_KEY)
trusted_certs = [certs[:cert], certs[:sub_ca], certs[:root_ca]] if (cert_configs.value && cert_configs.value['custom']&.any? { |e| e['name'] == @cert_record.name }) ||
@cert_record.name == DEFAULT_CERT_NAME
render turbo_stream: turbo_stream.replace('result', partial: 'result', @cert_record.errors.add(:name, 'already exists')
locals: { pdfs:, files: params[:files], trusted_certs: })
rescue HexaPDF::MalformedPDFError return render turbo_stream: turbo_stream.replace(:modal, template: 'esign_settings/new'),
render turbo_stream: turbo_stream.replace('result', html: helpers.tag.div('Invalid PDF', id: 'result')) status: :unprocessable_entity
end
save_new_cert!(cert_configs, @cert_record)
redirect_to settings_esign_path, notice: 'Certificate has been successfully added!'
rescue OpenSSL::PKCS12::PKCS12Error
@cert_record.errors.add(:password, "is invalid. Make sure you're uploading a valid .p12 file")
render turbo_stream: turbo_stream.replace(:modal, template: 'esign_settings/new'), status: :unprocessable_entity
end
def update
cert_configs = EncryptedConfig.find_by(account: current_account, key: EncryptedConfig::ESIGN_CERTS_KEY)
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['status'] = 'default' if custom_cert_data
cert_configs.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)
cert_configs.value['custom'].reject! { |e| e['name'] == params[:name] }
cert_configs.save!
redirect_to settings_esign_path, notice: 'Certificate has been removed'
end
private
def save_new_cert!(cert_configs, cert_record)
pkcs = OpenSSL::PKCS12.new(cert_record.file.read, cert_record.password)
cert_configs.value ||= {}
cert_configs.value['custom'] ||= []
cert_configs.value['custom'].each { |e| e['status'] = 'validate' }
cert_configs.value['custom'] << {
data: Base64.urlsafe_encode64(pkcs.to_der),
password: cert_record.password,
name: cert_record.name,
status: 'default'
}
cert_configs.save!
end
def cert_params
return {} if params[:esign_settings_controller_cert_form_record].blank?
params.require(:esign_settings_controller_cert_form_record).permit(:name, :file, :password)
end end
end end
@@ -2,6 +2,7 @@
class RegistrationsController < Devise::RegistrationsController class RegistrationsController < Devise::RegistrationsController
prepend_before_action :require_no_authentication, only: [:show] prepend_before_action :require_no_authentication, only: [:show]
prepend_before_action :maybe_redirect_if_signed_in, only: [:show]
def show; end def show; end
@@ -13,6 +14,23 @@ class RegistrationsController < Devise::RegistrationsController
private private
def after_sign_up_path_for(...)
if params[:redir].present?
return console_redirect_index_path(redir: params[:redir]) if params[:redir].starts_with?(Docuseal::CONSOLE_URL)
return params[:redir]
end
super
end
def maybe_redirect_if_signed_in
return unless signed_in?
return if params[:redir].blank?
redirect_to after_sign_up_path_for(current_user), allow_other_host: true
end
def require_no_authentication def require_no_authentication
super super
+10
View File
@@ -12,6 +12,16 @@ class SessionsController < Devise::SessionsController
private private
def after_sign_in_path_for(...)
if params[:redir].present?
return console_redirect_index_path(redir: params[:redir]) if params[:redir].starts_with?(Docuseal::CONSOLE_URL)
return params[:redir]
end
super
end
def require_no_authentication def require_no_authentication
super super
@@ -0,0 +1,5 @@
# frozen_string_literal: true
class SmsSettingsController < ApplicationController
def index; end
end
+7 -13
View File
@@ -6,7 +6,7 @@ class SubmissionsController < ApplicationController
def show def show
@submission = @submission =
Submission.joins(:template).where(template: { account_id: current_account.id }) Submission.joins(:template).where(template: { account_id: current_account.id })
.preload(template: { documents_attachments: { preview_images_attachments: :blob } }) .preload(:template, template_schema_documents: [:blob, { preview_images_attachments: :blob }])
.find(params[:id]) .find(params[:id])
render :show, layout: 'plain' render :show, layout: 'plain'
@@ -20,26 +20,20 @@ class SubmissionsController < ApplicationController
Submissions.create_from_emails(template: @template, Submissions.create_from_emails(template: @template,
user: current_user, user: current_user,
source: :invite, source: :invite,
send_email: params[:send_email] == '1', mark_as_sent: params[:send_email] == '1',
emails: params[:emails]) emails: params[:emails])
else else
Submissions.create_from_submitters(template: @template, Submissions.create_from_submitters(template: @template,
user: current_user, user: current_user,
source: :invite, source: :invite,
send_email: params[:send_email] == '1', submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random',
mark_as_sent: params[:send_email] == '1',
submissions_attrs: submissions_params[:submission].to_h.values) submissions_attrs: submissions_params[:submission].to_h.values)
end end
submitters = submissions.flat_map(&:submitters) Submissions.send_signature_requests(submissions, params)
if params[:send_email] == '1' redirect_to template_path(@template), notice: 'New recipients have been added'
submitters.each do |submitter|
SubmitterMailer.invitation_email(submitter, message: params[:message]).deliver_later!
end
end
redirect_to template_path(@template),
notice: "#{submitters.size} #{'recipient'.pluralize(submitters.size)} added"
end end
def destroy def destroy
@@ -54,7 +48,7 @@ class SubmissionsController < ApplicationController
private private
def submissions_params def submissions_params
params.permit(submission: { submitters: [%i[uuid email]] }) params.permit(submission: { submitters: [%i[uuid email phone name]] })
end end
def load_template def load_template
@@ -15,9 +15,13 @@ class SubmissionsDebugController < ApplicationController
render 'submit_form/show' render 'submit_form/show'
end end
f.pdf do f.pdf do
Submissions::GenerateResultAttachments.call(@submitter) if params[:audit]
Submissions::GenerateAuditTrail.call(@submitter.submission)
else
Submissions::GenerateResultAttachments.call(@submitter)
end
send_data ActiveStorage::Attachment.where(name: :documents).last.download, send_data ActiveStorage::Attachment.where(name: params[:audit] ? :audit_trail : :documents).last.download,
filename: 'debug.pdf', filename: 'debug.pdf',
disposition: 'inline', disposition: 'inline',
type: 'application/pdf' type: 'application/pdf'
@@ -8,8 +8,12 @@ class SubmissionsDownloadController < ApplicationController
Submissions::EnsureResultGenerated.call(submitter) Submissions::EnsureResultGenerated.call(submitter)
last_submitter = submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last
Submissions::EnsureResultGenerated.call(last_submitter)
urls = urls =
Submitters.select_attachments_for_download(submitter).map do |attachment| Submitters.select_attachments_for_download(last_submitter).map do |attachment|
helpers.rails_blob_url(attachment) helpers.rails_blob_url(attachment)
end end
+5 -1
View File
@@ -7,7 +7,9 @@ class SubmitFormController < ApplicationController
def show def show
@submitter = @submitter =
Submitter.preload(submission: { template: { documents_attachments: { preview_images_attachments: :blob } } }) Submitter.preload(submission: [
:template, { template_schema_documents: [:blob, { preview_images_attachments: :blob }] }
])
.find_by!(slug: params[:slug]) .find_by!(slug: params[:slug])
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at? return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
@@ -21,6 +23,8 @@ class SubmitFormController < ApplicationController
Submitters::SubmitValues.call(submitter, params, request) Submitters::SubmitValues.call(submitter, params, request)
head :ok head :ok
rescue Submitters::SubmitValues::ValidationError => e
render json: { error: e.message }, status: :unprocessable_entity
end end
def completed def completed
@@ -0,0 +1,18 @@
# frozen_string_literal: true
class SubmittersSendEmailController < ApplicationController
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!
SubmissionEvent.create!(submitter:, event_type: 'send_email')
submitter.sent_at ||= Time.current
submitter.save!
redirect_back(fallback_location: submission_path(submitter.submission), notice: 'Email has been sent')
end
end
@@ -0,0 +1,18 @@
# frozen_string_literal: true
class TemplatesUploadsController < ApplicationController
def create
template = current_account.templates.new(author: current_user)
template.name = File.basename(params[:files].first.original_filename, '.*')
template.save!
documents = Templates::CreateAttachments.call(template, params)
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
template.update!(schema:)
redirect_to edit_template_path(template)
end
end
@@ -0,0 +1,32 @@
# frozen_string_literal: true
class VerifyPdfSignatureController < ApplicationController
def create
pdfs =
params[:files].map do |file|
HexaPDF::Document.new(io: file.open)
end
cert_data = if Docuseal.multitenant?
Docuseal::CERTS
else
EncryptedConfig.find_by(account: current_account, key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || {}
end
default_pkcs = GenerateCertificate.load_pkcs(cert_data)
custom_certs = cert_data.fetch('custom', []).map do |e|
OpenSSL::PKCS12.new(Base64.urlsafe_decode64(e['data']), e['password'].to_s)
end
trusted_certs = [default_pkcs.certificate,
*default_pkcs.ca_certs,
*custom_certs.map(&:certificate),
*custom_certs.flat_map(&:ca_certs).compact]
render turbo_stream: turbo_stream.replace('result', partial: 'result',
locals: { pdfs:, files: params[:files], trusted_certs: })
rescue HexaPDF::MalformedPDFError
render turbo_stream: turbo_stream.replace('result', html: helpers.tag.div('Invalid PDF', id: 'result'))
end
end
+3
View File
@@ -74,6 +74,9 @@ window.customElements.define('template-builder', class extends HTMLElement {
this.app = createApp(TemplateBuilder, { this.app = createApp(TemplateBuilder, {
template: reactive(JSON.parse(this.dataset.template)), template: reactive(JSON.parse(this.dataset.template)),
backgroundColor: '#faf7f5',
withPhone: this.dataset.withPhone === 'true',
acceptFileTypes: this.dataset.acceptFileTypes,
isDirectUpload: this.dataset.isDirectUpload === 'true' isDirectUpload: this.dataset.isDirectUpload === 'true'
}) })
+5 -1
View File
@@ -38,7 +38,11 @@ export default actionable(targetable(class extends HTMLElement {
this.uploadFiles(this.input.files) this.uploadFiles(this.input.files)
} }
toggleLoading = () => { toggleLoading = (e) => {
if (e && e.target && !e.target.contains(this)) {
return
}
this.loading.classList.toggle('hidden') this.loading.classList.toggle('hidden')
this.icon.classList.toggle('hidden') this.icon.classList.toggle('hidden')
this.classList.toggle('opacity-50') this.classList.toggle('opacity-50')
+2 -2
View File
@@ -9,12 +9,12 @@ window.customElements.define('submission-form', class extends HTMLElement {
this.appElem = document.createElement('div') this.appElem = document.createElement('div')
this.app = createApp(Form, { this.app = createApp(Form, {
submitterSlug: this.dataset.submitterSlug, submitter: JSON.parse(this.dataset.submitter),
submitterUuid: this.dataset.submitterUuid,
authenticityToken: this.dataset.authenticityToken, authenticityToken: this.dataset.authenticityToken,
canSendEmail: this.dataset.canSendEmail === 'true', canSendEmail: this.dataset.canSendEmail === 'true',
isDirectUpload: this.dataset.isDirectUpload === 'true', isDirectUpload: this.dataset.isDirectUpload === 'true',
isDemo: this.dataset.isDemo === 'true', isDemo: this.dataset.isDemo === 'true',
attribution: this.dataset.attribution !== 'false',
withConfetti: true, withConfetti: true,
values: reactive(JSON.parse(this.dataset.values)), values: reactive(JSON.parse(this.dataset.values)),
attachments: reactive(JSON.parse(this.dataset.attachments)), attachments: reactive(JSON.parse(this.dataset.attachments)),
+5 -3
View File
@@ -113,7 +113,7 @@
</template> </template>
<script> <script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3 } from '@tabler/icons-vue' import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck } from '@tabler/icons-vue'
export default { export default {
name: 'FieldArea', name: 'FieldArea',
@@ -179,7 +179,8 @@ export default {
select: 'Select', select: 'Select',
checkbox: 'Checkbox', checkbox: 'Checkbox',
radio: 'Radio', radio: 'Radio',
multiple: 'Multiple Select' multiple: 'Multiple Select',
phone: 'Phone'
} }
}, },
fieldIcons () { fieldIcons () {
@@ -193,7 +194,8 @@ export default {
checkbox: IconCheckbox, checkbox: IconCheckbox,
radio: IconCircleDot, radio: IconCircleDot,
cells: IconColumns3, cells: IconColumns3,
multiple: IconChecks multiple: IconChecks,
phone: IconPhoneCheck
} }
}, },
image () { image () {
+18 -10
View File
@@ -7,13 +7,13 @@
:height="30" :height="30"
/> />
<span> <span>
Form has been completed! {{ t('form_has_been_completed') }}
</span> </span>
</p> </p>
<div class="space-y-3 mt-5"> <div class="space-y-3 mt-5">
<button <button
v-if="canSendEmail && !isDemo" v-if="canSendEmail && !isDemo"
class="white-button flex items-center space-x-1 w-full" class="white-button !h-auto flex items-center space-x-1 w-full"
:disabled="isSendingCopy" :disabled="isSendingCopy"
@click.prevent="sendCopyToEmail" @click.prevent="sendCopyToEmail"
> >
@@ -23,7 +23,7 @@
/> />
<IconMail v-else /> <IconMail v-else />
<span> <span>
Send copy via email {{ t('send_copy_via_email') }}
</span> </span>
</button> </button>
<button <button
@@ -37,7 +37,7 @@
/> />
<IconDownload v-else /> <IconDownload v-else />
<span> <span>
Download {{ t('download') }}
</span> </span>
</button> </button>
<a <a
@@ -58,17 +58,20 @@
> >
<IconLogin /> <IconLogin />
<span> <span>
Create a Free Account {{ t('create_a_free_account') }}
</span> </span>
</a> </a>
</div> </div>
<div class="text-center mt-4"> <div
Signed with v-if="attribution"
class="text-center mt-4"
>
{{ t('signed_with') }}
<a <a
href="https://www.docuseal.co" href="https://www.docuseal.co"
target="_blank" target="_blank"
class="underline" class="underline"
>DocuSeal</a> - open source documents software >DocuSeal</a> - {{ t('open_source_documents_software') }}
</div> </div>
</div> </div>
</template> </template>
@@ -86,7 +89,7 @@ export default {
IconLogin, IconLogin,
IconDownload IconDownload
}, },
inject: ['baseUrl'], inject: ['baseUrl', 't'],
props: { props: {
submitterSlug: { submitterSlug: {
type: String, type: String,
@@ -97,6 +100,11 @@ export default {
required: false, required: false,
default: false default: false
}, },
attribution: {
type: Boolean,
required: false,
default: true
},
withConfetti: { withConfetti: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -132,7 +140,7 @@ export default {
fetch(this.baseUrl + `/send_submission_email.json?submitter_slug=${this.submitterSlug}`, { fetch(this.baseUrl + `/send_submission_email.json?submitter_slug=${this.submitterSlug}`, {
method: 'POST' method: 'POST'
}).then(() => { }).then(() => {
alert('Email has been sent') alert(this.t('email_has_been_sent'))
}).finally(() => { }).finally(() => {
this.isSendingCopy = false this.isSendingCopy = false
}) })
+2 -2
View File
@@ -29,7 +29,7 @@
{{ message }} {{ message }}
</div> </div>
<div class="text-xs"> <div class="text-xs">
<span class="font-medium">Click to upload</span> or drag and drop files <span class="font-medium">{{ t('click_to_upload') }}</span> {{ t('or_drag_and_drop_files') }}
</div> </div>
</div> </div>
</div> </div>
@@ -55,7 +55,7 @@ export default {
IconCloudUpload, IconCloudUpload,
IconInnerShadowTop IconInnerShadowTop
}, },
inject: ['baseUrl'], inject: ['baseUrl', 't'],
props: { props: {
message: { message: {
type: String, type: String,
+124 -36
View File
@@ -13,7 +13,7 @@
class="btn btn-neutral flex text-white absolute rounded-none border-x-0 md:border md:rounded-full bottom-0 w-full md:mb-4 text-base" class="btn btn-neutral flex text-white absolute rounded-none border-x-0 md:border md:rounded-full bottom-0 w-full md:mb-4 text-base"
@click.prevent="isFormVisible = true" @click.prevent="isFormVisible = true"
> >
Submit Form {{ t('submit_form') }}
<IconArrowsDiagonal <IconArrowsDiagonal
class="absolute right-0 mr-4" class="absolute right-0 mr-4"
:width="20" :width="20"
@@ -24,6 +24,7 @@
v-show="isFormVisible" v-show="isFormVisible"
id="form_container" id="form_container"
class="shadow-md bg-base-100 absolute bottom-0 md:bottom-4 w-full border-base-200 border p-4 rounded" class="shadow-md bg-base-100 absolute bottom-0 md:bottom-4 w-full border-base-200 border p-4 rounded"
:style="{ backgroundColor: backgroundColor }"
> >
<button <button
v-if="!isCompleted" v-if="!isCompleted"
@@ -62,7 +63,7 @@
:for="currentField.uuid" :for="currentField.uuid"
class="label text-2xl mb-2" class="label text-2xl mb-2"
>{{ currentField.name }} >{{ currentField.name }}
<template v-if="!currentField.required">(optional)</template> <template v-if="!currentField.required">({{ t('optional') }})</template>
</label> </label>
<div <div
v-else v-else
@@ -74,7 +75,7 @@
v-model="values[currentField.uuid]" v-model="values[currentField.uuid]"
class="base-input !text-2xl w-full" class="base-input !text-2xl w-full"
:required="currentField.required" :required="currentField.required"
:placeholder="`Type here...${currentField.required ? '' : ' (optional)'}`" :placeholder="`${t('type_here')}...${currentField.required ? '' : ` (${t('optional')})`}`"
type="text" type="text"
:name="`values[${currentField.uuid}]`" :name="`values[${currentField.uuid}]`"
@focus="$refs.areas.scrollIntoField(currentField)" @focus="$refs.areas.scrollIntoField(currentField)"
@@ -87,7 +88,7 @@
:for="currentField.uuid" :for="currentField.uuid"
class="label text-2xl mb-2" class="label text-2xl mb-2"
>{{ currentField.name }} >{{ currentField.name }}
<template v-if="!currentField.required">(optional)</template> <template v-if="!currentField.required">({{ t('optional') }})</template>
</label> </label>
<div <div
v-else v-else
@@ -111,7 +112,7 @@
:for="currentField.uuid" :for="currentField.uuid"
class="label text-2xl mb-2" class="label text-2xl mb-2"
>{{ currentField.name }} >{{ currentField.name }}
<template v-if="!currentField.required">(optional)</template> <template v-if="!currentField.required">({{ t('optional') }})</template>
</label> </label>
<div <div
v-else v-else
@@ -129,7 +130,7 @@
value="" value=""
:selected="!values[currentField.uuid]" :selected="!values[currentField.uuid]"
> >
Select your option {{ t('select_your_option') }}
</option> </option>
<option <option
v-for="(option, index) in currentField.options" v-for="(option, index) in currentField.options"
@@ -147,7 +148,7 @@
:for="currentField.uuid" :for="currentField.uuid"
class="label text-2xl mb-2" class="label text-2xl mb-2"
>{{ currentField.name }} >{{ currentField.name }}
<template v-if="!currentField.required">(optional)</template> <template v-if="!currentField.required">({{ t('optional') }})</template>
</label> </label>
<div class="flex w-full"> <div class="flex w-full">
<div class="space-y-3.5 mx-auto"> <div class="space-y-3.5 mx-auto">
@@ -195,7 +196,7 @@
> >
<template v-if="isAnonymousChecboxes"> <template v-if="isAnonymousChecboxes">
<span class="text-xl"> <span class="text-xl">
Complete hightlighted checkboxes and click <span class="font-semibold">{{ stepFields.length === currentStep + 1 ? 'submit' : 'next' }}</span>. {{ t('complete_hightlighted_checkboxes_and_click') }} <span class="font-semibold">{{ stepFields.length === currentStep + 1 ? t('submit') : t('next') }}</span>.
</span> </span>
<input <input
v-for="field in currentStepFields" v-for="field in currentStepFields"
@@ -264,6 +265,16 @@
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
@attached="[attachments.push($event), $refs.areas.scrollIntoField(currentField)]" @attached="[attachments.push($event), $refs.areas.scrollIntoField(currentField)]"
/> />
<PhoneStep
v-else-if="currentField.type === 'phone'"
ref="currentStep"
v-model="values[currentField.uuid]"
:field="currentField"
:default-value="submitter.phone"
:submitter-slug="submitterSlug"
@focus="$refs.areas.scrollIntoField(currentField)"
@submit="submitStep"
/>
</div> </div>
<div class="mt-6 md:mt-8"> <div class="mt-6 md:mt-8">
<button <button
@@ -277,10 +288,10 @@
class="mr-1 animate-spin" class="mr-1 animate-spin"
/> />
<span v-if="stepFields.length === currentStep + 1"> <span v-if="stepFields.length === currentStep + 1">
Submit {{ t('submit') }}
</span> </span>
<span v-else> <span v-else>
Next {{ t('next') }}
</span><span </span><span
v-if="isSubmitting" v-if="isSubmitting"
class="w-6 flex justify-start mr-1" class="w-6 flex justify-start mr-1"
@@ -292,8 +303,9 @@
<FormCompleted <FormCompleted
v-else v-else
:is-demo="isDemo" :is-demo="isDemo"
:attribution="attribution"
:with-confetti="withConfetti" :with-confetti="withConfetti"
:can-send-email="canSendEmail" :can-send-email="canSendEmail && !!submitter.email"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
/> />
<div class="flex justify-center"> <div class="flex justify-center">
@@ -318,8 +330,10 @@ import ImageStep from './image_step'
import SignatureStep from './signature_step' import SignatureStep from './signature_step'
import AttachmentStep from './attachment_step' import AttachmentStep from './attachment_step'
import MultiSelectStep from './multi_select_step' import MultiSelectStep from './multi_select_step'
import PhoneStep from './phone_step'
import FormCompleted from './completed' import FormCompleted from './completed'
import { IconInnerShadowTop, IconArrowsDiagonal, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue' import { IconInnerShadowTop, IconArrowsDiagonal, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import { t } from './i18n'
export default { export default {
name: 'SubmissionForm', name: 'SubmissionForm',
@@ -331,17 +345,19 @@ export default {
MultiSelectStep, MultiSelectStep,
IconInnerShadowTop, IconInnerShadowTop,
IconArrowsDiagonal, IconArrowsDiagonal,
PhoneStep,
IconArrowsDiagonalMinimize2, IconArrowsDiagonalMinimize2,
FormCompleted FormCompleted
}, },
provide () { provide () {
return { return {
baseUrl: this.baseUrl baseUrl: this.baseUrl,
t: this.t
} }
}, },
props: { props: {
submitterSlug: { submitter: {
type: String, type: Object,
required: true required: true
}, },
canSendEmail: { canSendEmail: {
@@ -349,10 +365,6 @@ export default {
required: false, required: false,
default: false default: false
}, },
submitterUuid: {
type: String,
required: true
},
attachments: { attachments: {
type: Array, type: Array,
required: false, required: false,
@@ -378,6 +390,11 @@ export default {
required: false, required: false,
default: '' default: ''
}, },
backgroundColor: {
type: String,
required: false,
default: ''
},
isDirectUpload: { isDirectUpload: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -388,6 +405,11 @@ export default {
required: false, required: false,
default: false default: false
}, },
attribution: {
type: Boolean,
required: false,
default: true
},
values: { values: {
type: Object, type: Object,
required: false, required: false,
@@ -399,20 +421,28 @@ export default {
isCompleted: false, isCompleted: false,
isFormVisible: true, isFormVisible: true,
currentStep: 0, currentStep: 0,
isSubmitting: false isSubmitting: false,
recalculateButtonDisabledKey: ''
} }
}, },
computed: { computed: {
currentStepFields () { currentStepFields () {
return this.stepFields[this.currentStep] return this.stepFields[this.currentStep]
}, },
submitterSlug () {
return this.submitter.slug
},
isAnonymousChecboxes () { isAnonymousChecboxes () {
return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name) && this.currentStepFields.length > 4 return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name) && this.currentStepFields.length > 4
}, },
isButtonDisabled () { isButtonDisabled () {
return this.isSubmitting || if (this.recalculateButtonDisabledKey) {
return this.isSubmitting ||
(this.currentField.required && ['image', 'file'].includes(this.currentField.type) && !this.values[this.currentField.uuid]?.length) || (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)
} else {
return false
}
}, },
currentField () { currentField () {
return this.currentStepFields[0] return this.currentStepFields[0]
@@ -460,12 +490,58 @@ export default {
scrollbox.parentNode.style.maxHeight = '-webkit-fill-available' scrollbox.parentNode.style.maxHeight = '-webkit-fill-available'
}) })
} }
this.$nextTick(() => {
this.recalculateButtonDisabledKey = Math.random()
this.maybeTrackEmailClick().finally(() => {
this.trackViewForm()
})
})
}, },
methods: { methods: {
t,
maybeTrackEmailClick () {
const queryParams = new URLSearchParams(window.location.search)
if (queryParams.has('t')) {
const t = queryParams.get('t')
queryParams.delete('t')
const newUrl = [window.location.pathname, queryParams.toString()].filter(Boolean).join('?')
window.history.replaceState({}, document.title, newUrl)
return fetch(this.baseUrl + '/api/submitter_email_clicks', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
t,
submitter_slug: this.submitterSlug
})
})
} else {
return Promise.resolve({})
}
},
trackViewForm () {
fetch(this.baseUrl + '/api/submitter_form_views', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
submitter_slug: this.submitterSlug
})
})
},
goToStep (step, scrollToArea = false, clickUpload = false) { goToStep (step, scrollToArea = false, clickUpload = false) {
this.currentStep = this.stepFields.indexOf(step) this.currentStep = this.stepFields.indexOf(step)
this.$nextTick(() => { this.$nextTick(() => {
this.recalculateButtonDisabledKey = Math.random()
if (scrollToArea) { if (scrollToArea) {
this.$refs.areas.scrollIntoField(step[0]) this.$refs.areas.scrollIntoField(step[0])
} }
@@ -490,28 +566,40 @@ export default {
async submitStep () { async submitStep () {
this.isSubmitting = true this.isSubmitting = true
const stepPromise = this.currentField.type === 'signature' const stepPromise = ['signature', 'phone'].includes(this.currentField.type)
? this.$refs.currentStep.submit ? this.$refs.currentStep.submit
: () => Promise.resolve({}) : () => Promise.resolve({})
await stepPromise() stepPromise().then(async () => {
const formData = new FormData(this.$refs.form)
const formData = new FormData(this.$refs.form) if (this.currentStep === this.stepFields.length - 1) {
formData.append('completed', 'true')
if (this.currentStep === this.stepFields.length - 1) {
formData.append('completed', 'true')
}
this.saveStep(formData).then(response => {
const nextStep = this.stepFields[this.currentStep + 1]
if (nextStep) {
this.goToStep(this.stepFields[this.currentStep + 1], true)
} else {
this.isCompleted = true
} }
await this.saveStep(formData).then(async (response) => {
if (response.status === 422) {
const data = await response.json()
alert(data.error || 'Value is invalid')
return Promise.reject(new Error(data.error))
}
const nextStep = this.stepFields[this.currentStep + 1]
if (nextStep) {
this.goToStep(this.stepFields[this.currentStep + 1], true)
} else {
this.isCompleted = true
}
}).catch(error => {
console.error('Error submitting form:', error)
}).finally(() => {
this.isSubmitting = false
})
}).catch(error => { }).catch(error => {
console.error('Error submitting form:', error) console.log(error)
}).finally(() => { }).finally(() => {
this.isSubmitting = false this.isSubmitting = false
}) })
+242
View File
@@ -0,0 +1,242 @@
const en = {
submit_form: 'Submit Form',
type_here: 'Type here',
optional: 'optional',
select_your_option: 'Select your option',
complete_hightlighted_checkboxes_and_click: 'Complete hightlighted checkboxes and click',
submit: 'submit',
next: 'next',
click_to_upload: 'Click to upload',
or_drag_and_drop_files: 'or drag and drop files',
send_copy_via_email: 'Send copy via email',
download: 'Download',
form_has_been_completed: 'Form has been completed!',
create_a_free_account: 'Create a Free Account',
signed_with: 'Signed with',
open_source_documents_software: 'open source documents software',
verified_phone_number: 'Verify Phone Number',
use_international_format: 'Use internatioanl format: +1xxx',
six_digits_code: '6-digit code',
change_phone_number: 'Change phone number',
sending: 'Sending...',
resend_code: 'Re-send code',
verification_code_has_been_resent: 'Verification code has been re-sent via SMS',
email_has_been_sent: 'Email has been sent'
}
const es = {
submit_form: 'Enviar Formulario',
type_here: 'Escribe aquí',
optional: 'opcional',
select_your_option: 'Selecciona tu opción',
complete_hightlighted_checkboxes_and_click: 'Completa las casillas resaltadas y haz clic',
submit: 'enviar',
next: 'siguiente',
click_to_upload: 'Haz clic para cargar',
or_drag_and_drop_files: 'o arrastra y suelta archivos',
send_copy_via_email: 'Enviar copia por correo electrónico',
download: 'Descargar',
form_has_been_completed: '¡El formulario ha sido completado!',
create_a_free_account: 'Crear una Cuenta Gratuita',
signed_with: 'Firmado con',
open_source_documents_software: 'software de documentos de código abierto',
verified_phone_number: 'Verificar número de teléfono',
use_international_format: 'Usar formato internacional: +1xxx',
six_digits_code: 'Código de 6 dígitos',
change_phone_number: 'Cambiar número de teléfono',
sending: 'Enviando...',
resend_code: 'Reenviar código',
verification_code_has_been_resent: 'El código de verificación ha sido reenviado por SMS',
email_has_been_sent: 'El correo electrónico ha sido enviado'
}
const it = {
submit_form: 'Invia Modulo',
type_here: 'Digita qui',
optional: 'opzionale',
select_your_option: 'Seleziona la tua opzione',
complete_hightlighted_checkboxes_and_click: 'Completa le caselle evidenziate e fai clic',
submit: 'invia',
next: 'avanti',
click_to_upload: 'Clicca per caricare',
or_drag_and_drop_files: 'oppure trascina e rilascia i file',
send_copy_via_email: 'Invia copia via email',
download: 'Scarica',
form_has_been_completed: 'Il modulo è stato completato!',
create_a_free_account: 'Crea un Account Gratuito',
signed_with: 'Firmato con',
open_source_documents_software: 'software di documenti open source',
verified_phone_number: 'Verifica numero di telefono',
use_international_format: 'Usa formato internazionale: +1xxx',
six_digits_code: 'Codice a 6 cifre',
change_phone_number: 'Cambia numero di telefono',
sending: 'Invio in corso...',
resend_code: 'Rinvia codice',
verification_code_has_been_resent: 'Il codice di verifica è stato rinviato tramite SMS',
email_has_been_sent: "L'email è stata inviata"
}
const de = {
submit_form: 'Formular absenden',
type_here: 'Hier eingeben',
optional: 'optional',
select_your_option: 'Wähle deine Option',
complete_hightlighted_checkboxes_and_click: 'Markierte Kontrollkästchen ausfüllen und klicken',
submit: 'absenden',
next: 'weiter',
click_to_upload: 'Klicken zum Hochladen',
or_drag_and_drop_files: 'oder Dateien hierher ziehen und ablegen',
send_copy_via_email: 'Kopie per E-Mail senden',
download: 'Herunterladen',
form_has_been_completed: 'Formular wurde ausgefüllt!',
create_a_free_account: 'Kostenloses Konto erstellen',
signed_with: 'Unterschrieben mit',
open_source_documents_software: 'Open-Source-Dokumentensoftware',
verified_phone_number: 'Telefonnummer überprüfen',
use_international_format: 'Internationales Format verwenden: +1xxx',
six_digits_code: '6-stelliger Code',
change_phone_number: 'Telefonnummer ändern',
sending: 'Senden...',
resend_code: 'Code erneut senden',
verification_code_has_been_resent: 'Die Verifizierungscode wurde erneut per SMS gesendet',
email_has_been_sent: 'Die E-Mail wurde gesendet'
}
const fr = {
submit_form: 'Envoyer le Formulaire',
type_here: 'Tapez ici',
optional: 'facultatif',
select_your_option: 'Sélectionnez votre option',
complete_hightlighted_checkboxes_and_click: 'Complétez les cases à cocher en surbrillance et cliquez',
submit: 'envoyer',
next: 'suivant',
click_to_upload: 'Cliquez pour télécharger',
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',
form_has_been_completed: 'Le formulaire a été complété !',
create_a_free_account: 'Créer un Compte Gratuit',
signed_with: 'Signé avec',
open_source_documents_software: 'logiciel de documents open source',
verified_phone_number: 'Vérifier le numéro de téléphone',
use_international_format: 'Utiliser le format international : +1xxx',
six_digits_code: 'Code à 6 chiffres',
change_phone_number: 'Changer le numéro de téléphone',
sending: 'Envoi en cours...',
resend_code: 'Renvoyer le code',
verification_code_has_been_resent: 'Le code de vérification a été renvoyé par SMS',
email_has_been_sent: "L'email a été envoyé"
}
const pl = {
submit_form: 'Wyślij Formularz',
type_here: 'Wpisz tutaj',
optional: 'opcjonalny',
select_your_option: 'Wybierz swoją opcję',
complete_hightlighted_checkboxes_and_click: 'Wypełnij zaznaczone pola wyboru i kliknij',
submit: 'wyślij',
next: 'dalej',
click_to_upload: 'Kliknij, aby przesłać',
or_drag_and_drop_files: 'lub przeciągnij i upuść pliki',
send_copy_via_email: 'Wyślij kopię drogą mailową',
download: 'Pobierz',
form_has_been_completed: 'Formularz został wypełniony!',
create_a_free_account: 'Utwórz darmowe konto',
signed_with: 'Podpisane za pomocą',
open_source_documents_software: 'oprogramowanie do dokumentów open source',
verified_phone_number: 'Zweryfikuj numer telefonu',
use_international_format: 'Użyj międzynarodowego formatu: +1xxx',
six_digits_code: '6-cyfrowy kod',
change_phone_number: 'Zmień numer telefonu',
sending: 'Wysyłanie...',
resend_code: 'Ponownie wyślij kod',
verification_code_has_been_resent: 'Kod weryfikacyjny został ponownie wysłany',
email_has_been_sent: 'E-mail został wysłany'
}
const uk = {
submit_form: 'Надіслати Форму',
type_here: 'Введіть тут',
optional: 'необов’язково',
select_your_option: 'Виберіть варіант',
complete_hightlighted_checkboxes_and_click: 'Заповніть позначені прапорці та натисніть',
submit: 'надіслати',
next: 'далі',
click_to_upload: 'Клацніть, щоб завантажити',
or_drag_and_drop_files: 'або перетягніть файли сюди',
send_copy_via_email: 'Надіслати копію електронною поштою',
download: 'Завантажити',
form_has_been_completed: 'Форму заповнено!',
create_a_free_account: 'Створити безкоштовний обліковий запис',
signed_with: 'Підписано за допомогою',
open_source_documents_software: 'відкритий програмний засіб для документів',
verified_phone_number: 'Підтвердіть номер телефону',
use_international_format: 'Використовуйте міжнародний формат: +1xxx',
six_digits_code: '6-значний код',
change_phone_number: 'Змінити номер телефону',
sending: 'Надсилаю...',
resend_code: 'Повторно відправити код',
verification_code_has_been_resent: 'Код підтвердження був повторно надісланий',
email_has_been_sent: 'Електронний лист був відправлений'
}
const cs = {
submit_form: 'Odeslat formulář',
type_here: 'Zadejte zde',
optional: 'volitelné',
select_your_option: 'Vyberte svou volbu',
complete_hightlighted_checkboxes_and_click: 'Označte zvýrazněné zaškrtávací políčka a klikněte na',
submit: 'odeslat',
next: 'další',
click_to_upload: 'Klikněte pro nahrání',
or_drag_and_drop_files: 'nebo přetáhněte soubory sem',
send_copy_via_email: 'Odeslat kopii e-mailem',
download: 'Stáhnout',
form_has_been_completed: 'Formulář byl dokončen!',
create_a_free_account: 'Vytvořit bezplatný účet',
signed_with: 'Podepsáno pomocí',
open_source_documents_software: 'open source software pro dokumenty',
verified_phone_number: 'Ověřte telefonní číslo',
use_international_format: 'Použijte mezinárodní formát: +1xxx',
six_digits_code: '6-místný kód',
change_phone_number: 'Změnit telefonní číslo',
sending: 'Odesílání...',
resend_code: 'Znovu odeslat kód',
verification_code_has_been_resent: 'Ověřovací kód byl znovu odeslán',
email_has_been_sent: 'E-mail byl odeslán'
}
const pt = {
submit_form: 'Enviar Formulário',
type_here: 'Digite aqui',
optional: 'opcional',
select_your_option: 'Selecione sua opção',
complete_hightlighted_checkboxes_and_click: 'Complete as caixas de seleção destacadas e clique',
submit: 'enviar',
next: 'próximo',
click_to_upload: 'Clique para fazer o upload',
or_drag_and_drop_files: 'ou arraste e solte arquivos',
send_copy_via_email: 'Enviar cópia por e-mail',
download: 'Baixar',
form_has_been_completed: 'O formulário foi concluído!',
create_a_free_account: 'Criar uma Conta Gratuita',
signed_with: 'Assinado com',
open_source_documents_software: 'software de documentos de código aberto',
verified_phone_number: 'Verificar Número de Telefone',
use_international_format: 'Use formato internacional: +1xxx',
six_digits_code: 'Código de 6 dígitos',
change_phone_number: 'Alterar número de telefone',
sending: 'Enviando...',
resend_code: 'Reenviar código',
verification_code_has_been_resent: 'O código de verificação foi reenviado via SMS',
email_has_been_sent: 'Email enviado'
}
const i18n = { en, es, it, de, fr, pl, uk, cs, pt }
const browserLanguage = (navigator.language || navigator.userLanguage || 'en').split('-')[0]
const t = (key) => i18n[browserLanguage]?.[key] || i18n.en[key] || key
export default i18n
export { t }
@@ -0,0 +1,155 @@
<template>
<div>
<label
:for="isCodeSent ? 'one_time_code' : field.uuid"
class="label text-2xl mb-2"
>{{ field.name || t('verified_phone_number') }}
<template v-if="!field.required">({{ t('optional') }})</template>
</label>
<div>
<input
type="hidden"
name="normalize_phone"
value="true"
>
<div v-if="isCodeSent">
<input
id="one_time_code"
class="base-input !text-2xl w-full text-center"
name="one_time_code"
type="text"
autocomplete="one-time-code"
:placeholder="t('six_digits_code')"
required
maxlength="6"
autofocus
inputmode="decimal"
@input="onInputCode"
>
<div class="flex justify-between mt-2 -mb-2 md:-mb-4">
<a
href="#"
class="link"
@click.prevent="isCodeSent = false"
>
{{ t('change_phone_number') }}
</a>
<a
href="#"
class="link"
@click.prevent="resendCode"
>
{{ isResendLoading ? t('sending') : t('resend_code') }}
</a>
</div>
</div>
<input
v-show="!isCodeSent"
:id="field.uuid"
ref="phone"
:value="modelValue || defaultValue"
class="base-input !text-2xl w-full"
autocomplete="tel"
pattern="^\+[0-9\s\-]+$"
:oninvalid="`this.value ? this.setCustomValidity('${t('use_international_format')}...') : ''`"
oninput="this.setCustomValidity('')"
type="tel"
inputmode="tel"
:required="field.required"
placeholder="+1 234 567-8900"
:name="`values[${field.uuid}]`"
@input="$emit('update:model-value', $event.target.value)"
@focus="$emit('focus')"
>
</div>
</div>
</template>
<script>
function throttle (func, delay) {
let lastCallTime = 0
return function (...args) {
const now = Date.now()
if (now - lastCallTime >= delay) {
func.apply(this, args)
lastCallTime = now
}
}
}
export default {
name: 'PhoneStep',
inject: ['t', 'baseUrl'],
props: {
field: {
type: Object,
required: true
},
submitterSlug: {
type: String,
required: true
},
modelValue: {
type: String,
required: false,
default: ''
},
defaultValue: {
type: String,
required: false,
default: ''
}
},
emits: ['update:model-value', 'focus', 'submit'],
data () {
return {
isCodeSent: false,
isResendLoading: false
}
},
methods: {
emitSubmit: throttle(function (e) {
this.$emit('submit')
}, 1000),
onInputCode (e) {
if (e.target.value.length === 6) {
this.emitSubmit()
}
},
resendCode () {
this.isResendLoading = true
this.sendVerificationCode().finally(() => {
alert(this.t('verification_code_has_been_resent'))
this.isResendLoading = false
})
},
sendVerificationCode () {
return fetch(this.baseUrl + '/api/send_phone_verification_code', {
method: 'POST',
body: JSON.stringify({
submitter_slug: this.submitterSlug,
phone: this.$refs.phone.value
}),
headers: { 'Content-Type': 'application/json' }
})
},
async submit () {
if (!this.isCodeSent) {
this.sendVerificationCode()
this.$emit('update:model-value', this.$refs.phone.value)
this.isCodeSent = true
return Promise.reject(new Error('verify with code'))
} else {
return Promise.resolve({})
}
}
}
}
</script>
+25 -2
View File
@@ -75,6 +75,7 @@
:with-arrows="template.schema.length > 1" :with-arrows="template.schema.length > 1"
:item="item" :item="item"
:document="sortedDocuments[index]" :document="sortedDocuments[index]"
:accept-file-types="acceptFileTypes"
:template="template" :template="template"
:is-direct-upload="isDirectUpload" :is-direct-upload="isDirectUpload"
@scroll-to="scrollIntoDocument(item)" @scroll-to="scrollIntoDocument(item)"
@@ -84,9 +85,13 @@
@down="moveDocument(item, 1)" @down="moveDocument(item, 1)"
@change="save" @change="save"
/> />
<div class="sticky bottom-0 bg-base-100 py-2"> <div
class="sticky bottom-0 py-2"
:class="{ 'bg-base-100': withStickySubmitters }"
>
<Upload <Upload
v-if="sortedDocuments.length" v-if="sortedDocuments.length"
:accept-file-types="acceptFileTypes"
:template-id="template.id" :template-id="template.id"
:is-direct-upload="isDirectUpload" :is-direct-upload="isDirectUpload"
@success="updateFromUpload" @success="updateFromUpload"
@@ -101,6 +106,7 @@
<Dropzone <Dropzone
v-if="!sortedDocuments.length" v-if="!sortedDocuments.length"
:template-id="template.id" :template-id="template.id"
:accept-file-types="acceptFileTypes"
:is-direct-upload="isDirectUpload" :is-direct-upload="isDirectUpload"
@success="updateFromUpload" @success="updateFromUpload"
/> />
@@ -233,6 +239,8 @@ export default {
template: this.template, template: this.template,
save: this.save, save: this.save,
baseFetch: this.baseFetch, baseFetch: this.baseFetch,
backgroundColor: this.backgroundColor,
withPhone: this.withPhone,
selectedAreaRef: computed(() => this.selectedAreaRef) selectedAreaRef: computed(() => this.selectedAreaRef)
} }
}, },
@@ -246,6 +254,16 @@ export default {
required: false, required: false,
default: false default: false
}, },
backgroundColor: {
type: String,
required: false,
default: ''
},
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
},
baseUrl: { baseUrl: {
type: String, type: String,
required: false, required: false,
@@ -261,6 +279,11 @@ export default {
required: false, required: false,
default: true default: true
}, },
withPhone: {
type: Boolean,
required: false,
default: false
},
fetchOptions: { fetchOptions: {
type: Object, type: Object,
required: false, required: false,
@@ -327,7 +350,7 @@ export default {
onWindowResize (e) { onWindowResize (e) {
const breakpointLg = 1024 const breakpointLg = 1024
this.isBreakpointLg = this.$el.getRootNode().children[0].offsetWidth < breakpointLg this.isBreakpointLg = this.$el.getRootNode().querySelector('div[data-v-app]').offsetWidth < breakpointLg
}, },
setDocumentRefs (el) { setDocumentRefs (el) {
if (el) { if (el) {
+10 -3
View File
@@ -42,7 +42,7 @@
ref="input" ref="input"
type="file" type="file"
name="files[]" name="files[]"
accept="image/*, application/pdf" :accept="acceptFileTypes"
multiple multiple
@change="upload" @change="upload"
> >
@@ -67,6 +67,11 @@ export default {
type: [Number, String], type: [Number, String],
required: true required: true
}, },
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
},
isDirectUpload: { isDirectUpload: {
type: Boolean, type: Boolean,
required: true, required: true,
@@ -89,8 +94,10 @@ export default {
return 'Uploading...' return 'Uploading...'
} else if (this.isProcessing) { } else if (this.isProcessing) {
return 'Processing...' return 'Processing...'
} else { } else if (this.acceptFileTypes === 'image/*, application/pdf') {
return 'Add PDF documents or images' return 'Add PDF documents or images'
} else {
return 'Add documents or images'
} }
} }
}, },
@@ -102,7 +109,7 @@ export default {
methods: { methods: {
upload: Upload.methods.upload, upload: Upload.methods.upload,
onDropFiles (e) { onDropFiles (e) {
if ([...e.dataTransfer.files].every((f) => f.type.match(/(?:image\/)|(?:application\/pdf)/))) { if (this.acceptFileTypes !== 'image/*, application/pdf' || [...e.dataTransfer.files].every((f) => f.type.match(/(?:image\/)|(?:application\/pdf)/))) {
this.$refs.input.files = e.dataTransfer.files this.$refs.input.files = e.dataTransfer.files
this.upload() this.upload()
@@ -29,8 +29,11 @@
@update:model-value="$emit('name-change', selectedSubmitter)" @update:model-value="$emit('name-change', selectedSubmitter)"
/> />
</div> </div>
<span> <span class="flex items-center">
▼ <IconPlus
width="18"
height="18"
/>
</span> </span>
</label> </label>
<ul <ul
@@ -86,7 +89,7 @@
</template> </template>
<script> <script>
import { IconUserPlus, IconTrashX } from '@tabler/icons-vue' import { IconUserPlus, IconTrashX, IconPlus } from '@tabler/icons-vue'
import Contenteditable from './contenteditable' import Contenteditable from './contenteditable'
import { v4 } from 'uuid' import { v4 } from 'uuid'
@@ -95,6 +98,7 @@ export default {
components: { components: {
IconUserPlus, IconUserPlus,
Contenteditable, Contenteditable,
IconPlus,
IconTrashX IconTrashX
}, },
props: { props: {
+23 -18
View File
@@ -18,33 +18,36 @@
:class="menuClasses" :class="menuClasses"
@click="closeDropdown" @click="closeDropdown"
> >
<li <template
v-for="(icon, type) in fieldIcons" v-for="(icon, type) in fieldIcons"
:key="type" :key="type"
> >
<a <li v-if="withPhone || type !== 'phone'">
href="#" <a
class="text-sm py-1 px-2" href="#"
:class="{ 'active': type === modelValue }" class="text-sm py-1 px-2"
@click.prevent="$emit('update:model-value', type)" :class="{ 'active': type === modelValue }"
> @click.prevent="$emit('update:model-value', type)"
<component >
:is="icon" <component
:stroke-width="1.6" :is="icon"
:width="20" :stroke-width="1.6"
/> :width="20"
{{ fieldNames[type] }} />
</a> {{ fieldNames[type] }}
</li> </a>
</li>
</template>
</ul> </ul>
</span> </span>
</template> </template>
<script> <script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3 } from '@tabler/icons-vue' import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck } from '@tabler/icons-vue'
export default { export default {
name: 'FiledTypeDropdown', name: 'FiledTypeDropdown',
inject: ['withPhone'],
props: { props: {
modelValue: { modelValue: {
type: String, type: String,
@@ -79,7 +82,8 @@ export default {
checkbox: 'Checkbox', checkbox: 'Checkbox',
multiple: 'Multiple', multiple: 'Multiple',
radio: 'Radio', radio: 'Radio',
cells: 'Cells' cells: 'Cells',
phone: 'Phone'
} }
}, },
fieldIcons () { fieldIcons () {
@@ -93,7 +97,8 @@ export default {
checkbox: IconCheckbox, checkbox: IconCheckbox,
cells: IconColumns3, cells: IconColumns3,
multiple: IconChecks, multiple: IconChecks,
radio: IconCircleDot radio: IconCircleDot,
phone: IconPhoneCheck
} }
} }
}, },
+72 -39
View File
@@ -25,48 +25,79 @@
/> />
</div> </div>
<div class="grid grid-cols-3 gap-1 pb-2"> <div class="grid grid-cols-3 gap-1 pb-2">
<button <template
v-for="(icon, type) in fieldIcons" v-for="(icon, type) in fieldIcons"
:key="type" :key="type"
draggable="true"
class="flex items-center justify-center border border-dashed border-base-300 w-full rounded relative"
@dragstart="onDragstart(type)"
@dragend="$emit('drag-end')"
@click="addField(type)"
> >
<div class="w-0 absolute left-0"> <button
<svg v-if="withPhone || type != 'phone'"
xmlns="http://www.w3.org/2000/svg" draggable="true"
class="cursor-grab" class="flex items-center justify-center border border-dashed border-base-300 w-full rounded relative"
width="18" :style="{ backgroundColor: backgroundColor }"
height="18" @dragstart="onDragstart(type)"
viewBox="0 0 24 24" @dragend="$emit('drag-end')"
stroke-width="1.5" @click="addField(type)"
stroke="currentColor" >
fill="none" <div class="w-0 absolute left-0">
stroke-linecap="round" <svg
stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg"
> class="cursor-grab"
<path width="18"
stroke="none" height="18"
d="M0 0h24v24H0z" viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none" fill="none"
/> stroke-linecap="round"
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /> stroke-linejoin="round"
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /> >
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /> <path
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /> stroke="none"
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /> d="M0 0h24v24H0z"
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /> fill="none"
</svg> />
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
</svg>
</div>
<div class="flex items-center flex-col px-2 py-2">
<component :is="icon" />
<span class="text-xs mt-1">
{{ fieldNames[type] }}
</span>
</div>
</button>
<div
v-else
class="tooltip flex"
data-tip="Unlock SMS-verified phone number field with paid plan. Use text field for phone numbers without verification."
>
<a
href="https://www.docuseal.co/pricing"
target="_blank"
class="opacity-50 flex items-center justify-center border border-dashed border-base-300 w-full rounded relative"
:style="{ backgroundColor: backgroundColor }"
>
<div class="w-0 absolute left-0">
<IconLock
width="18"
height="18"
stroke-width="1.5"
/>
</div>
<div class="flex items-center flex-col px-2 py-2">
<component :is="icon" />
<span class="text-xs mt-1">
{{ fieldNames[type] }}
</span>
</div>
</a>
</div> </div>
<div class="flex items-center flex-col px-2 py-2"> </template>
<component :is="icon" />
<span class="text-xs mt-1">
{{ fieldNames[type] }}
</span>
</div>
</button>
</div> </div>
<div <div
v-if="fields.length < 4" v-if="fields.length < 4"
@@ -94,14 +125,16 @@ import Field from './field'
import { v4 } from 'uuid' import { v4 } from 'uuid'
import FieldType from './field_type' import FieldType from './field_type'
import FieldSubmitter from './field_submitter' import FieldSubmitter from './field_submitter'
import { IconLock } from '@tabler/icons-vue'
export default { export default {
name: 'TemplateFields', name: 'TemplateFields',
components: { components: {
Field, Field,
FieldSubmitter FieldSubmitter,
IconLock
}, },
inject: ['save'], inject: ['save', 'backgroundColor', 'withPhone'],
props: { props: {
fields: { fields: {
type: Array, type: Array,
@@ -18,6 +18,7 @@
<ReplaceButton <ReplaceButton
:is-direct-upload="isDirectUpload" :is-direct-upload="isDirectUpload"
:template-id="template.id" :template-id="template.id"
:accept-file-types="acceptFileTypes"
class="opacity-0 group-hover:opacity-100" class="opacity-0 group-hover:opacity-100"
@click.stop @click.stop
@success="$emit('replace', { replaceSchemaItem: item, ...$event })" @success="$emit('replace', { replaceSchemaItem: item, ...$event })"
@@ -94,6 +95,11 @@ export default {
type: Object, type: Object,
required: true required: true
}, },
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
},
isDirectUpload: { isDirectUpload: {
type: Boolean, type: Boolean,
required: true, required: true,
+6 -1
View File
@@ -14,7 +14,7 @@
ref="input" ref="input"
name="files[]" name="files[]"
type="file" type="file"
accept="image/*, application/pdf" :accept="acceptFileTypes"
@change="upload" @change="upload"
> >
</form> </form>
@@ -32,6 +32,11 @@ export default {
type: [Number, String], type: [Number, String],
required: true required: true
}, },
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
},
isDirectUpload: { isDirectUpload: {
type: Boolean, type: Boolean,
required: true, required: true,
+6 -1
View File
@@ -33,7 +33,7 @@
ref="input" ref="input"
name="files[]" name="files[]"
type="file" type="file"
accept="image/*, application/pdf" :accept="acceptFileTypes"
multiple multiple
@change="upload" @change="upload"
> >
@@ -56,6 +56,11 @@ export default {
type: [Number, String], type: [Number, String],
required: true required: true
}, },
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
},
isDirectUpload: { isDirectUpload: {
type: Boolean, type: Boolean,
required: true, required: true,
@@ -1,7 +0,0 @@
# frozen_string_literal: true
class GenerateSubmitterResultAttachmentsJob < ApplicationJob
def perform(submitter)
Submissions::EnsureResultGenerated.call(submitter)
end
end
@@ -0,0 +1,53 @@
# frozen_string_literal: true
class ProcessSubmitterCompletionJob < ApplicationJob
def perform(submitter)
is_all_completed = !submitter.submission.submitters.exists?(completed_at: nil)
if !is_all_completed && submitter.submission.submitters_order_preserved?
enqueue_next_submitter_request_notification(submitter)
end
Submissions::EnsureResultGenerated.call(submitter)
if submitter.account.encrypted_configs.exists?(key: EncryptedConfig::WEBHOOK_URL_KEY)
SendWebhookRequestJob.perform_later(submitter)
end
return unless is_all_completed
return if submitter.completed_at != submitter.submission.submitters.maximum(:completed_at)
Submissions::GenerateAuditTrail.call(submitter.submission)
enqueue_emails(submitter)
end
def enqueue_emails(submitter)
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
SubmitterMailer.completed_email(submitter, user, bcc:).deliver_later!
end
to = submitter.submission.submitters.sort_by(&:completed_at).select(&:email?).map(&:friendly_name).join(', ')
SubmitterMailer.documents_copy_email(submitter, to:).deliver_later! if to.present?
end
def enqueue_next_submitter_request_notification(submitter)
next_submitter_item =
submitter.submission.template_submitters.find do |e|
sub = submitter.submission.submitters.find { |s| s.uuid == e['uuid'] }
sub.completed_at.blank? && sub.sent_at.blank?
end
return unless next_submitter_item
next_submitter = submitter.submission.submitters.find { |s| s.uuid == next_submitter_item['uuid'] }
Submitters.send_signature_requests([next_submitter], send_email: true)
end
end
@@ -0,0 +1,12 @@
# frozen_string_literal: true
class SendSubmitterInvitationEmailJob < ApplicationJob
def perform(submitter)
SubmitterMailer.invitation_email(submitter).deliver_now!
SubmissionEvent.create!(submitter:, event_type: 'send_email')
submitter.sent_at ||= Time.current
submitter.save
end
end
+30 -12
View File
@@ -17,42 +17,46 @@ class SubmitterMailer < ApplicationMailer
'You have been invited to submit a form' 'You have been invited to submit a form'
end end
mail(to: @submitter.email, mail(to: @submitter.friendly_name,
from: from_address_for_submitter(submitter), from: from_address_for_submitter(submitter),
subject:, subject:,
reply_to: submitter.submission.created_by_user&.friendly_name) reply_to: (submitter.submission.created_by_user || submitter.template.author)&.friendly_name)
end end
def completed_email(submitter, user) def completed_email(submitter, user, bcc: nil)
@current_account = submitter.submission.template.account @current_account = submitter.submission.template.account
@submitter = submitter @submitter = submitter
@submission = submitter.submission
@user = user @user = user
Submissions::EnsureResultGenerated.call(submitter)
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY) @email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY)
add_completed_email_attachments!(submitter)
subject = subject =
if @email_config if @email_config
ReplaceEmailVariables.call(@email_config.value['subject'], submitter:) ReplaceEmailVariables.call(@email_config.value['subject'], submitter:)
else else
%(#{submitter.email} has completed the "#{submitter.submission.template.name}" form) submitters = submitter.submission.submitters.order(:completed_at)
.map { |e| e.name || e.email || e.phone }.join(', ')
%(#{submitter.submission.template.name} has been completed by #{submitters})
end end
mail(from: from_address_for_submitter(submitter), mail(from: from_address_for_submitter(submitter),
to: user.email, to: user.friendly_name,
bcc:,
subject:) subject:)
end end
def documents_copy_email(submitter) def documents_copy_email(submitter, to: nil)
@current_account = submitter.submission.template.account @current_account = submitter.submission.template.account
@submitter = submitter @submitter = submitter
Submissions::EnsureResultGenerated.call(@submitter) Submissions::EnsureResultGenerated.call(@submitter)
@documents = Submitters.select_attachments_for_download(submitter) @documents = add_completed_email_attachments!(submitter)
@documents.each do |attachment|
attachments[attachment.filename.to_s] = attachment.download
end
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY) @email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY)
@@ -64,12 +68,26 @@ class SubmitterMailer < ApplicationMailer
end end
mail(from: from_address_for_submitter(submitter), mail(from: from_address_for_submitter(submitter),
to: submitter.email, to: to || @submitter.friendly_name,
subject:) subject:)
end end
private private
def add_completed_email_attachments!(submitter)
documents = Submitters.select_attachments_for_download(submitter)
documents.each do |attachment|
attachments[attachment.filename.to_s] = attachment.download
end
if submitter.submission.audit_trail.present?
attachments[submitter.submission.audit_trail.filename.to_s] = submitter.submission.audit_trail.download
end
documents
end
def from_address_for_submitter(submitter) def from_address_for_submitter(submitter)
submitter.submission.created_by_user&.friendly_name || submitter.submission.template.author.friendly_name submitter.submission.created_by_user&.friendly_name || submitter.submission.template.author.friendly_name
end end
+3 -5
View File
@@ -36,12 +36,10 @@ class AccountConfig < ApplicationRecord
'{{account.name}}' '{{account.name}}'
}, },
SUBMITTER_COMPLETED_EMAIL_KEY => { SUBMITTER_COMPLETED_EMAIL_KEY => {
'subject' => '{{submitter.email}} has completed the "{{template.name}}" form', 'subject' => '{{template.name}} has been completed by {{submission.submitters}}',
'body' => "Hi,\n\n" \ 'body' => "Hi,\n\n" \
"{{submitter.email}} has completed the \"{{template.name}}\" form:\n\n" \ "\"{{template.name}}\" form has been completed by {{submission.submitters}}\n\n" \
"{{submission.link}}\n\n" \ '{{submission.link}}'
"Thanks,\n" \
'{{account.name}}'
}, },
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => { SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => {
'subject' => 'Your copy of documents', 'subject' => 'Your copy of documents',
-2
View File
@@ -31,8 +31,6 @@ class EncryptedConfig < ApplicationRecord
belongs_to :account belongs_to :account
validates :key, inclusion: { in: CONFIG_KEYS }
encrypts :value encrypts :value
serialize :value, JSON serialize :value, JSON
+14
View File
@@ -7,6 +7,7 @@
# id :bigint not null, primary key # id :bigint not null, primary key
# deleted_at :datetime # deleted_at :datetime
# source :text not null # source :text not null
# submitters_order :string not null
# template_fields :text # template_fields :text
# template_schema :text # template_schema :text
# template_submitters :text # template_submitters :text
@@ -31,12 +32,20 @@ class Submission < ApplicationRecord
belongs_to :created_by_user, class_name: 'User', optional: true belongs_to :created_by_user, class_name: 'User', optional: true
has_many :submitters, dependent: :destroy has_many :submitters, dependent: :destroy
has_many :submission_events, dependent: :destroy
serialize :template_fields, JSON serialize :template_fields, JSON
serialize :template_schema, JSON serialize :template_schema, JSON
serialize :template_submitters, JSON serialize :template_submitters, JSON
attribute :source, :string, default: 'link' attribute :source, :string, default: 'link'
attribute :submitters_order, :string, default: 'random'
has_one_attached :audit_trail
has_many :template_schema_documents,
->(e) { where(uuid: (e.template_schema.presence || e.template.schema).pluck('attachment_uuid')) },
through: :template, source: :documents_attachments
scope :active, -> { where(deleted_at: nil) } scope :active, -> { where(deleted_at: nil) }
@@ -46,4 +55,9 @@ class Submission < ApplicationRecord
embed: 'embed', embed: 'embed',
link: 'link' link: 'link'
}, scope: false, prefix: true }, scope: false, prefix: true
enum :submitters_order, {
random: 'random',
preserved: 'preserved'
}, scope: false, prefix: true
end end
+53
View File
@@ -0,0 +1,53 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: submission_events
#
# id :bigint not null, primary key
# data :text not null
# event_timestamp :datetime not null
# event_type :string not null
# created_at :datetime not null
# updated_at :datetime not null
# submission_id :bigint not null
# submitter_id :bigint
#
# Indexes
#
# index_submission_events_on_submission_id (submission_id)
# index_submission_events_on_submitter_id (submitter_id)
#
# Foreign Keys
#
# fk_rails_... (submission_id => submissions.id)
# fk_rails_... (submitter_id => submitters.id)
#
class SubmissionEvent < ApplicationRecord
belongs_to :submission
belongs_to :submitter, optional: true
attribute :data, :string, default: -> { {} }
attribute :event_timestamp, :datetime, default: -> { Time.current }
serialize :data, JSON
before_validation :set_submission_id, on: :create
enum :event_type, {
send_email: 'send_email',
send_sms: 'send_sms',
open_email: 'open_email',
click_email: 'click_email',
click_sms: 'click_sms',
start_form: 'start_form',
view_form: 'view_form',
complete_form: 'complete_form'
}, scope: false
private
def set_submission_id
self.submission_id = submitter&.submission_id
end
end
+12 -1
View File
@@ -6,9 +6,11 @@
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# completed_at :datetime # completed_at :datetime
# email :string not null # email :string
# ip :string # ip :string
# name :string
# opened_at :datetime # opened_at :datetime
# phone :string
# sent_at :datetime # sent_at :datetime
# slug :string not null # slug :string not null
# ua :string # ua :string
@@ -42,6 +44,7 @@ class Submitter < ApplicationRecord
has_many_attached :attachments has_many_attached :attachments
has_many :document_generation_events, dependent: :destroy has_many :document_generation_events, dependent: :destroy
has_many :submission_events, dependent: :destroy
def status def status
if completed_at? if completed_at?
@@ -55,6 +58,14 @@ class Submitter < ApplicationRecord
end end
end end
def friendly_name
if name.present?
"#{name} <#{email}>"
else
email
end
end
def status_event_at def status_event_at
completed_at || opened_at || sent_at || created_at completed_at || opened_at || sent_at || created_at
end end
+2 -1
View File
@@ -59,7 +59,8 @@
{ {
"submitters": [ "submitters": [
{ {
"name": "<%= current_account.templates.last ? current_account.templates.last.submitters.first['name'] : 'First Submitter' %>", "name": "John Doe",
"role": "<%= current_account.templates.last ? current_account.templates.last.submitters.first['name'] : 'First Submitter' %>",
"email": "<%= current_user.email.sub('@', '+test@') %>", "email": "<%= current_user.email.sub('@', '+test@') %>",
"values": { "values": {
"Form Text Field Name": "Default Value" "Form Text Field Name": "Default Value"
+8 -5
View File
@@ -1,11 +1,14 @@
<% if Docuseal.demo? %><%= render 'shared/demo_alert' %><% end %> <% if Docuseal.demo? %><%= render 'shared/demo_alert' %><% end %>
<% if @templates.any? %> <% if @templates.any? %>
<div class="flex justify-between mb-4 items-center"> <div class="flex justify-between mb-4 items-center">
<h1 class="text-4xl font-bold">Templates</h1> <h1 class="text-4xl font-bold"><span class="hidden md:inline">Document</span> Templates</h1>
<%= link_to new_template_path, class: 'btn btn-primary text-base btn-md gap-2', data: { turbo_frame: :modal } do %> <div class="flex space-x-2">
<%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %> <%= render 'templates/upload_button' %>
<span class="hidden md:block">Create</span> <%= link_to new_template_path, class: 'btn btn-primary text-base btn-md gap-2', data: { turbo_frame: :modal } do %>
<% end %> <%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %>
<span class="hidden md:block">Create</span>
<% end %>
</div>
</div> </div>
<div class="grid gap-4 md:grid-cols-3"> <div class="grid gap-4 md:grid-cols-3">
<% @templates.each do |template| %> <% @templates.each do |template| %>
+4 -1
View File
@@ -1,6 +1,9 @@
<div class="max-w-xl mx-auto px-2"> <div class="max-w-xl mx-auto px-2">
<h1 class="text-3xl font-bold text-center my-8">Profile Details</h1> <h1 class="text-3xl font-bold text-center my-8">Profile Details</h1>
<%= form_for('', as: resource_name, html: { class: 'space-y-6' }, url: new_registration_path) do |f| %> <%= 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] %>
<% end %>
<div class="space-y-2"> <div class="space-y-2">
<%= render 'devise/shared/error_messages', resource: %> <%= render 'devise/shared/error_messages', resource: %>
<%= f.fields_for resource do |ff| %> <%= f.fields_for resource do |ff| %>
+5 -1
View File
@@ -1,6 +1,10 @@
<div class="max-w-xl mx-auto px-2"> <div class="max-w-xl 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 my-8">Create Free Account</h1>
<%= form_for(User.new, html: { class: 'space-y-6' }, url: new_registration_path, method: :get) do |f| %> <%= 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] %>
<% end %>
<div class="space-y-2"> <div class="space-y-2">
<div class="form-control"> <div class="form-control">
<%= f.label :email, class: 'label' %> <%= f.label :email, class: 'label' %>
+5 -1
View File
@@ -1,6 +1,10 @@
<div class="max-w-xl mx-auto px-2"> <div class="max-w-xl 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 my-8">Log In</h1>
<%= form_for(resource, as: resource_name, html: { class: 'space-y-6' }, url: session_path(resource_name)) do |f| %> <%= 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] %>
<% end %>
<div class="space-y-2"> <div class="space-y-2">
<div class="form-control"> <div class="form-control">
<%= f.label :email, class: 'label' %> <%= f.label :email, class: 'label' %>
+1 -1
View File
@@ -3,7 +3,7 @@
<%= link_to 'Log in', new_session_path(resource_name), class: 'link link-hover' %> <%= link_to 'Log in', new_session_path(resource_name), class: 'link link-hover' %>
<% end %> <% end %>
<%- if devise_mapping.registerable? && controller_name != 'registrations' %> <%- if devise_mapping.registerable? && controller_name != 'registrations' %>
<%= link_to 'Create free account', registration_path, class: 'link link-hover' %> <%= link_to 'Create free account', registration_path({ redir: params[:redir] }.compact_blank), class: 'link link-hover' %>
<% end %> <% end %>
<%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %> <%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %>
<%= link_to 'Forgot your password?', new_password_path(resource_name), class: 'link link-hover' %> <%= link_to 'Forgot your password?', new_password_path(resource_name), class: 'link link-hover' %>
@@ -0,0 +1,10 @@
<div class="text-center">
<div class="join">
<a href="https://docuseal.co<%= request.fullpath.gsub('docuseal.eu', 'docuseal.co') %>" class="btn bg-base-200 join-item w-32 <%= 'bg-base-300' if request.host == 'docuseal.co' %>">
🌎 Global
</a>
<a href="https://docuseal.eu<%= request.fullpath.gsub('docuseal.co', 'docuseal.eu') %>" class="btn bg-base-200 join-item w-32 <%= 'bg-base-300' if request.host == 'docuseal.eu' %>">
🇪🇺 Europe
</a>
</div>
</div>
Whitespace-only changes.
-41
View File
@@ -1,41 +0,0 @@
<div class="flex flex-wrap space-y-4 md:flex-nowrap md:space-y-0">
<%= render 'shared/settings_nav' %>
<div class="flex-grow max-w-xl mx-auto">
<h1 class="text-4xl font-bold mb-4">PDF Signature</h1>
<div id="result">
<p class="mb-2">
Upload signed PDF file to validate its signature:
</p>
</div>
<%= form_for '', url: settings_esign_index_path, method: :post, html: { enctype: 'multipart/form-data' } do |f| %>
<%= f.button type: 'submit', class: 'flex' do %>
<div class="disabled mb-3">
<%= svg_icon('loader', class: 'w-5 h-5 animate-spin inline') %>
Analyzing...
</div>
<% end %>
<file-dropzone data-is-direct-upload="false" data-name="verify_attachments" data-submit-on-upload="true" class="w-full">
<label for="file" class="w-full block h-32 relative bg-base-200 hover:bg-base-200/70 rounded-md border border-base-content 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">
<%= svg_icon('cloud_upload', class: 'w-10 h-10') %>
</span>
<span data-target="file-dropzone.loading" class="hidden">
<%= svg_icon('loader', class: 'w-10 h-10 animate-spin') %>
</span>
<div class="font-medium mb-1">
Verify Signed PDF
</div>
<div class="text-xs">
<span class="font-medium">Click to upload</span> or drag and drop files
</div>
</div>
<input id="file" name="files[]" class="hidden" data-action="change:file-dropzone#onSelectFiles" data-target="file-dropzone.input" type="file" accept="application/pdf" multiple>
</div>
</label>
</file-dropzone>
<% end %>
</div>
<div class="w-0 md:w-52"></div>
</div>
+24
View File
@@ -0,0 +1,24 @@
<%= render 'shared/turbo_modal', title: 'Upload Certificate' do %>
<%= form_for @cert_record, url: settings_esign_path, html: { class: 'space-y-4', enctype: 'multipart/form-data' }, data: { turbo_frame: :_top } do |f| %>
<div class="space-y-2">
<div class="form-control">
<%= f.label :name, class: 'label' %>
<%= f.text_field :name, required: true, class: 'base-input' %>
</div>
<div class="form-control">
<%= f.label :file, class: 'label' %>
<%= f.file_field :file, required: true %>
<label class="label">
<span class="label-text-alt">Use a valid .der, .p12 or .pfx file.</span>
</label>
</div>
<div class="form-control">
<%= f.label :password, 'Password (optional)', class: 'label' %>
<%= f.text_field :password, class: 'base-input' %>
</div>
</div>
<div class="form-control pt-2">
<%= f.button button_title, class: 'base-button' %>
</div>
<% end %>
<% end %>
+99
View File
@@ -0,0 +1,99 @@
<div class="flex flex-wrap space-y-4 md:flex-nowrap md:space-y-0 md:space-x-10">
<%= render 'shared/settings_nav' %>
<div class="flex-grow">
<div class="max-w-xl">
<h1 class="text-4xl font-bold mb-4">PDF Signature</h1>
<div id="result">
<p class="mb-2">
Upload signed PDF file to validate its signature:
</p>
</div>
<%= form_for '', url: verify_pdf_signature_index_path, method: :post, html: { enctype: 'multipart/form-data' } do |f| %>
<%= f.button type: 'submit', class: 'flex' do %>
<div class="disabled mb-3">
<%= svg_icon('loader', class: 'w-5 h-5 animate-spin inline') %>
Analyzing...
</div>
<% end %>
<file-dropzone data-is-direct-upload="false" data-name="verify_attachments" data-submit-on-upload="true" class="w-full">
<label for="file" class="w-full block h-32 relative bg-base-200 hover:bg-base-200/70 rounded-md border border-base-content 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">
<%= svg_icon('cloud_upload', class: 'w-10 h-10') %>
</span>
<span data-target="file-dropzone.loading" class="hidden">
<%= svg_icon('loader', class: 'w-10 h-10 animate-spin') %>
</span>
<div class="font-medium mb-1">
Verify Signed PDF
</div>
<div class="text-xs">
<span class="font-medium">Click to upload</span> or drag and drop files
</div>
</div>
<input id="file" name="files[]" class="hidden" data-action="change:file-dropzone#onSelectFiles" data-target="file-dropzone.input" type="file" accept="application/pdf" multiple>
</div>
</label>
</file-dropzone>
<% end %>
</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>
<% end %>
</div>
<%= render 'alert' %>
<div class="overflow-x-auto">
<table class="table w-full table-lg rounded-b-none overflow-hidden">
<thead class="bg-base-200">
<tr class="text-neutral uppercase">
<th>
Name
</th>
<th>
Valid To
</th>
<th>
Status
</th>
<th class="text-right" width="1px">
</th>
</tr>
</thead>
<tbody>
<% @pkcs_list.each do |item| %>
<tr scope="row" class="group">
<td>
<%= item['name'] %>
</td>
<td>
<%= l(item['pkcs'].certificate.not_after.to_date, format: :long, locale: current_account.locale) %>
</td>
<td>
<% if item['status'] == 'default' %>
<span class="badge badge-lg badge-info badge-outline">
<%= item['status'] %>
</span>
<% else %>
<%= 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' %>
<%= 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 %>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
</div>
+6
View File
@@ -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="M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"></path>
<path d="M12 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"></path>
<path d="M12 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"></path>
</svg>

After

Width:  |  Height:  |  Size: 470 B

+6
View File
@@ -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="M12 6h-6a2 2 0 0 0 -2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-6"></path>
<path d="M11 13l9 -9"></path>
<path d="M15 4h5v5"></path>
</svg>

After

Width:  |  Height:  |  Size: 432 B

+4
View File
@@ -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 4h4l2 5l-2.5 1.5a11 11 0 0 0 5 5l1.5 -2.5l5 2v4a2 2 0 0 1 -2 2a16 16 0 0 1 -15 -15a2 2 0 0 1 2 -2"></path>
</svg>

After

Width:  |  Height:  |  Size: 409 B

+6
View File
@@ -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="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2"></path>
<path d="M7 9l5 -5l5 5"></path>
<path d="M12 4l0 12"></path>
</svg>

After

Width:  |  Height:  |  Size: 416 B

@@ -0,0 +1,28 @@
<div class="collapse collapse-plus bg-base-200">
<input type="checkbox">
<div class="collapse-title text-xl font-medium">
<div>
Documents Copy Email
</div>
</div>
<div class="collapse-content">
<%= form_for AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY), url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
<%= f.hidden_field :key %>
<%= f.fields_for :value, Struct.new(:subject, :body).new(*f.object.value.values_at('subject', 'body')) do |ff| %>
<div class="form-control">
<%= ff.label :subject, class: 'label' %>
<%= ff.text_field :subject, required: true, class: 'base-input' %>
</div>
<div class="form-control">
<%= ff.label :body, class: 'label' %>
<autoresize-textarea>
<%= ff.text_area :body, required: true, class: 'base-input w-full py-2' %>
</autoresize-textarea>
</div>
<% end %>
<div class="form-control pt-2">
<%= f.button button_title(title: 'Save', disabled_with: 'Saving'), class: 'base-button' %>
</div>
<% end %>
</div>
</div>
@@ -1,11 +1,11 @@
<div class="alert my-4"> <div class="alert my-4">
<%= svg_icon('info_circle', class: 'w-6 h-6') %> <%= svg_icon('info_circle', class: 'w-6 h-6') %>
<div> <div>
<p class="font-bold">Unlock with DocuSeal Enterprise</p> <p class="font-bold">Unlock with DocuSeal Pro</p>
<p> <p>
Display your company name and logo when signing documents. Display your company name and logo when signing documents.
<br> <br>
<a class="link font-medium" target="_blank" href="<%= "#{Docuseal::PRODUCT_URL}/pricing" %>">Learn More</a> <a class="link font-medium" target="_blank" href="<%= "#{Docuseal::CONSOLE_URL}/#{Docuseal.multitenant? ? 'plans' : 'on_premise'}" %>">Learn More</a>
</p> </p>
</div> </div>
</div> </div>
@@ -0,0 +1,28 @@
<div class="collapse collapse-plus bg-base-200">
<input type="checkbox">
<div class="collapse-title text-xl font-medium">
<div>
Signature Request Email
</div>
</div>
<div class="collapse-content">
<%= form_for AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY), url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
<%= f.hidden_field :key %>
<%= f.fields_for :value, Struct.new(:subject, :body).new(*f.object.value.values_at('subject', 'body')) do |ff| %>
<div class="form-control">
<%= ff.label :subject, class: 'label' %>
<%= ff.text_field :subject, required: true, class: 'base-input' %>
</div>
<div class="form-control">
<%= ff.label :body, class: 'label' %>
<autoresize-textarea>
<%= ff.text_area :body, required: true, class: 'base-input w-full py-2' %>
</autoresize-textarea>
</div>
<% end %>
<div class="form-control pt-2">
<%= f.button button_title(title: 'Save', disabled_with: 'Saving'), class: 'base-button' %>
</div>
<% end %>
</div>
</div>
@@ -0,0 +1,28 @@
<div class="collapse collapse-plus bg-base-200">
<input type="checkbox">
<div class="collapse-title text-xl font-medium">
<div>
Form Completed Email
</div>
</div>
<div class="collapse-content">
<%= form_for AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY), url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
<%= f.hidden_field :key %>
<%= f.fields_for :value, Struct.new(:subject, :body).new(*f.object.value.values_at('subject', 'body')) do |ff| %>
<div class="form-control">
<%= ff.label :subject, class: 'label' %>
<%= ff.text_field :subject, required: true, class: 'base-input' %>
</div>
<div class="form-control">
<%= ff.label :body, class: 'label' %>
<autoresize-textarea>
<%= ff.text_area :body, required: true, class: 'base-input w-full py-2' %>
</autoresize-textarea>
</div>
<% end %>
<div class="form-control pt-2">
<%= f.button button_title(title: 'Save', disabled_with: 'Saving'), class: 'base-button' %>
</div>
<% end %>
</div>
</div>
@@ -2,89 +2,10 @@
<%= render 'shared/settings_nav' %> <%= render 'shared/settings_nav' %>
<div class="flex-grow max-w-xl mx-auto"> <div class="flex-grow max-w-xl mx-auto">
<p class="text-4xl font-bold mb-4">Email Templates</p> <p class="text-4xl font-bold mb-4">Email Templates</p>
<div class="collapse collapse-plus bg-base-200 px-1"> <div class="space-y-4">
<input type="checkbox"> <%= render 'signature_request_email_form' %>
<div class="collapse-title text-xl font-medium"> <%= render 'submitter_completed_email_form' %>
<div> <%= render 'documents_copy_email_form' %>
Signature Request Email
</div>
</div>
<div class="collapse-content">
<%= form_for AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY), url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
<%= f.hidden_field :key %>
<%= f.fields_for :value, Struct.new(:subject, :body).new(*f.object.value.values_at('subject', 'body')) do |ff| %>
<div class="form-control">
<%= ff.label :subject, class: 'label' %>
<%= ff.text_field :subject, required: true, class: 'base-input' %>
</div>
<div class="form-control">
<%= ff.label :body, class: 'label' %>
<autoresize-textarea>
<%= ff.text_area :body, required: true, class: 'base-input w-full py-2' %>
</autoresize-textarea>
</div>
<% end %>
<div class="form-control pt-2">
<%= f.button button_title(title: 'Save', disabled_with: 'Saving'), class: 'base-button' %>
</div>
<% end %>
</div>
</div>
<div class="collapse collapse-plus bg-base-200 px-1 mt-4">
<input type="checkbox">
<div class="collapse-title text-xl font-medium">
<div>
Form Completed Email
</div>
</div>
<div class="collapse-content">
<%= form_for AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY), url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
<%= f.hidden_field :key %>
<%= f.fields_for :value, Struct.new(:subject, :body).new(*f.object.value.values_at('subject', 'body')) do |ff| %>
<div class="form-control">
<%= ff.label :subject, class: 'label' %>
<%= ff.text_field :subject, required: true, class: 'base-input' %>
</div>
<div class="form-control">
<%= ff.label :body, class: 'label' %>
<autoresize-textarea>
<%= ff.text_area :body, required: true, class: 'base-input w-full py-2' %>
</autoresize-textarea>
</div>
<% end %>
<div class="form-control pt-2">
<%= f.button button_title(title: 'Save', disabled_with: 'Saving'), class: 'base-button' %>
</div>
<% end %>
</div>
</div>
<div class="collapse collapse-plus bg-base-200 px-1 mt-4">
<input type="checkbox">
<div class="collapse-title text-xl font-medium">
<div>
Documents Copy Email
</div>
</div>
<div class="collapse-content">
<%= form_for AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY), url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
<%= f.hidden_field :key %>
<%= f.fields_for :value, Struct.new(:subject, :body).new(*f.object.value.values_at('subject', 'body')) do |ff| %>
<div class="form-control">
<%= ff.label :subject, class: 'label' %>
<%= ff.text_field :subject, required: true, class: 'base-input' %>
</div>
<div class="form-control">
<%= ff.label :body, class: 'label' %>
<autoresize-textarea>
<%= ff.text_area :body, required: true, class: 'base-input w-full py-2' %>
</autoresize-textarea>
</div>
<% end %>
<div class="form-control pt-2">
<%= f.button button_title(title: 'Save', disabled_with: 'Saving'), class: 'base-button' %>
</div>
<% end %>
</div>
</div> </div>
<p class="text-4xl font-bold mb-4 mt-8">Company Logo</p> <p class="text-4xl font-bold mb-4 mt-8">Company Logo</p>
<%= render 'logo_form' %> <%= render 'logo_form' %>
+1 -4
View File
@@ -1,4 +1 @@
<div class="text-center px-2"> <%= render 'shared/powered_by' %>
Powered by
<a href="<%= Docuseal::PRODUCT_URL %>" class="underline"><%= Docuseal::PRODUCT_NAME %></a> - open source documents software
</div>
+1 -1
View File
@@ -8,7 +8,7 @@
</span> </span>
<span class="disabled"> <span class="disabled">
<span class="flex items-center justify-center space-x-2"> <span class="flex items-center justify-center space-x-2">
<%= local_assigns[:icon_disabled] || svg_icon('loader', class: 'w-6 h-6 animate-spin') %> <%= local_assigns[:icon_disabled] || svg_icon('loader', class: 'w-5 h-5 animate-spin') %>
<span><%= disabled_with %>...</span> <span><%= disabled_with %>...</span>
</span> </span>
</span> </span>
+1 -1
View File
@@ -1,4 +1,4 @@
<div id="flash" class="absolute top-0 w-full h-0"> <div id="flash" class="absolute top-0 w-full h-0 z-20">
<div class="max-w-xl mx-auto mt-1.5"> <div class="max-w-xl mx-auto mt-1.5">
<div class="px-4 py-3 rounded-2xl bg-base-200 flex items-center justify-between mx-4 md:mx-0"> <div class="px-4 py-3 rounded-2xl bg-base-200 flex items-center justify-between mx-4 md:mx-0">
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
+1 -1
View File
@@ -44,7 +44,7 @@
</li> </li>
<% end %> <% end %>
<li> <li>
<%= link_to settings_esign_index_path, class: 'flex items-center' do %> <%= link_to settings_esign_path, class: 'flex items-center' do %>
<%= svg_icon('zoom_check', class: 'w-5 h-5 stroke-2') %> <%= svg_icon('zoom_check', class: 'w-5 h-5 stroke-2') %>
<span class="mr-1">Verify PDF</span> <span class="mr-1">Verify PDF</span>
<% end %> <% end %>
+4
View File
@@ -0,0 +1,4 @@
<div class="text-center px-2">
Powered by
<a href="<%= Docuseal::PRODUCT_URL %>" class="underline"><%= Docuseal::PRODUCT_NAME %></a> - open source documents software
</div>
+4 -1
View File
@@ -16,9 +16,12 @@
<li> <li>
<%= link_to 'Storage', settings_storage_index_path, class: 'text-base hover:bg-base-300' %> <%= link_to 'Storage', settings_storage_index_path, class: 'text-base hover:bg-base-300' %>
</li> </li>
<li>
<%= link_to 'SMS', settings_sms_path, class: 'text-base hover:bg-base-300' %>
</li>
<% end %> <% end %>
<li> <li>
<%= link_to 'Signature', settings_esign_index_path, class: 'text-base hover:bg-base-300' %> <%= link_to 'E-Signature', settings_esign_path, class: 'text-base hover:bg-base-300' %>
</li> </li>
<li> <li>
<%= link_to 'Team', settings_users_path, class: 'text-base hover:bg-base-300' %> <%= link_to 'Team', settings_users_path, class: 'text-base hover:bg-base-300' %>
@@ -0,0 +1,11 @@
<div class="alert">
<%= svg_icon('info_circle', class: 'w-6 h-6') %>
<div>
<p class="font-bold">Send signature requests via SMS</p>
<p class="text-gray-700">
Unlock with DocuSeal Enterprise
<br>
<a class="link font-medium" target="_blank" href="<%= "#{Docuseal::CONSOLE_URL}/#{Docuseal.multitenant? ? 'plans' : 'on_premise'}" %>">Learn More</a>
</p>
</div>
</div>
+8
View File
@@ -0,0 +1,8 @@
<div class="flex flex-wrap space-y-4 md:flex-nowrap md:space-y-0">
<%= render 'shared/settings_nav' %>
<div class="flex-grow max-w-xl mx-auto">
<h1 class="text-4xl font-bold mb-4">SMS</h1>
<%= render 'placeholder' %>
</div>
<div class="w-0 md:w-52"></div>
</div>
+1 -1
View File
@@ -17,7 +17,7 @@
</div> </div>
</div> </div>
<div class="text-center text-2xl font-semibold"> <div class="text-center text-2xl font-semibold">
Form has been submitter already Form has been submitted already
</div> </div>
<% if Accounts.can_send_emails?(@submitter.submission.template.account) %> <% if Accounts.can_send_emails?(@submitter.submission.template.account) %>
<div> <div>
@@ -0,0 +1 @@
<a href="<%= annot['value'] %>" rel="noopener noreferrer nofollow" target="_blank" class="flex absolute" style="width: <%= annot['w'] * 100 %>%; height: <%= annot['h'] * 100 %>%; left: <%= annot['x'] * 100 %>%; top: <%= annot['y'] * 100 %>%"></a>
@@ -0,0 +1,44 @@
<%= form_for '', url: template_submissions_path(template), html: { class: 'space-y-4', autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
<dynamic-list class="space-y-4">
<div class="space-y-4">
<div class="card card-compact bg-base-200" data-targets="dynamic-list.items">
<div class="card-body">
<div class="absolute right-4 top-5">
<a href="#" data-action="click:dynamic-list#removeItem" class="<%= template.submitters.size == 1 ? 'right-2' : '-top-3' %> relative block w-6 h-6 rounded-lg text-neutral-700 text-center bg-base-300 p-1 hidden hover:bg-neutral hover:text-white">
<%= svg_icon('trash', class: 'w-4 h-4') %>
</a>
</div>
<div class="grid <%= 'md:grid-cols-2' if template.submitters.size > 1 %> gap-4">
<% template.submitters.each do |item| %>
<div class="form-control">
<% if template.submitters.size > 1 %>
<label class="label pt-0 pb-1 text-xs">
<span class="label-text"> <%= item['name'] %></span>
</label>
<% end %>
<input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>">
<input type="text" name="submission[1][submitters][][name]" autocomplete="off" class="input input-sm input-bordered" placeholder="Name" required>
<div class="grid <%= 'md:grid-cols-2 gap-1' if template.submitters.size == 1 %>">
<input type="email" name="submission[1][submitters][][email]" autocomplete="off" class="input input-sm input-bordered mt-1.5" placeholder="Email (optional)">
<input type="tel" pattern="^\+[0-9\s\-]+$" oninvalid="this.value ? this.setCustomValidity('Use internatioanl format: +1xxx...') : ''" oninput="this.setCustomValidity('')" name="submission[1][submitters][][phone]" autocomplete="off" class="input input-sm input-bordered mt-1.5" placeholder="Phone (optional)">
</div>
</div>
<% end %>
</div>
</div>
</div>
</div>
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
<span>Add New</span>
</a>
</dynamic-list>
<div>
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
<%= render 'send_email', f:, template: %>
<%= render 'send_sms', f: %>
</div>
<div class="form-control">
<%= f.button button_title(title: 'Add Recipients'), class: 'base-button' %>
</div>
<% end %>
@@ -0,0 +1,45 @@
<%= form_for '', url: template_submissions_path(template), html: { class: 'space-y-4', autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
<% if template.submitters.size == 1 %>
<div class="form-control">
<autoresize-textarea>
<%= f.text_area :emails, required: true, class: 'base-textarea w-full', placeholder: 'Type emails here...' %>
</autoresize-textarea>
</div>
<% else %>
<dynamic-list class="space-y-4">
<div class="space-y-4">
<div class="card card-compact bg-base-200" data-targets="dynamic-list.items">
<div class="card-body">
<div class="absolute right-4 top-5">
<a href="#" data-action="click:dynamic-list#removeItem" class="-top-3 relative block w-6 h-6 rounded-lg text-neutral-700 text-center bg-base-300 p-1 hidden hover:bg-neutral hover:text-white">
<%= svg_icon('trash', class: 'w-4 h-4') %>
</a>
</div>
<div class="grid md:grid-cols-2 gap-4">
<% template.submitters.each do |item| %>
<div class="form-control">
<label class="label pt-0 pb-1 text-xs">
<span class="label-text"> <%= item['name'] %></span>
</label>
<input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>">
<input type="email" name="submission[1][submitters][][email]" autocomplete="off" class="input input-sm input-bordered" placeholder="Email" required>
</div>
<% end %>
</div>
</div>
</div>
</div>
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
<span>Add New</span>
</a>
</dynamic-list>
<% end %>
<div>
<%= render 'send_email', f:, template: %>
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
</div>
<div class="form-control">
<%= f.button button_title(title: 'Add Recipients'), class: 'base-button' %>
</div>
<% end %>
@@ -0,0 +1,45 @@
<%= form_for '', url: template_submissions_path(template), html: { class: 'space-y-4', autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
<dynamic-list class="space-y-4">
<div class="space-y-4">
<div class="card card-compact bg-base-200" data-targets="dynamic-list.items">
<div class="card-body">
<div class="absolute right-4 top-5">
<a href="#" data-action="click:dynamic-list#removeItem" class="<%= template.submitters.size == 1 ? 'right-2' : '-top-3' %> relative block w-6 h-6 rounded-lg text-neutral-700 text-center bg-base-300 p-1 hidden hover:bg-neutral hover:text-white">
<%= svg_icon('trash', class: 'w-4 h-4') %>
</a>
</div>
<div class="grid md:grid-cols-2 <%= template.submitters.size > 1 ? 'gap-4' : 'gap-1' %>">
<% template.submitters.each do |item| %>
<div class="form-control">
<% if template.submitters.size > 1 %>
<label class="label pt-0 pb-1 text-xs">
<span class="label-text"> <%= item['name'] %></span>
</label>
<% end %>
<input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>">
<input type="tel" pattern="^\+[0-9\s\-]+$" oninvalid="this.value ? this.setCustomValidity('Use internatioanl format: +1xxx...') : ''" oninput="this.setCustomValidity('')" name="submission[1][submitters][][phone]" autocomplete="off" class="input input-sm input-bordered" placeholder="Phone" required>
<% if template.submitters.size > 1 %>
<input type="text" name="submission[1][submitters][][name]" autocomplete="off" class="input input-sm input-bordered mt-1.5" placeholder="Name (optional)">
<% end %>
</div>
<% if template.submitters.size == 1 %>
<div class="form-control flex">
<input type="text" name="submission[1][submitters][][name]" autocomplete="off" class="input input-sm input-bordered" placeholder="Name (optional)">
</div>
<% end %>
<% end %>
</div>
</div>
</div>
</div>
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
<span>Add New</span>
</a>
</dynamic-list>
<%= render 'send_sms', f: %>
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
<div class="form-control">
<%= f.button button_title(title: 'Add Recipients'), class: 'base-button' %>
</div>
<% end %>
@@ -0,0 +1,20 @@
<div class="form-control">
<% is_smtp_configured = Accounts.can_send_emails?(current_account) %>
<%= f.label :send_email, for: uuid = SecureRandom.uuid, class: 'flex items-center cursor-pointer' do %>
<%= f.check_box :send_email, id: uuid, class: 'base-checkbox', disabled: !is_smtp_configured, checked: is_smtp_configured %>
<span class="label">Send emails</span>
<% end %>
<% unless is_smtp_configured %>
<div class="alert my-4">
<%= svg_icon('info_circle', class: 'w-6 h-6') %>
<div>
<p class="font-bold">SMTP not Configured</p>
<p class="text-gray-700">
Configure SMTP settings in order to send emails:
<br>
<a class="link font-medium" data-turbo-frame="_top" href="<%= settings_email_index_path %>">Go to SMTP settings</a>
</p>
</div>
</div>
<% end %>
</div>
+3
View File
@@ -0,0 +1,3 @@
<div class="form-control">
<%= render 'sms_settings/placeholder' %>
</div>
@@ -0,0 +1,5 @@
<div class="mt-2 mb-1">
<div class="tooltip w-full" data-tip="Unlock with Enterpise">
<%= link_to submitter.sent_at? ? 'Re-send SMS' : 'Send SMS', "#{Docuseal::CONSOLE_URL}/#{Docuseal.multitenant? ? 'plans' : 'on_premise'}", class: 'btn btn-sm btn-primary text-gray-400 w-full' %>
</div>
</div>
@@ -0,0 +1,11 @@
<% if template.submitters.size > 1 %>
<div class="form-control">
<%= f.label :preserve_order, for: uuid = SecureRandom.uuid, class: 'flex items-center cursor-pointer' do %>
<%= f.check_box :preserve_order, id: uuid, class: 'base-checkbox', checked: template.submissions.last&.submitters_order.in?(['preserved', nil]) %>
<span class="label">Preserve submitters order</span>
<span class="tooltip" data-tip="When checked, notifications will be sent to the second submitter once the form is completed by the previous submitter. Uncheck this option to send notifications to all submitters simultaneously right away.">
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
</span>
<% end %>
</div>
<% end %>
+22 -77
View File
@@ -1,81 +1,26 @@
<%= render 'shared/turbo_modal', title: 'New Recipients' do %> <%= render 'shared/turbo_modal', title: 'Add New Recipients' do %>
<%= form_for '', url: template_submissions_path(@template), html: { class: 'space-y-4', autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %> <% options = [['via Email', 'email'], ['via Phone', 'phone'], ['Detailed', 'detailed']] %>
<% if @template.submitters.size == 1 %> <toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="relative text-center mt-4 block">
<div class="form-control"> <div class="join">
<%= f.label :emails, class: 'label' %> <% options.each_with_index do |(label, value), index| %>
<autoresize-textarea> <span>
<%= f.text_area :emails, required: true, class: 'base-textarea w-full' %> <%= radio_button_tag 'option', value, value == 'email', class: 'peer hidden', data: { action: 'change:toggle-visible#trigger' } %>
</autoresize-textarea> <label for="option_<%= value %>" class="<%= '!rounded-s-full' if index.zero? %> btn btn-focus btn-sm join-item w-28 peer-checked:btn-active normal-case">
</div> <%= label %>
<% else %> </label>
<dynamic-list class="space-y-4"> </span>
<div class="space-y-4">
<div class="card card-compact bg-base-200" data-targets="dynamic-list.items">
<div class="card-body">
<div class="absolute right-4 top-5">
<a href="#" data-action="click:dynamic-list#removeItem" class="-top-3 relative block w-6 h-6 rounded-lg text-neutral-700 text-center bg-base-300 p-1 hidden hover:bg-neutral hover:text-white">
<%= svg_icon('trash', class: 'w-4 h-4') %>
</a>
</div>
<div class="grid md:grid-cols-2 gap-4">
<% @template.submitters.each do |item| %>
<div class="form-control">
<label class="label pt-0 pb-1 text-xs">
<span class="label-text"> <%= item['name'] %></span>
</label>
<input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>">
<input type="email" name="submission[1][submitters][][email]" value="<%= item['email'] %>" autocomplete="off" class="input input-sm input-bordered" placeholder="Email" required>
</div>
<% end %>
</div>
</div>
</div>
</div>
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
<span>Add Recipient</span>
</a>
</dynamic-list>
<% end %>
<div class="form-control">
<% is_smtp_configured = Accounts.can_send_emails?(current_account) %>
<%= f.label :send_email, class: 'flex items-center cursor-pointer' do %>
<%= f.check_box :send_email, class: 'base-checkbox', disabled: !is_smtp_configured, onchange: "window.message_field && message_field.classList.toggle('hidden', !event.currentTarget.checked)" %>
<span class="label">Send Email</span>
<% end %>
<% unless is_smtp_configured %>
<div class="alert my-4">
<%= svg_icon('info_circle', class: 'w-6 h-6') %>
<div>
<p class="font-bold">SMTP not Configured</p>
<p class="text-gray-700">
Configure SMTP settings in order to send emails:
<br>
<a class="link font-medium" data-turbo-frame="_top" href="<%= settings_email_index_path %>">Go to SMTP settings</a>
</p>
</div>
</div>
<% end %> <% end %>
</div> </div>
<% unless AccountConfig.exists?(key: AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY) %> </toggle-visible>
<div id="message_field" class="card card-compact bg-base-200 hidden"> <div class="mt-4">
<div class="card-body"> <div id="email">
<div class="form-control space-y-2"> <%= render 'email_form', template: @template %>
<span class="label-text">Hi there,</span>
<autoresize-textarea>
<%= f.text_area :message, value: format(SubmitterMailer::DEFAULT_MESSAGE, name: @template.name), required: true, class: 'base-textarea !rounded-lg w-full' %>
</autoresize-textarea>
<span class="label-text">
Thanks,
<br>
<%= current_account.name %>
</span>
</div>
</div>
</div>
<% end %>
<div class="form-control">
<%= f.button button_title(title: 'Add Recipients'), class: 'base-button' %>
</div> </div>
<% end %> <div id="phone" class="hidden">
<%= render 'phone_form', template: @template %>
</div>
<div id="detailed" class="hidden">
<%= render 'detailed_form', template: @template %>
</div>
</div>
<% end %> <% end %>
+44 -8
View File
@@ -5,6 +5,12 @@
<span class="text-3xl font-semibold focus:text-clip"><%= @submission.template.name %></span> <span class="text-3xl font-semibold focus:text-clip"><%= @submission.template.name %></span>
</a> </a>
<div class="space-x-3 flex items-center"> <div class="space-x-3 flex items-center">
<% if @submission.audit_trail.present? %>
<a href="<%= rails_blob_path(@submission.audit_trail) %>" class="white-button" target="_blank">
<%= svg_icon('external_link', class: 'w-6 h-6') %>
Audit Log
</a>
<% end %>
<% if last_submitter = @submission.submitters.to_a.select(&:completed_at?).max_by(&:completed_at) %> <% if last_submitter = @submission.submitters.to_a.select(&:completed_at?).max_by(&:completed_at) %>
<download-button data-src="<%= submitter_download_index_path(last_submitter.slug) %>" class="base-button"> <download-button data-src="<%= submitter_download_index_path(last_submitter.slug) %>" class="base-button">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton"> <span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
@@ -24,7 +30,7 @@
<div class="flex md:max-h-[calc(100vh-60px)]"> <div class="flex md:max-h-[calc(100vh-60px)]">
<div class="overflow-y-auto overflow-x-hidden hidden lg:block w-52 flex-none pr-3 mt-0.5 pt-0.5"> <div class="overflow-y-auto overflow-x-hidden hidden lg:block w-52 flex-none pr-3 mt-0.5 pt-0.5">
<% (@submission.template_schema || @submission.template.schema).each do |item| %> <% (@submission.template_schema || @submission.template.schema).each do |item| %>
<% document = @submission.template.documents.find { |a| item['attachment_uuid'] == a.uuid } %> <% document = @submission.template_schema_documents.find { |a| item['attachment_uuid'] == a.uuid } %>
<a href="#<%= "page-#{document.uuid}-0" %>" onclick="[event.preventDefault(), window[event.target.closest('a').href.split('#')[1]].scrollIntoView({ behavior: 'smooth', block: 'start' })]" class="block cursor-pointer"> <a href="#<%= "page-#{document.uuid}-0" %>" onclick="[event.preventDefault(), window[event.target.closest('a').href.split('#')[1]].scrollIntoView({ behavior: 'smooth', block: 'start' })]" class="block cursor-pointer">
<img src="<%= document.preview_images.first.url %>" width="<%= document.preview_images.first.metadata['width'] %>" height="<%= document.preview_images.first.metadata['height'] %>" class="rounded border" loading="lazy"> <img src="<%= document.preview_images.first.url %>" width="<%= document.preview_images.first.metadata['width'] %>" height="<%= document.preview_images.first.metadata['height'] %>" class="rounded border" loading="lazy">
<div class="pb-2 pt-1.5 text-center"> <div class="pb-2 pt-1.5 text-center">
@@ -39,11 +45,15 @@
<% values = @submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } %> <% values = @submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } %>
<% attachments_index = ActiveStorage::Attachment.where(record: @submission.submitters, name: :attachments).preload(:blob).index_by(&:uuid) %> <% attachments_index = ActiveStorage::Attachment.where(record: @submission.submitters, name: :attachments).preload(:blob).index_by(&:uuid) %>
<% (@submission.template_schema || @submission.template.schema).each do |item| %> <% (@submission.template_schema || @submission.template.schema).each do |item| %>
<% document = @submission.template.documents.find { |e| e.uuid == item['attachment_uuid'] } %> <% document = @submission.template_schema_documents.find { |e| e.uuid == item['attachment_uuid'] } %>
<% document_annots_index = document.metadata.dig('pdf', 'annotations')&.group_by { |e| e['page'] } || {} %>
<% document.preview_images.sort_by { |a| a.filename.base.to_i }.each_with_index do |page, index| %> <% document.preview_images.sort_by { |a| a.filename.base.to_i }.each_with_index do |page, index| %>
<div id="<%= "page-#{document.uuid}-#{index}" %>" class="relative"> <div id="<%= "page-#{document.uuid}-#{index}" %>" class="relative">
<img src="<%= page.url %>" width="<%= page.metadata['width'] %>" class="shadow-md mb-4" height="<%= page.metadata['height'] %>" loading="lazy"> <img src="<%= page.url %>" width="<%= page.metadata['width'] %>" class="shadow-md mb-4" height="<%= page.metadata['height'] %>" loading="lazy">
<div class="top-0 bottom-0 left-0 right-0 absolute"> <div class="top-0 bottom-0 left-0 right-0 absolute">
<% document_annots_index[index]&.each do |annot| %>
<%= render 'submissions/annotation', annot: %>
<% end %>
<% fields_index.dig(document.uuid, index)&.each do |(area, field)| %> <% fields_index.dig(document.uuid, index)&.each do |(area, field)| %>
<% value = values[field['uuid']] %> <% value = values[field['uuid']] %>
<% next if value.blank? %> <% next if value.blank? %>
@@ -68,18 +78,44 @@
<%= (@submission.template_submitters || @submission.template.submitters).find { |e| e['uuid'] == submitter&.uuid }&.dig('name') || "#{(index + 1).ordinalize} Submitter" %> <%= (@submission.template_submitters || @submission.template.submitters).find { |e| e['uuid'] == submitter&.uuid }&.dig('name') || "#{(index + 1).ordinalize} Submitter" %>
</span> </span>
</div> </div>
<div class="flex items-center space-x-1 mt-1"> <% if submitter&.name.present? %>
<%= svg_icon('mail', class: 'w-5 h-5') %> <div class="flex items-center space-x-1 mt-1">
<span> <%= svg_icon('user', class: 'w-5 h-5') %>
<%= submitter&.email || 'N/A' %> <span>
</span> <%= submitter&.name %>
</div> </span>
</div>
<% end %>
<% if submitter&.email.present? %>
<div class="flex items-center space-x-1 mt-1">
<%= svg_icon('mail', class: 'w-5 h-5') %>
<span>
<%= submitter.email || 'N/A' %>
</span>
</div>
<% end %>
<% if submitter&.phone.present? %>
<div class="flex items-center space-x-1 mt-1">
<%= svg_icon('phone', class: 'w-5 h-5') %>
<span>
<%= submitter.phone %>
</span>
</div>
<% end %>
<div class="flex items-center space-x-1 mt-1"> <div class="flex items-center space-x-1 mt-1">
<%= svg_icon('writing', class: 'w-5 h-5') %> <%= svg_icon('writing', class: 'w-5 h-5') %>
<span> <span>
<%= submitter&.completed_at? ? l(submitter.completed_at.in_time_zone(current_account.timezone), format: :long, locale: current_account.locale) : 'Not completed yet' %> <%= submitter&.completed_at? ? l(submitter.completed_at.in_time_zone(current_account.timezone), format: :long, locale: current_account.locale) : 'Not completed yet' %>
</span> </span>
</div> </div>
<% if submitter && submitter.email && !submitter.completed_at %>
<div class="mt-2 mb-1">
<%= button_to button_title(title: submitter.sent_at? ? 'Re-send Email' : 'Send Email', disabled_with: 'Sending'), submitter_send_email_index_path(submitter_slug: submitter.slug), class: 'btn btn-sm btn-primary w-full' %>
</div>
<% end %>
<% if submitter && submitter.phone && !submitter.completed_at %>
<%= render 'send_sms_button', submitter: %>
<% end %>
<% if submitter && !submitter.completed_at? %> <% if submitter && !submitter.completed_at? %>
<div class="mt-2 mb-1"> <div class="mt-2 mb-1">
<a class="btn btn-sm btn-primary w-full" target="_blank" href="<%= submit_form_path(slug: submitter.slug) %>"> <a class="btn btn-sm btn-primary w-full" target="_blank" href="<%= submit_form_path(slug: submitter.slug) %>">
@@ -0,0 +1,3 @@
<% data_attachments = attachments_index.values.select { |e| e.record_id == submitter.id }.to_json(only: %i[uuid], methods: %i[url filename content_type]) %>
<% data_fields = (submitter.submission.template_fields || submitter.submission.template.fields).select { |f| f['submitter_uuid'] == submitter.uuid }.to_json %>
<submission-form data-is-demo="<%= Docuseal.demo? %>" data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(Struct.new(:id).new(@submitter.submission.template.account_id)) %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-authenticity-token="<%= form_authenticity_token %>" data-values="<%= submitter.values.to_json %>"></submission-form>
+1 -1
View File
@@ -17,7 +17,7 @@
</div> </div>
</div> </div>
<div> <div>
<% if Accounts.can_send_emails?(@submitter.submission.template.account) %> <% if Accounts.can_send_emails?(@submitter.submission.template.account) && @submitter.email.present? %>
<%= button_to button_title(title: 'Send copy to Email', disabled_with: 'Sending', icon: svg_icon('mail_forward', class: 'w-6 h-6')), send_submission_email_index_path, params: { submitter_slug: @submitter.slug }, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'white-button w-full' %> <%= button_to button_title(title: 'Send copy to Email', disabled_with: 'Sending', icon: svg_icon('mail_forward', class: 'w-6 h-6')), send_submission_email_index_path, params: { submitter_slug: @submitter.slug }, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'white-button w-full' %>
<div class="divider">OR</div> <div class="divider">OR</div>
<% end %> <% end %>
+6 -2
View File
@@ -8,11 +8,15 @@
<%= render 'banner' %> <%= render 'banner' %>
</div> </div>
<% (@submitter.submission.template_schema || @submitter.submission.template.schema).each do |item| %> <% (@submitter.submission.template_schema || @submitter.submission.template.schema).each do |item| %>
<% document = @submitter.submission.template.documents.find { |a| a.uuid == item['attachment_uuid'] } %> <% document = @submitter.submission.template_schema_documents.find { |a| a.uuid == item['attachment_uuid'] } %>
<% document_annots_index = document.metadata.dig('pdf', 'annotations')&.group_by { |e| e['page'] } || {} %>
<% document.preview_images.sort_by { |a| a.filename.base.to_i }.each_with_index do |page, index| %> <% document.preview_images.sort_by { |a| a.filename.base.to_i }.each_with_index do |page, index| %>
<div class="relative my-4 shadow-md"> <div class="relative my-4 shadow-md">
<img src="<%= page.url %>" width="<%= page.metadata['width'] %>" height="<%= page.metadata['height'] %>" loading="lazy"> <img src="<%= page.url %>" width="<%= page.metadata['width'] %>" height="<%= page.metadata['height'] %>" loading="lazy">
<div id="page-<%= [document.uuid, index].join('-') %>" class="top-0 bottom-0 left-0 right-0 absolute"> <div id="page-<%= [document.uuid, index].join('-') %>" class="top-0 bottom-0 left-0 right-0 absolute">
<% document_annots_index[index]&.each do |annot| %>
<%= render 'submissions/annotation', annot: %>
<% end %>
<% fields_index.dig(document.uuid, index)&.each do |(area, field)| %> <% fields_index.dig(document.uuid, index)&.each do |(area, field)| %>
<% value = values[field['uuid']] %> <% value = values[field['uuid']] %>
<% next if value.blank? %> <% next if value.blank? %>
@@ -29,7 +33,7 @@
<div class="fixed bottom-0 w-full h-0 z-20"> <div class="fixed bottom-0 w-full h-0 z-20">
<div class="mx-auto" style="max-width: 1000px"> <div class="mx-auto" style="max-width: 1000px">
<div class="relative md:mx-32"> <div class="relative md:mx-32">
<submission-form data-is-demo="<%= Docuseal.demo? %>" data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-submitter-uuid="<%= @submitter.uuid %>" data-submitter-slug="<%= @submitter.slug %>" data-can-send-email="<%= Accounts.can_send_emails?(Struct.new(:id).new(@submitter.submission.template.account_id)) %>" data-attachments="<%= attachments_index.values.select { |e| e.record_id == @submitter.id }.to_json(only: %i[uuid], methods: %i[url filename content_type]) %>" data-fields="<%= (@submitter.submission.template_fields || @submitter.submission.template.fields).select { |f| f['submitter_uuid'] == @submitter.uuid }.to_json %>" data-values="<%= @submitter.values.to_json %>" data-authenticity-token="<%= form_authenticity_token %>"></submission-form> <%= render 'submission_form', attachments_index:, submitter: @submitter %>
</div> </div>
</div> </div>
</div> </div>
@@ -1,7 +1,7 @@
<% if @email_config %> <% if @email_config %>
<%= auto_link(simple_format(h(ReplaceEmailVariables.call(@email_config.value['body'], submitter: @submitter)))) %> <%= auto_link(simple_format(h(ReplaceEmailVariables.call(@email_config.value['body'], submitter: @submitter)))) %>
<% else %> <% else %>
<p>Hi <%= @user.first_name %>,</p> <p>Hi there,</p>
<p><%= @submitter.email %> has completed the "<%= @submitter.submission.template.name %>" form.</p> <p>"<%= @submitter.submission.template.name %>" form has been completed by <%= @submitter.submission.submitters.order(:completed_at).map { |e| e.name || e.email || e.phone }.join(', ') %>.</p>
<p><%= link_to 'View Submission', submission_url(@submitter.submission) %></p> <p><%= link_to 'View Submission', submission_url(@submitter.submission) %></p>
<% end %> <% end %>
@@ -3,7 +3,7 @@
<% else %> <% else %>
<p>Hi there,</p> <p>Hi there,</p>
<%= simple_format(@message) %> <%= simple_format(@message) %>
<p><%= link_to 'Submit Form', submit_form_url(slug: @submitter.slug) %></p> <p><%= link_to 'Submit Form', submit_form_url(slug: @submitter.slug, t: SubmissionEvents.build_tracking_param(@submitter, 'click_email')) %></p>
<p>Please contact us by replying to this email if you didn't request this.</p> <p>Please contact us by replying to this email if you didn't request this.</p>
<p> <p>
Thanks,<br><%= @current_account.name %> Thanks,<br><%= @current_account.name %>
+116
View File
@@ -0,0 +1,116 @@
<% status_badges = { 'awaiting' => 'badge-info', 'sent' => 'badge-info', 'completed' => 'badge-success', 'opened' => 'badge-warning' } %>
<a href="<%= submission_path(submission) %>" class="bg-base-200 w-full flex flex-col md:flex-row space-y-4 md:space-y-0 md:justify-between rounded-2xl px-6 py-5 md:items-center">
<% submitters = (submission.template_submitters || submission.template.submitters).filter_map { |item| submission.submitters.find { |e| e.uuid == item['uuid'] } } %>
<% is_submission_completed = submitters.all?(&:completed_at?) %>
<% if submitters.size == 1 %>
<div>
<% submitter = submitters.first %>
<div class="flex items-center space-x-4">
<span class="flex items-center space-x-3">
<div class="tooltip flex" data-tip="<%= l(submitter.status_event_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %>">
<span class="badge <%= status_badges[submitter.status] %> md:w-32 badge-lg bg-opacity-50 uppercase text-sm font-semibold">
<%= submitter.status %>
</span>
</div>
<span class="text-lg break-all flex items-center">
<%= submitter.name || submitter.email || submitter.phone %>
</span>
</span>
</div>
</div>
<div class="flex space-x-2 items-center">
<% if submitter.completed_at? %>
<form onsubmit="event.preventDefault()">
<button onclick="event.stopPropagation()">
<download-button data-src="<%= submitter_download_index_path(submitter.slug) %>" class="btn btn-sm btn-neutral text-white md:w-36">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-5 h-5 stroke-2') %>
<span class="inline">Download</span>
</span>
<span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-5 h-5 animate-spin') %>
<span class="inline">Downloa...</span>
</span>
</download-button>
</button>
</form>
<% else %>
<%= render 'shared/clipboard_copy', text: submit_form_url(slug: submitter.slug), class: 'btn btn-sm btn-neutral text-white md:w-36 flex', icon_class: 'w-6 h-6 text-white', copy_title: 'Copy Link', copy_title_md: 'Copy', copied_title_md: 'Copied' %>
<% end %>
<span class="btn btn-outline btn-sm w-20 md:w-24">View</span>
<%= button_to button_title(title: nil, disabled_with: 'Remov', icon: svg_icon('trash', class: 'w-6 h-6')), submission_path(submission), class: 'btn btn-outline btn-sm', title: 'Delete', method: :delete, data: { turbo_confirm: 'Are you sure?' }, onclick: 'event.stopPropagation()' %>
</div>
<% else %>
<div class="space-y-1 w-full md:mr-2">
<div class="flex items-center space-x-3">
<% if is_submission_completed %>
<% latest_submitter = submitters.select(&:completed_at?).max_by(&:completed_at) %>
<div class="tooltip flex" data-tip="<%= l(latest_submitter.status_event_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %>">
<span class="badge <%= status_badges[latest_submitter.status] %> md:w-32 bg-opacity-50 badge-lg uppercase text-sm font-semibold">
<%= latest_submitter.status %>
</span>
</div>
<% end %>
<div class="w-full">
<% submitters.each_with_index do |submitter, index| %>
<div class="flex justify-between items-center space-x-3">
<span class="flex items-center space-x-3">
<% unless is_submission_completed %>
<div class="tooltip flex" data-tip="<%= l(submitter.status_event_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %>">
<span class="badge md:w-24 <%= status_badges[submitter.status] %> bg-opacity-50 uppercase text-xs font-semibold">
<%= submitter.status %>
</span>
</div>
<% end %>
<span class="text-lg break-all">
<%= submitter.name || submitter.email || submitter.phone %>
</span>
</span>
<% if submitter.completed_at? && !is_submission_completed %>
<form onsubmit="event.preventDefault()">
<button onclick="event.stopPropagation()">
<download-button data-src="<%= submitter_download_index_path(submitter.slug) %>" class="btn btn-xs btn-neutral text-white md:w-36">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-4 h-4 stroke-2') %>
<span class="hidden md:inline">Download</span>
</span>
<span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-4 h-4 animate-spin') %>
<span class="hidden md:inline">Downloa...</span>
</span>
</download-button>
</button>
</form>
<% elsif !is_submission_completed %>
<div class="flex items-center space-x-3">
<%= render 'shared/clipboard_copy', text: submit_form_url(slug: submitter.slug), class: 'btn btn-xs text-xs btn-neutral text-white md:w-36 flex', icon_class: 'w-4 h-4 text-white', copy_title: 'Copy Link' %>
</div>
<% end %>
</div>
<% end %>
</div>
</div>
</div>
<div class="flex space-x-2 items-center">
<% if is_submission_completed %>
<% latest_submitter = submitters.select(&:completed_at?).max_by(&:completed_at) %>
<form onsubmit="event.preventDefault()">
<button onclick="event.stopPropagation()">
<download-button data-src="<%= submitter_download_index_path(latest_submitter.slug) %>" class="btn btn-sm btn-neutral text-white md:w-36">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-5 h-5 stroke-2') %>
<span class="inline">Download</span>
</span>
<span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-5 h-5 animate-spin') %>
<span class="inline">Downloa...</span>
</span>
</download-button>
</button>
</form>
<% end %>
<span class="btn btn-outline btn-sm w-20 md:w-24">View</span>
<%= button_to button_title(title: nil, disabled_with: 'Remov', icon: svg_icon('trash', class: 'w-6 h-6')), submission_path(submission), class: 'btn btn-outline btn-sm', title: 'Delete', method: :delete, data: { turbo_confirm: 'Are you sure?' }, onclick: 'event.stopPropagation()' %>
</div>
<% end %>
</a>
@@ -0,0 +1,17 @@
<%= form_for '', url: templates_upload_path, method: :post, class: 'inline', html: { enctype: 'multipart/form-data' } do %>
<button type="submit" class="btn btn-ghost text-base" onclick="[event.preventDefault(), window.upload_template.click()]">
<span class="enabled">
<span class="flex items-center justify-center space-x-2">
<%= svg_icon('upload', class: 'w-6 h-6 stroke-2') %>
<span class="hidden md:block">Upload</span>
</span>
</span>
<span class="disabled">
<span class="flex items-center justify-center space-x-2">
<%= local_assigns[:icon_disabled] || svg_icon('loader', class: 'w-5 h-5 animate-spin') %>
<span class="hidden md:block">Uploading...</span>
</span>
</span>
</button>
<input id="upload_template" name="files[]" class="hidden" onchange="this.form.requestSubmit()" type="file" accept="image/*, application/pdf<%= ', .docx, .doc, .xlsx, .xls' if Docuseal.multitenant? %>" multiple>
<% end %>
+1 -1
View File
@@ -1,4 +1,4 @@
<%= render 'shared/turbo_modal', title: @base_template ? 'Clone Template' : 'New Template' do %> <%= render 'shared/turbo_modal', title: @base_template ? 'Clone Template' : 'New Document Template' do %>
<%= form_for @template, data: { turbo_frame: :_top } do |f| %> <%= form_for @template, data: { turbo_frame: :_top } do |f| %>
<% if @base_template %> <% if @base_template %>
<%= hidden_field_tag :base_template_id, @base_template.id %> <%= hidden_field_tag :base_template_id, @base_template.id %>
+1 -118
View File
@@ -38,126 +38,9 @@
</div> </div>
</div> </div>
<% end %> <% end %>
<% status_badges = { 'awaiting' => 'badge-info', 'sent' => 'badge-info', 'completed' => 'badge-success', 'opened' => 'badge-warning' } %>
<% if @submissions.present? %> <% if @submissions.present? %>
<div class="space-y-4"> <div class="space-y-4">
<% @submissions.each do |submission| %> <%= render partial: 'submission', collection: @submissions %>
<a href="<%= submission_path(submission) %>" class="bg-base-200 w-full flex flex-col md:flex-row space-y-4 md:space-y-0 md:justify-between rounded-2xl px-6 py-5 md:items-center">
<% submitters = (submission.template_submitters || submission.template.submitters).filter_map { |item| submission.submitters.find { |e| e.uuid == item['uuid'] } } %>
<% is_submission_completed = submitters.all?(&:completed_at?) %>
<% if submitters.size == 1 %>
<div>
<% submitter = submitters.first %>
<div class="flex items-center space-x-4">
<span class="flex items-center space-x-3">
<div class="tooltip flex" data-tip="<%= l(submitter.status_event_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %>">
<span class="badge <%= status_badges[submitter.status] %> md:w-32 badge-lg bg-opacity-50 uppercase text-sm font-semibold">
<%= submitter.status %>
</span>
</div>
<span class="text-lg break-all">
<%= submitter.email %>
</span>
</span>
</div>
</div>
<div class="flex space-x-2 items-center">
<% if submitter.completed_at? %>
<form onsubmit="event.preventDefault()">
<button onclick="event.stopPropagation()">
<download-button data-src="<%= submitter_download_index_path(submitter.slug) %>" class="btn btn-sm btn-neutral text-white md:w-36">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-5 h-5 stroke-2') %>
<span class="inline">Download</span>
</span>
<span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-5 h-5 animate-spin') %>
<span class="inline">Downloa...</span>
</span>
</download-button>
</button>
</form>
<% else %>
<%= render 'shared/clipboard_copy', text: submit_form_url(slug: submitter.slug), class: 'btn btn-sm btn-neutral text-white md:w-36 flex', icon_class: 'w-6 h-6 text-white', copy_title: 'Copy Link' %>
<% end %>
<span class="btn btn-outline btn-sm w-20 md:w-24">View</span>
<%= button_to button_title(title: nil, disabled_with: 'Remov', icon: svg_icon('trash', class: 'w-6 h-6')), submission_path(submission), class: 'btn btn-outline btn-sm', title: 'Delete', method: :delete, data: { turbo_confirm: 'Are you sure?' }, onclick: 'event.stopPropagation()' %>
</div>
<% else %>
<div class="space-y-1 w-full md:mr-2">
<div class="flex items-center space-x-3">
<% if is_submission_completed %>
<% latest_submitter = submitters.select(&:completed_at?).max_by(&:completed_at) %>
<div class="tooltip flex" data-tip="<%= l(latest_submitter.status_event_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %>">
<span class="badge <%= status_badges[latest_submitter.status] %> md:w-32 bg-opacity-50 badge-lg uppercase text-sm font-semibold">
<%= latest_submitter.status %>
</span>
</div>
<% end %>
<div class="w-full">
<% submitters.each_with_index do |submitter, index| %>
<div class="flex justify-between items-center space-x-3">
<span class="flex items-center space-x-3">
<% unless is_submission_completed %>
<div class="tooltip flex" data-tip="<%= l(submitter.status_event_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %>">
<span class="badge md:w-24 <%= status_badges[submitter.status] %> bg-opacity-50 uppercase text-xs font-semibold">
<%= submitter.status %>
</span>
</div>
<% end %>
<span class="text-lg break-all">
<%= submitter.email %>
</span>
</span>
<% if submitter.completed_at? && !is_submission_completed %>
<form onsubmit="event.preventDefault()">
<button onclick="event.stopPropagation()">
<download-button data-src="<%= submitter_download_index_path(submitter.slug) %>" class="btn btn-xs btn-neutral text-white md:w-36">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-4 h-4 stroke-2') %>
<span class="hidden md:inline">Download</span>
</span>
<span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-4 h-4 animate-spin') %>
<span class="hidden md:inline">Downloa...</span>
</span>
</download-button>
</button>
</form>
<% elsif !is_submission_completed %>
<div class="flex items-center space-x-3">
<%= render 'shared/clipboard_copy', text: submit_form_url(slug: submitter.slug), class: 'btn btn-xs text-xs btn-neutral text-white md:w-36 flex', icon_class: 'w-4 h-4 text-white', copy_title: 'Copy Link' %>
</div>
<% end %>
</div>
<% end %>
</div>
</div>
</div>
<div class="flex space-x-2 items-center">
<% if is_submission_completed %>
<% latest_submitter = submitters.select(&:completed_at?).max_by(&:completed_at) %>
<form onsubmit="event.preventDefault()">
<button onclick="event.stopPropagation()">
<download-button data-src="<%= submitter_download_index_path(latest_submitter.slug) %>" class="btn btn-sm btn-neutral text-white md:w-36">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-5 h-5 stroke-2') %>
<span class="inline">Download</span>
</span>
<span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-5 h-5 animate-spin') %>
<span class="inline">Downloa...</span>
</span>
</download-button>
</button>
</form>
<% end %>
<span class="btn btn-outline btn-sm w-20 md:w-24">View</span>
<%= button_to button_title(title: nil, disabled_with: 'Remov', icon: svg_icon('trash', class: 'w-6 h-6')), submission_path(submission), class: 'btn btn-outline btn-sm', title: 'Delete', method: :delete, data: { turbo_confirm: 'Are you sure?' }, onclick: 'event.stopPropagation()' %>
</div>
<% end %>
</a>
<% end %>
</div> </div>
<%= render 'shared/pagination', pagy: @pagy, items_name: 'submissions' %> <%= render 'shared/pagination', pagy: @pagy, items_name: 'submissions' %>
<% else %> <% else %>
+1 -1
View File
@@ -49,7 +49,7 @@
<%= link_to edit_user_path(user), class: 'btn btn-outline btn-xs', title: 'Edit', data: { turbo_frame: 'modal' } do %> <%= link_to edit_user_path(user), class: 'btn btn-outline btn-xs', title: 'Edit', data: { turbo_frame: 'modal' } do %>
Edit Edit
<% end %> <% end %>
<%= link_to user_path(user), class: 'btn btn-outline btn-error btn-xs', title: 'Delete', data: { turbo_method: :delete, turbo_confirm: 'Are you sure?' } do %> <%= button_to user_path(user), method: :delete, class: 'btn btn-outline btn-error btn-xs', title: 'Delete', data: { turbo_confirm: 'Are you sure?' } do %>
Remove Remove
<% end %> <% end %>
</td> </td>
@@ -29,10 +29,10 @@
</p> </p>
<% if message == 'Signature valid' %> <% if message == 'Signature valid' %>
<p class="flex space-x-1 items-center"> <p class="flex space-x-1 items-center">
<% if signature.signature_handler.signer_certificate.public_key.to_der == trusted_certs.first.public_key.to_der %> <% if trusted_certs.any? { |e| e.public_key.to_der == signature.signature_handler.signer_certificate.public_key.to_der } %>
<%= svg_icon('circle_check', class: 'w-6 h-6 text-green-500') %> <%= svg_icon('circle_check', class: 'w-6 h-6 text-green-500') %>
<span> <span>
Signed with DocuSeal certificate Signed with trusted certificate
</span> </span>
<% else %> <% else %>
<%= svg_icon('x_circle', class: 'w-6 h-6 text-red-500') %> <%= svg_icon('x_circle', class: 'w-6 h-6 text-red-500') %>
@@ -54,7 +54,7 @@
</div> </div>
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1">
<%= svg_icon('certificate', class: 'w-5 h-5 inline') %> <%= svg_icon('certificate', class: 'w-5 h-5 inline') %>
<span><%= signature.signer_name %></span> <span><%= signature.signer_name.force_encoding('UTF-8') %></span>
</div> </div>
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1">
<%= svg_icon('lock_access', class: 'w-5 h-5 inline') %> <%= svg_icon('lock_access', class: 'w-5 h-5 inline') %>
+1
View File
@@ -44,6 +44,7 @@ Rails.application.configure do
# The :test delivery method accumulates sent emails in the # The :test delivery method accumulates sent emails in the
# ActionMailer::Base.deliveries array. # ActionMailer::Base.deliveries array.
config.action_mailer.delivery_method = :test config.action_mailer.delivery_method = :test
config.active_job.queue_adapter = :test
# Print deprecation notices to the stderr. # Print deprecation notices to the stderr.
config.active_support.deprecation = :stderr config.active_support.deprecation = :stderr
+11 -3
View File
@@ -17,8 +17,10 @@ Rails.application.routes.draw do
devise_scope :user do devise_scope :user do
if Docuseal.multitenant? if Docuseal.multitenant?
resource :registration, only: %i[show], path: 'sign_up' resource :registration, only: %i[show], path: 'sign_up'
resource :registration, only: %i[create], path: 'new' do unauthenticated do
get '' => :new, as: :new resource :registration, only: %i[create], path: 'new' do
get '' => :new, as: :new
end
end end
end end
@@ -29,6 +31,8 @@ Rails.application.routes.draw do
namespace :api, defaults: { format: :json } do namespace :api, defaults: { format: :json } do
resources :attachments, only: %i[create] resources :attachments, only: %i[create]
resources :submitter_email_clicks, only: %i[create]
resources :submitter_form_views, only: %i[create]
resources :submissions, only: %i[create] resources :submissions, only: %i[create]
resources :templates, only: %i[update show index] do resources :templates, only: %i[update show index] do
resources :submissions, only: %i[create] resources :submissions, only: %i[create]
@@ -36,12 +40,14 @@ Rails.application.routes.draw do
end end
end end
resources :verify_pdf_signature, only: %i[create]
resources :dashboard, only: %i[index] resources :dashboard, only: %i[index]
resources :setup, only: %i[index create] resources :setup, only: %i[index create]
resource :newsletter, only: %i[show update] resource :newsletter, only: %i[show update]
resources :users, only: %i[new create edit update destroy] resources :users, only: %i[new create edit update destroy]
resources :submissions, only: %i[show destroy] resources :submissions, only: %i[show destroy]
resources :console_redirect, only: %i[index] resources :console_redirect, only: %i[index]
resource :templates_upload, only: %i[create]
resources :templates, only: %i[new create edit show destroy] do resources :templates, only: %i[new create edit show destroy] do
resources :submissions, only: %i[new create] resources :submissions, only: %i[new create]
resources :submissions_export, only: %i[index new] resources :submissions_export, only: %i[index new]
@@ -61,6 +67,7 @@ Rails.application.routes.draw do
resources :submitters, only: %i[], param: 'slug' do resources :submitters, only: %i[], param: 'slug' do
resources :download, only: %i[index], controller: 'submissions_download' resources :download, only: %i[index], controller: 'submissions_download'
resources :send_email, only: %i[create], controller: 'submitters_send_email'
resources :debug, only: %i[index], controller: 'submissions_debug' if Rails.env.development? resources :debug, only: %i[index], controller: 'submissions_debug' if Rails.env.development?
end end
@@ -68,8 +75,9 @@ Rails.application.routes.draw do
unless Docuseal.multitenant? unless Docuseal.multitenant?
resources :storage, only: %i[index create], controller: 'storage_settings' resources :storage, only: %i[index create], controller: 'storage_settings'
resources :email, only: %i[index create], controller: 'email_settings' resources :email, only: %i[index create], controller: 'email_settings'
resources :sms, only: %i[index], controller: 'sms_settings'
end end
resources :esign, only: %i[index create], controller: 'esign_settings' resource :esign, only: %i[show create new update destroy], controller: 'esign_settings'
resources :users, only: %i[index] resources :users, only: %i[index]
resource :personalization, only: %i[show create], controller: 'personalization_settings' resource :personalization, only: %i[show create], controller: 'personalization_settings'
if !Docuseal.multitenant? || Docuseal.demo? if !Docuseal.multitenant? || Docuseal.demo?
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff. Show more