Compare commits

..
64 Commits
Author SHA1 Message Date
Alex Turchyn 05d578d88f Merge from docusealco/wip 2024-10-28 14:44:22 +02:00
Pete Matsyburka 8773b30b10 adjust auto migration 2024-10-27 14:26:52 +02:00
Pete Matsyburka 1b636c5878 fix completed submitters 2024-10-26 19:46:22 +03:00
Pete Matsyburka 46e948ab99 download combined completed 2024-10-26 19:30:23 +03:00
Pete Matsyburka 889103aa5e use com 2024-10-26 09:54:41 +03:00
Pete Matsyburka 558a14a5f2 fix email validate 2024-10-25 22:17:23 +03:00
Pete Matsyburka 72bbdb38bf mask key 2024-10-25 16:17:57 +03:00
Pete Matsyburka 3975a03cb4 adjust from email 2024-10-24 23:32:36 +03:00
Pete Matsyburka e398327fb7 refactor 2024-10-24 20:27:29 +03:00
Oleksandr Turchyn 9266694d65 add integration users page 2024-10-24 20:09:57 +03:00
Pete Matsyburka 350c4b1c2a do not submit invalid field value 2024-10-24 18:45:08 +03:00
Pete Matsyburka c3a933d87d fix fields detection 2024-10-24 17:56:02 +03:00
Pete Matsyburka 970e53a5a3 remove log 2024-10-24 12:30:14 +03:00
Pete Matsyburka 520bac2ec7 add decline account preferences toggle 2024-10-24 12:15:32 +03:00
Pete Matsyburka e1eddd31c4 fix field condition 2024-10-24 12:08:10 +03:00
Oleksandr Turchyn 3c616a824c show error for cross-environment API key usage 2024-10-24 12:07:58 +03:00
Pete Matsyburka 90d86b58c2 adjust migration 2024-10-23 22:57:33 +03:00
Pete Matsyburka 4332b4ebb8 fix specs 2024-10-23 12:01:55 +03:00
Oleksandr Turchyn e2f930f6f7 add completed submitters and documents 2024-10-23 12:01:43 +03:00
Pete Matsyburka c303159c63 adjust email interceptor 2024-10-23 10:46:53 +03:00
Pete Matsyburka c1ce2bec00 fix update name 2024-10-23 10:45:30 +03:00
Oleksandr Turchyn 3d2046f227 add validation for submitters count 2024-10-22 22:27:11 +03:00
Pete Matsyburka babaf1aa48 adjust error message 2024-10-22 18:18:14 +03:00
Pete Matsyburka 3f03d343ca Revert "dry run email validation"
This reverts commit 910490528d.
2024-10-22 17:41:25 +03:00
Pete Matsyburka 110cdb5123 fix specs 2024-10-22 15:56:59 +03:00
Pete Matsyburka 440d1114be fix email validation 2024-10-22 09:58:07 +03:00
Pete Matsyburka 9032517d4e smtp settings 2024-10-21 23:40:56 +03:00
Pete Matsyburka 7c71fefc47 optimize query 2024-10-21 14:34:11 +03:00
Pete Matsyburka e8bd8ff76f fix cdn url 2024-10-21 12:33:36 +03:00
Pete Matsyburka fed420cae1 Merge pull request #39 from docusealco/wip 2024-10-21 12:15:46 +03:00
Pete Matsyburka 910490528d dry run email validation 2024-10-20 15:44:24 +03:00
Alex Turchyn ab810be250 validate email format 2024-10-20 15:42:20 +03:00
Alex Turchyn 178ee47093 add more specs 2024-10-20 15:26:14 +03:00
Alex Turchyn 6f90147a11 validate the uniqueness of submitter roles 2024-10-20 15:26:04 +03:00
Pete Matsyburka 3ebaacbb7a locale date format 2024-10-20 09:48:39 +03:00
Pete Matsyburka 64b39b53a2 fix com from 2024-10-19 10:09:53 +03:00
Pete Matsyburka 2948910c25 fix com selector 2024-10-19 09:41:13 +03:00
Pete Matsyburka 1d20c79cf4 com host 2024-10-18 23:15:55 +03:00
Pete Matsyburka 48f3b13f3e adjust smtp from 2024-10-18 23:07:40 +03:00
Pete Matsyburka 1fc1e9af16 fix font size 2024-10-18 17:48:31 +03:00
Pete Matsyburka ce70016603 update rails 2024-10-16 23:58:37 +03:00
Pete Matsyburka e0e79e343d update email 2024-10-16 12:16:20 +03:00
Pete Matsyburka 6568f81ee9 adjust pdf generation 2024-10-15 20:58:08 +03:00
Oleksandr Turchyn 386f130d7a allow to paste date in date field 2024-10-15 19:31:25 +03:00
Pete Matsyburka 3ee41975fd add documents preview endpoint 2024-10-15 19:31:24 +03:00
Pete Matsyburka d9f314898b with style false 2024-10-14 12:28:26 +03:00
Pete Matsyburka 4fc7412cac fix ios 18 heif images 2024-10-14 12:28:26 +03:00
Pete Matsyburka 9adbfc52c2 fix signing date format 2024-10-14 12:28:26 +03:00
Pete Matsyburka 1ad8d9196a optimize phone step 2024-10-14 12:28:26 +03:00
Oleksandr Turchyn e9172392b5 add the phone code selector 2024-10-14 12:28:26 +03:00
Pete Matsyburka d6d94de328 fix submit form step 2024-10-14 12:28:26 +03:00
Alex Turchyn 77261f457c fix selector colors 2024-10-14 12:28:26 +03:00
Pete Matsyburka c258915031 fix z index 2024-10-14 12:28:26 +03:00
Pete Matsyburka c1db8bdd3b rename job 2024-10-14 12:28:26 +03:00
Pete Matsyburka c700484ce8 add reuse signature config 2024-10-14 12:28:26 +03:00
Pete Matsyburka b62bd200ed fix layout 2024-10-14 12:28:26 +03:00
Pete Matsyburka a164fe8dd8 fix sign 2024-10-14 12:28:26 +03:00
Pete Matsyburka bb40e872b3 fix custom message 2024-10-14 12:28:26 +03:00
Pete Matsyburka bab80e9488 fix image preview format 2024-10-14 12:28:26 +03:00
Pete Matsyburka d090d79ebf process images async 2024-10-14 12:28:26 +03:00
Pete Matsyburka 2299ba4e29 fix clear button 2024-10-14 12:28:26 +03:00
Pete Matsyburka d6474f0e6e adjust submitter edit 2024-10-14 12:28:26 +03:00
Oleksandr Turchyn e0a297cf81 add ability to edit submitter details 2024-10-14 12:28:26 +03:00
Pete Matsyburka 40f5fa4217 add recurrent payment 2024-10-14 12:28:26 +03:00
99 changed files with 3223 additions and 405 deletions

No files matched your search

+1 -1
View File
@@ -66,7 +66,7 @@ RSpec/ExampleLength:
Max: 40 Max: 40
RSpec/MultipleMemoizedHelpers: RSpec/MultipleMemoizedHelpers:
Max: 6 Max: 9
Metrics/BlockNesting: Metrics/BlockNesting:
Max: 4 Max: 4
+2 -2
View File
@@ -41,7 +41,7 @@ ENV OPENSSL_CONF=/app/openssl_legacy.cnf
WORKDIR /app WORKDIR /app
RUN apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils redis vips-heif gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf RUN echo '@edge https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils redis libheif@edge vips-heif gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
RUN echo $'.include = /etc/ssl/openssl.cnf\n\ RUN echo $'.include = /etc/ssl/openssl.cnf\n\
\n\ \n\
@@ -57,7 +57,7 @@ activate = 1' >> /app/openssl_legacy.cnf
COPY ./Gemfile ./Gemfile.lock ./ COPY ./Gemfile ./Gemfile.lock ./
RUN apk add --no-cache build-base && bundle install && apk del build-base && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf RUN apk add --no-cache build-base && bundle install && apk del --no-cache build-base && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf
COPY ./bin ./bin COPY ./bin ./bin
COPY ./app ./app COPY ./app ./app
+56 -56
View File
@@ -1,29 +1,29 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (7.2.1) actioncable (7.2.1.1)
actionpack (= 7.2.1) actionpack (= 7.2.1.1)
activesupport (= 7.2.1) activesupport (= 7.2.1.1)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
actionmailbox (7.2.1) actionmailbox (7.2.1.1)
actionpack (= 7.2.1) actionpack (= 7.2.1.1)
activejob (= 7.2.1) activejob (= 7.2.1.1)
activerecord (= 7.2.1) activerecord (= 7.2.1.1)
activestorage (= 7.2.1) activestorage (= 7.2.1.1)
activesupport (= 7.2.1) activesupport (= 7.2.1.1)
mail (>= 2.8.0) mail (>= 2.8.0)
actionmailer (7.2.1) actionmailer (7.2.1.1)
actionpack (= 7.2.1) actionpack (= 7.2.1.1)
actionview (= 7.2.1) actionview (= 7.2.1.1)
activejob (= 7.2.1) activejob (= 7.2.1.1)
activesupport (= 7.2.1) activesupport (= 7.2.1.1)
mail (>= 2.8.0) mail (>= 2.8.0)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
actionpack (7.2.1) actionpack (7.2.1.1)
actionview (= 7.2.1) actionview (= 7.2.1.1)
activesupport (= 7.2.1) activesupport (= 7.2.1.1)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
racc racc
rack (>= 2.2.4, < 3.2) rack (>= 2.2.4, < 3.2)
@@ -32,35 +32,35 @@ GEM
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
useragent (~> 0.16) useragent (~> 0.16)
actiontext (7.2.1) actiontext (7.2.1.1)
actionpack (= 7.2.1) actionpack (= 7.2.1.1)
activerecord (= 7.2.1) activerecord (= 7.2.1.1)
activestorage (= 7.2.1) activestorage (= 7.2.1.1)
activesupport (= 7.2.1) activesupport (= 7.2.1.1)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (7.2.1) actionview (7.2.1.1)
activesupport (= 7.2.1) activesupport (= 7.2.1.1)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
activejob (7.2.1) activejob (7.2.1.1)
activesupport (= 7.2.1) activesupport (= 7.2.1.1)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (7.2.1) activemodel (7.2.1.1)
activesupport (= 7.2.1) activesupport (= 7.2.1.1)
activerecord (7.2.1) activerecord (7.2.1.1)
activemodel (= 7.2.1) activemodel (= 7.2.1.1)
activesupport (= 7.2.1) activesupport (= 7.2.1.1)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activestorage (7.2.1) activestorage (7.2.1.1)
actionpack (= 7.2.1) actionpack (= 7.2.1.1)
activejob (= 7.2.1) activejob (= 7.2.1.1)
activerecord (= 7.2.1) activerecord (= 7.2.1.1)
activesupport (= 7.2.1) activesupport (= 7.2.1.1)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (7.2.1) activesupport (7.2.1.1)
base64 base64
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1) concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -320,7 +320,7 @@ GEM
mysql2 (0.5.6) mysql2 (0.5.6)
net-http-persistent (4.0.4) net-http-persistent (4.0.4)
connection_pool (~> 2.2) connection_pool (~> 2.2)
net-imap (0.4.16) net-imap (0.4.17)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@@ -372,7 +372,7 @@ GEM
puma (6.4.3) puma (6.4.3)
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.8.1) racc (1.8.1)
rack (3.1.7) rack (3.1.8)
rack-proxy (0.7.7) rack-proxy (0.7.7)
rack rack
rack-session (2.0.0) rack-session (2.0.0)
@@ -382,20 +382,20 @@ GEM
rackup (2.1.0) rackup (2.1.0)
rack (>= 3) rack (>= 3)
webrick (~> 1.8) webrick (~> 1.8)
rails (7.2.1) rails (7.2.1.1)
actioncable (= 7.2.1) actioncable (= 7.2.1.1)
actionmailbox (= 7.2.1) actionmailbox (= 7.2.1.1)
actionmailer (= 7.2.1) actionmailer (= 7.2.1.1)
actionpack (= 7.2.1) actionpack (= 7.2.1.1)
actiontext (= 7.2.1) actiontext (= 7.2.1.1)
actionview (= 7.2.1) actionview (= 7.2.1.1)
activejob (= 7.2.1) activejob (= 7.2.1.1)
activemodel (= 7.2.1) activemodel (= 7.2.1.1)
activerecord (= 7.2.1) activerecord (= 7.2.1.1)
activestorage (= 7.2.1) activestorage (= 7.2.1.1)
activesupport (= 7.2.1) activesupport (= 7.2.1.1)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 7.2.1) railties (= 7.2.1.1)
rails-dom-testing (2.2.0) rails-dom-testing (2.2.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
@@ -410,9 +410,9 @@ GEM
actionview (> 3.1) actionview (> 3.1)
activesupport (> 3.1) activesupport (> 3.1)
railties (> 3.1) railties (> 3.1)
railties (7.2.1) railties (7.2.1.1)
actionpack (= 7.2.1) actionpack (= 7.2.1.1)
activesupport (= 7.2.1) activesupport (= 7.2.1.1)
irb (~> 1.13) irb (~> 1.13)
rackup (>= 1.0.0) rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
@@ -559,7 +559,7 @@ GEM
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
zeitwerk (2.6.18) zeitwerk (2.7.0)
PLATFORMS PLATFORMS
aarch64-linux aarch64-linux
+3 -3
View File
@@ -25,12 +25,12 @@
DocuSeal is an open source platform that provides secure and efficient digital document signing and processing. Create PDF forms to have them filled and signed online on any device with an easy-to-use, mobile-optimized web tool. DocuSeal is an open source platform that provides secure and efficient digital document signing and processing. Create PDF forms to have them filled and signed online on any device with an easy-to-use, mobile-optimized web tool.
</p> </p>
<h2 align="center"> <h2 align="center">
<a href="https://demo.docuseal.co">✨ Live Demo</a> <a href="https://demo.docuseal.tech">✨ Live Demo</a>
<span>|</span> <span>|</span>
<a href="https://docuseal.co/sign_up">☁️ Try in Cloud</a> <a href="https://docuseal.com/sign_up">☁️ Try in Cloud</a>
</h2> </h2>
[![Demo](https://github.com/docusealco/docuseal/assets/5418788/d8703ea3-361a-423f-8bfe-eff1bd9dbe14)](https://demo.docuseal.co) [![Demo](https://github.com/docusealco/docuseal/assets/5418788/d8703ea3-361a-423f-8bfe-eff1bd9dbe14)](https://demo.docuseal.tech)
## Features ## Features
- PDF form fields builder (WYSIWYG) - PDF form fields builder (WYSIWYG)
+1 -1
View File
@@ -1,6 +1,6 @@
# Reporting a Vulnerability # Reporting a Vulnerability
If you come across any security concern or vulnarability, please report the information via email to security@docuseal.co instead of opening a GitHub issue. We will promptly respond and will collaborate with you to validate the issue, and resolve it ASAP. If you come across any security concern or vulnarability, please report the information via email to security@docuseal.com instead of opening a GitHub issue. We will promptly respond and will collaborate with you to validate the issue, and resolve it ASAP.
**We have a bug bounty program to reward security researchers.** **We have a bug bounty program to reward security researchers.**
@@ -8,6 +8,7 @@ class AccountConfigsController < ApplicationController
AccountConfig::ALLOW_TYPED_SIGNATURE, AccountConfig::ALLOW_TYPED_SIGNATURE,
AccountConfig::FORCE_MFA, AccountConfig::FORCE_MFA,
AccountConfig::ALLOW_TO_RESUBMIT, AccountConfig::ALLOW_TO_RESUBMIT,
AccountConfig::ALLOW_TO_DECLINE_KEY,
AccountConfig::FORM_PREFILL_SIGNATURE_KEY, AccountConfig::FORM_PREFILL_SIGNATURE_KEY,
AccountConfig::ESIGNING_PREFERENCE_KEY, AccountConfig::ESIGNING_PREFERENCE_KEY,
AccountConfig::FORM_WITH_CONFETTI_KEY, AccountConfig::FORM_WITH_CONFETTI_KEY,
+29 -2
View File
@@ -25,9 +25,9 @@ module Api
render json: { error: 'Too many requests' }, status: :too_many_requests render json: { error: 'Too many requests' }, status: :too_many_requests
end end
if Rails.env.production? unless Rails.env.development?
rescue_from CanCan::AccessDenied do |e| rescue_from CanCan::AccessDenied do |e|
render json: { error: e.message }, status: :forbidden render json: { error: access_denied_error_message(e) }, status: :forbidden
end end
rescue_from JSON::ParserError do |e| rescue_from JSON::ParserError do |e|
@@ -39,6 +39,33 @@ module Api
private private
def access_denied_error_message(error)
return 'Not authorized' if request.headers['X-Auth-Token'].blank?
return 'Not authorized' unless error.subject.is_a?(ActiveRecord::Base)
return 'Not authorized' unless error.subject.respond_to?(:account_id)
linked_account_record_exists =
if current_user.account.testing?
current_user.account.linked_account_accounts.where(account_type: 'testing')
.exists?(account_id: error.subject.account_id)
else
current_user.account.testing_accounts.exists?(id: error.subject.account_id)
end
return 'Not authorized' unless linked_account_record_exists
object_name = error.subject.model_name.human
id = error.subject.id
if current_user.account.testing?
"#{object_name} #{id} not found using testing API key; Use production API key to " \
"access production #{object_name.downcase.pluralize}."
else
"#{object_name} #{id} not found using production API key; Use testing API key to " \
"access testing #{object_name.downcase.pluralize}."
end
end
def paginate(relation, field: :id) def paginate(relation, field: :id)
result = relation.order(field => :desc) result = relation.order(field => :desc)
.limit([params.fetch(:limit, DEFAULT_LIMIT).to_i, MAX_LIMIT].min) .limit([params.fetch(:limit, DEFAULT_LIMIT).to_i, MAX_LIMIT].min)
@@ -0,0 +1,45 @@
# frozen_string_literal: true
module Api
class SubmissionDocumentsController < ApiBaseController
load_and_authorize_resource :submission
def index
documents =
if @submission.submitters.all?(&:completed_at?)
last_submitter = @submission.submitters.max_by(&:completed_at)
if last_submitter.documents_attachments.blank?
last_submitter.documents_attachments = Submissions::EnsureResultGenerated.call(submitter)
end
last_submitter.documents_attachments
else
values_hash = Submissions::GeneratePreviewAttachments.build_values_hash(@submission)
if @submission.preview_documents.present? &&
@submission.preview_documents.all? { |s| s.metadata['values_hash'] == values_hash }
@submission.preview_documents
else
ApplicationRecord.no_touching do
@submission.preview_documents.each(&:destroy)
end
Submissions::GeneratePreviewAttachments.call(@submission, values_hash:)
end
end
ActiveRecord::Associations::Preloader.new(
records: documents,
associations: [:blob]
).call
render json: {
id: @submission.id,
documents: documents.map do |attachment|
{ name: attachment.filename.base, url: ActiveStorage::Blob.proxy_url(attachment.blob) }
end
}
end
end
end
@@ -74,13 +74,14 @@ module Api
Submissions.send_signature_requests(submissions) Submissions.send_signature_requests(submissions)
submissions.each do |submission| submissions.each do |submission|
if submission.submitters.all?(&:completed_at?) && submission.submitters.last submission.submitters.each do |submitter|
ProcessSubmitterCompletionJob.perform_async({ 'submitter_id' => submission.submitters.last.id }) ProcessSubmitterCompletionJob.perform_async({ 'submitter_id' => submitter.id }) if submitter.completed_at?
end end
end end
render json: build_create_json(submissions) render json: build_create_json(submissions)
rescue Submitters::NormalizeValues::BaseError, DownloadUtils::UnableToDownload => e rescue Submitters::NormalizeValues::BaseError, Submissions::CreateFromSubmitters::BaseError,
DownloadUtils::UnableToDownload => e
Rollbar.warning(e) if defined?(Rollbar) Rollbar.warning(e) if defined?(Rollbar)
render json: { error: e.message }, status: :unprocessable_entity render json: { error: e.message }, status: :unprocessable_entity
@@ -120,6 +120,7 @@ module Api
end&.dig('uuid') end&.dig('uuid')
submitter.email = Submissions.normalize_email(attrs[:email]) if attrs.key?(:email) submitter.email = Submissions.normalize_email(attrs[:email]) if attrs.key?(:email)
submitter.name = attrs[:name] if attrs.key?(:name)
if attrs.key?(:phone) if attrs.key?(:phone)
submitter.phone = attrs[:phone].to_s.gsub(/[^0-9+]/, '') submitter.phone = attrs[:phone].to_s.gsub(/[^0-9+]/, '')
+1 -4
View File
@@ -20,10 +20,7 @@ module Api
pdf = HexaPDF::Document.new(io: StringIO.new(file)) pdf = HexaPDF::Document.new(io: StringIO.new(file))
trusted_certs = Accounts.load_trusted_certs(current_account) trusted_certs = Accounts.load_trusted_certs(current_account)
is_checksum_found = CompletedDocument.exists?(sha256: Base64.urlsafe_encode64(Digest::SHA256.digest(file)))
is_checksum_found = ActiveStorage::Attachment.joins(:blob)
.where(name: 'documents', record_type: 'Submitter')
.exists?(blob: { checksum: Digest::MD5.base64digest(file) })
render json: { render json: {
checksum_status: is_checksum_found ? 'verified' : 'not_found', checksum_status: is_checksum_found ? 'verified' : 'not_found',
@@ -38,6 +38,10 @@ class ApplicationController < ActionController::Base
end end
def default_url_options def default_url_options
if request.domain == 'docuseal.com'
return { host: 'docuseal.com', protocol: ENV['FORCE_SSL'].present? ? 'https' : 'http' }
end
Docuseal.default_url_options Docuseal.default_url_options
end end
@@ -9,7 +9,7 @@ class EmailSmtpSettingsController < ApplicationController
def create def create
if @encrypted_config.update(email_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'] || current_user.email).deliver_now!
redirect_to settings_email_index_path, notice: I18n.t('changes_have_been_saved') redirect_to settings_email_index_path, notice: I18n.t('changes_have_been_saved')
else else
@@ -12,7 +12,8 @@ class PreviewDocumentPageController < ActionController::API
return head :not_found unless attachment return head :not_found unless attachment
preview_image = attachment.preview_images.joins(:blob).find_by(blob: { filename: "#{params[:id]}#{FORMAT}" }) preview_image = attachment.preview_images.joins(:blob)
.find_by(blob: { filename: ["#{params[:id]}.png", "#{params[:id]}.jpg"] })
return redirect_to preview_image.url, allow_other_host: true if preview_image return redirect_to preview_image.url, allow_other_host: true if preview_image
@@ -9,6 +9,13 @@ class SubmissionsController < ApplicationController
def show def show
@submission = Submissions.preload_with_pages(@submission) @submission = Submissions.preload_with_pages(@submission)
unless @submission.submitters.all?(&:completed_at?)
ActiveRecord::Associations::Preloader.new(
records: [@submission],
associations: [submitters: :start_form_submission_events]
).call
end
render :show, layout: 'plain' render :show, layout: 'plain'
end end
@@ -15,6 +15,6 @@ class SubmissionsDashboardController < ApplicationController
@submissions = @submissions.pending if params[:status] == 'pending' @submissions = @submissions.pending if params[:status] == 'pending'
@submissions = @submissions.completed if params[:status] == 'completed' @submissions = @submissions.completed if params[:status] == 'completed'
@pagy, @submissions = pagy(@submissions.preload(:submitters).order(id: :desc)) @pagy, @submissions = pagy(@submissions.preload(submitters: :start_form_submission_events).order(id: :desc))
end end
end end
+80
View File
@@ -0,0 +1,80 @@
# frozen_string_literal: true
class SubmittersController < ApplicationController
load_and_authorize_resource :submitter, only: %i[edit update]
def edit
@submitter_email_message =
if @submitter.preferences['email_message_uuid'].present?
@submitter.account.email_messages.find_by(uuid: @submitter.preferences['email_message_uuid'])
end
end
def update
submission = @submitter.submission
if @submitter.submission_events.exists?(event_type: 'start_form') || submission.archived_at? || submission.expired?
return redirect_back fallback_location: submission_path(submission), alert: I18n.t('submitter_cannot_be_updated')
end
if submitter_params.values.all?(&:blank?)
return redirect_back fallback_location: submission_path(submission),
alert: I18n.t('at_least_one_field_must_be_filled')
end
if params[:is_custom_message] != '1'
params.delete(:subject)
params.delete(:body)
end
submitter_preferences = Submitters.normalize_preferences(@submitter.account, current_user, params)
if submitter_preferences.key?('email_message_uuid')
@submitter.preferences['email_message_uuid'] = submitter_preferences['email_message_uuid']
end
assign_submitter_attrs(@submitter, submitter_params)
if @submitter.save
maybe_resend_email_sms(@submitter, params)
redirect_back fallback_location: submission_path(submission), notice: I18n.t('changes_have_been_saved')
else
redirect_back fallback_location: submission_path(submission), alert: I18n.t('unable_to_save')
end
end
private
def maybe_resend_email_sms(submitter, params)
if params[:send_email] == '1' && submitter.email.present?
is_sent_recently = Docuseal.multitenant? &&
EmailEvent.exists?(email: submitter.email,
tag: 'submitter_invitation',
emailable: submitter,
event_type: 'send',
created_at: 4.hours.ago..Time.current)
SendSubmitterInvitationEmailJob.perform_async('submitter_id' => submitter.id) unless is_sent_recently
end
return if submitter.phone.blank?
return unless params[:send_sms] == '1'
SendSubmitterInvitationSmsJob.perform_async('submitter_id' => submitter.id)
end
def assign_submitter_attrs(submitter, attrs)
submitter.phone = attrs[:phone].to_s.gsub(/[^0-9+]/, '') if attrs.key?(:phone)
submitter.email = Submissions.normalize_email(attrs[:email]) if attrs.key?(:email)
submitter.name = attrs[:name] if attrs.key?(:name)
submitter
end
def submitter_params
params.require(:submitter).permit(:email, :name, :phone).transform_values(&:strip)
end
end
+1 -1
View File
@@ -15,7 +15,7 @@ class TemplatesController < ApplicationController
submissions = submissions.pending if params[:status] == 'pending' submissions = submissions.pending if params[:status] == 'pending'
submissions = submissions.completed if params[:status] == 'completed' submissions = submissions.completed if params[:status] == 'completed'
@pagy, @submissions = pagy(submissions.preload(:submitters).order(id: :desc)) @pagy, @submissions = pagy(submissions.preload(submitters: :start_form_submission_events).order(id: :desc))
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
redirect_to root_path redirect_to root_path
end end
+4 -2
View File
@@ -9,9 +9,11 @@ class UsersController < ApplicationController
def index def index
@users = @users =
if params[:status] == 'archived' if params[:status] == 'archived'
@users.archived @users.archived.where.not(role: 'integration')
elsif params[:status] == 'integration'
@users.active.where(role: 'integration')
else else
@users.active @users.active.where.not(role: 'integration')
end end
@pagy, @users = pagy(@users.where(account: current_account).order(id: :desc)) @pagy, @users = pagy(@users.where(account: current_account).order(id: :desc))
+2
View File
@@ -29,6 +29,7 @@ import SearchInput from './elements/search_input'
import ToggleAttribute from './elements/toggle_attribute' import ToggleAttribute from './elements/toggle_attribute'
import LinkedInput from './elements/linked_input' import LinkedInput from './elements/linked_input'
import CheckboxGroup from './elements/checkbox_group' import CheckboxGroup from './elements/checkbox_group'
import MaskedInput from './elements/masked_input'
import * as TurboInstantClick from './lib/turbo_instant_click' import * as TurboInstantClick from './lib/turbo_instant_click'
@@ -95,6 +96,7 @@ safeRegisterElement('search-input', SearchInput)
safeRegisterElement('toggle-attribute', ToggleAttribute) safeRegisterElement('toggle-attribute', ToggleAttribute)
safeRegisterElement('linked-input', LinkedInput) safeRegisterElement('linked-input', LinkedInput)
safeRegisterElement('checkbox-group', CheckboxGroup) safeRegisterElement('checkbox-group', CheckboxGroup)
safeRegisterElement('masked-input', MaskedInput)
safeRegisterElement('template-builder', class extends HTMLElement { safeRegisterElement('template-builder', class extends HTMLElement {
connectedCallback () { connectedCallback () {
-4
View File
@@ -130,7 +130,3 @@ button[disabled] .enabled {
outline-offset: 3px; outline-offset: 3px;
outline-color: hsl(var(--bc) / 0.2); outline-color: hsl(var(--bc) / 0.2);
} }
select:required:invalid {
color: gray !important;
}
+18
View File
@@ -0,0 +1,18 @@
export default class extends HTMLElement {
connectedCallback () {
const maskedToken = this.input.value
this.input.addEventListener('focus', () => {
this.input.value = this.dataset.token
this.input.select()
})
this.input.addEventListener('focusout', () => {
this.input.value = maskedToken
})
}
get input () {
return this.querySelector('input')
}
}
+1
View File
@@ -28,6 +28,7 @@ safeRegisterElement('submission-form', class extends HTMLElement {
requireSigningReason: this.dataset.requireSigningReason === 'true', requireSigningReason: this.dataset.requireSigningReason === 'true',
withConfetti: this.dataset.withConfetti !== 'false', withConfetti: this.dataset.withConfetti !== 'false',
withDisclosure: this.dataset.withDisclosure === 'true', withDisclosure: this.dataset.withDisclosure === 'true',
reuseSignature: this.dataset.reuseSignature !== 'false',
withTypedSignature: this.dataset.withTypedSignature !== 'false', withTypedSignature: this.dataset.withTypedSignature !== 'false',
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content, authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
rememberSignature: this.dataset.rememberSignature === 'true', rememberSignature: this.dataset.rememberSignature === 'true',
-4
View File
@@ -31,10 +31,6 @@ button[disabled] .enabled {
display: none; display: none;
} }
select:required:invalid {
@apply text-gray-300;
}
.input-bordered { .input-bordered {
@apply border-base-content/20; @apply border-base-content/20;
} }
+2 -1
View File
@@ -405,7 +405,8 @@ export default {
} }
if (this.field.preferences?.font_size) { if (this.field.preferences?.font_size) {
style.fontSize = this.field.preferences.font_size + 'pt' style.fontSize = `clamp(4pt, 1.6vw, ${this.field.preferences.font_size}pt)`
style.lineHeight = `clamp(6pt, 2.0vw, ${parseInt(this.field.preferences.font_size) + 3}pt)`
} }
return style return style
+1 -1
View File
@@ -76,7 +76,7 @@
</a> </a>
<a <a
v-if="isDemo" v-if="isDemo"
href="https://docuseal.co/sign_up" href="https://docuseal.com/sign_up"
class="white-button flex items-center space-x-1 w-full" class="white-button flex items-center space-x-1 w-full"
> >
<IconLogin /> <IconLogin />
@@ -45,6 +45,7 @@
:name="`values[${field.uuid}]`" :name="`values[${field.uuid}]`"
@keydown.enter="onEnter" @keydown.enter="onEnter"
@focus="$emit('focus')" @focus="$emit('focus')"
@paste="onPaste"
> >
</div> </div>
</div> </div>
@@ -98,6 +99,25 @@ export default {
this.$emit('submit') this.$emit('submit')
} }
}, },
onPaste (e) {
e.preventDefault()
let pasteData = e.clipboardData.getData('text').trim()
if (pasteData.match(/^\d{2}\.\d{2}\.\d{4}$/)) {
pasteData = pasteData.split('.').reverse().join('-')
}
const parsedDate = new Date(pasteData)
if (!isNaN(parsedDate)) {
const inputEl = this.$refs.input
inputEl.valueAsDate = new Date(parsedDate.getTime() - parsedDate.getTimezoneOffset() * 60000)
inputEl.dispatchEvent(new Event('input', { bubbles: true }))
}
},
setCurrentDate () { setCurrentDate () {
const inputEl = this.$refs.input const inputEl = this.$refs.input
+15 -6
View File
@@ -115,7 +115,7 @@
v-model="values[currentField.uuid]" v-model="values[currentField.uuid]"
:show-field-names="showFieldNames" :show-field-names="showFieldNames"
:field="currentField" :field="currentField"
@submit="submitStep" @submit="!isSubmitting && submitStep()"
@focus="scrollIntoField(currentField)" @focus="scrollIntoField(currentField)"
/> />
<div v-else-if="currentField.type === 'select'"> <div v-else-if="currentField.type === 'select'">
@@ -152,6 +152,7 @@
dir="auto" dir="auto"
:required="currentField.required" :required="currentField.required"
class="select base-input !text-2xl w-full text-center font-normal" class="select base-input !text-2xl w-full text-center font-normal"
:class="{ 'text-gray-300': !values[currentField.uuid] }"
:name="`values[${currentField.uuid}]`" :name="`values[${currentField.uuid}]`"
@change="values[currentField.uuid] = $event.target.value" @change="values[currentField.uuid] = $event.target.value"
@focus="scrollIntoField(currentField)" @focus="scrollIntoField(currentField)"
@@ -159,6 +160,7 @@
<option <option
value="" value=""
:selected="!values[currentField.uuid]" :selected="!values[currentField.uuid]"
class="text-gray-300"
> >
{{ t('select_your_option') }} {{ t('select_your_option') }}
</option> </option>
@@ -167,6 +169,7 @@
:key="option.uuid" :key="option.uuid"
:selected="values[currentField.uuid] == option.value" :selected="values[currentField.uuid] == option.value"
:value="option.value" :value="option.value"
class="text-base-content"
> >
{{ option.value }} {{ option.value }}
</option> </option>
@@ -392,7 +395,7 @@
:default-value="submitter.phone" :default-value="submitter.phone"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
@focus="scrollIntoField(currentField)" @focus="scrollIntoField(currentField)"
@submit="submitStep" @submit="!isSubmitting && submitStep()"
/> />
<PaymentStep <PaymentStep
v-else-if="currentField.type === 'payment'" v-else-if="currentField.type === 'payment'"
@@ -404,7 +407,7 @@
:values="values" :values="values"
@attached="attachments.push($event)" @attached="attachments.push($event)"
@focus="scrollIntoField(currentField)" @focus="scrollIntoField(currentField)"
@submit="submitStep" @submit="!isSubmitting && submitStep()"
/> />
</div> </div>
<div <div
@@ -1104,6 +1107,10 @@ export default {
const currentFieldUuids = this.currentStepFields.map((f) => f.uuid) const currentFieldUuids = this.currentStepFields.map((f) => f.uuid)
const currentFieldType = this.currentField.type const currentFieldType = this.currentField.type
if (!formData && !this.$refs.form.checkValidity()) {
return
}
if (this.dryRun) { if (this.dryRun) {
currentFieldUuids.forEach((fieldUuid) => { currentFieldUuids.forEach((fieldUuid) => {
this.submittedValues[fieldUuid] = this.values[fieldUuid] this.submittedValues[fieldUuid] = this.values[fieldUuid]
@@ -1142,13 +1149,15 @@ export default {
async submitStep () { async submitStep () {
this.isSubmitting = true this.isSubmitting = true
const submitStep = this.currentStep
const stepPromise = ['signature', 'phone', 'initials', 'payment'].includes(this.currentField.type) const stepPromise = ['signature', 'phone', 'initials', 'payment'].includes(this.currentField.type)
? this.$refs.currentStep.submit ? this.$refs.currentStep.submit
: () => Promise.resolve({}) : () => Promise.resolve({})
stepPromise().then(async () => { stepPromise().then(async () => {
const emptyRequiredField = this.stepFields.find((fields, index) => { const emptyRequiredField = this.stepFields.find((fields, index) => {
if (index >= this.currentStep) { if (index >= submitStep) {
return false return false
} }
@@ -1158,7 +1167,7 @@ export default {
}) })
const formData = new FormData(this.$refs.form) const formData = new FormData(this.$refs.form)
const isLastStep = this.currentStep === this.stepFields.length - 1 const isLastStep = submitStep === this.stepFields.length - 1
if (isLastStep && !emptyRequiredField && !this.inviteSubmitters.length) { if (isLastStep && !emptyRequiredField && !this.inviteSubmitters.length) {
formData.append('completed', 'true') formData.append('completed', 'true')
@@ -1183,7 +1192,7 @@ export default {
return Promise.reject(new Error(data.error)) return Promise.reject(new Error(data.error))
} }
const nextStep = (isLastStep && emptyRequiredField) || this.stepFields[this.currentStep + 1] const nextStep = (isLastStep && emptyRequiredField) || this.stepFields[submitStep + 1]
if (nextStep) { if (nextStep) {
if (this.alwaysMinimize) { if (this.alwaysMinimize) {
File diff suppressed because it is too large. Load diff
+106 -21
View File
@@ -61,30 +61,55 @@
</a> </a>
</div> </div>
</div> </div>
<input <div
v-show="!isCodeSent" v-show="!isCodeSent"
:id="field.uuid" class="flex w-full rounded-full outline-neutral-content outline-2 outline-offset-2 focus-within:outline"
ref="phone"
:value="modelValue || defaultValue"
:readonly="!!defaultValue"
class="base-input !text-2xl w-full"
autocomplete="tel"
pattern="^\+[0-9\s\-]+$"
type="tel"
inputmode="tel"
:required="field.required"
placeholder="+1 234 567-8900"
:name="`values[${field.uuid}]`"
@invalid="$event.target.value ? $event.target.setCustomValidity(`${t('use_international_format')}...`) : ''"
@input="[$event.target.setCustomValidity(''), $emit('update:model-value', $event.target.value)]"
@focus="$emit('focus')"
> >
<div class="relative inline-block">
<div class="btn bg-base-200 border border-neutral-300 text-2xl whitespace-nowrap font-normal rounded-l-full">
{{ selectedCountry.flag }} +{{ selectedCountry.dial }}
</div>
<select
class="absolute top-0 bottom-0 right-0 left-0 opacity-0 w-full h-full cursor-pointer"
:disabled="!!defaultValue"
@change="onCountrySelect(countries.find((country) => country.flag === $event.target.value))"
>
<option
v-for="(country, index) in countries"
:key="index"
:value="country.flag"
>
{{ country.flag }} {{ country.name }}
</option>
</select>
</div>
<input
:name="`values[${field.uuid}]`"
:value="fullInternationalPhoneValue"
hidden
>
<input
:id="field.uuid"
ref="phone"
:value="phoneValue"
:readonly="!!defaultValue"
class="base-input !text-2xl !rounded-l-none !border-l-0 !outline-none w-full"
autocomplete="tel"
type="tel"
inputmode="tel"
:required="field.required"
placeholder="234 567-8900"
@input="onPhoneInput"
@focus="$emit('focus')"
>
</div>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import MarkdownContent from './markdown_content' import MarkdownContent from './markdown_content'
import phoneData from './phone_data'
function throttle (func, delay) { function throttle (func, delay) {
let lastCallTime = 0 let lastCallTime = 0
@@ -144,13 +169,73 @@ export default {
data () { data () {
return { return {
isCodeSent: false, isCodeSent: false,
isResendLoading: false isResendLoading: false,
phoneValue: this.modelValue || this.defaultValue || '',
selectedCountry: {}
}
},
computed: {
countries () {
return phoneData.map(([name, dial, flag, tz]) => {
return { name, dial, flag, tz }
})
},
countriesDialIndex () {
return this.countries.reduce((acc, item) => {
acc[item.dial] ||= item
return acc
}, {})
},
dialCodesRegexp () {
const dialCodes = this.countries.map((country) => country.dial).sort((a, b) => b.length - a.length)
return new RegExp(`^\\+(${dialCodes.join('|')})`)
},
detectedPhoneValueDialCode () {
return (this.phoneValue || '').replace(/[^\d+]/g, '').match(this.dialCodesRegexp)?.[1]
},
fullInternationalPhoneValue () {
if (this.detectedPhoneValueDialCode) {
return this.phoneValue
} else if (this.phoneValue) {
return ['+', this.selectedCountry.dial, this.phoneValue].filter(Boolean).join('')
} else {
return ''
}
}
},
mounted () {
const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
if (this.detectedPhoneValueDialCode) {
this.selectedCountry = this.countriesDialIndex[this.detectedPhoneValueDialCode]
} else if (browserTimeZone) {
const tz = browserTimeZone.split('/')[1]
this.selectedCountry = this.countries.find((country) => country.tz.includes(tz)) || this.countries[0]
} }
}, },
methods: { methods: {
emitSubmit: throttle(function (e) { emitSubmit: throttle(function (e) {
this.$emit('submit') this.$emit('submit')
}, 1000), }, 1000),
onCountrySelect (country) {
if (this.selectedCountry.flag !== country.flag) {
this.phoneValue = this.phoneValue.replace(`+${this.selectedCountry.dial}`, `+${country.dial}`)
}
this.selectedCountry = country
this.$refs.phone.focus()
},
onPhoneInput (e) {
this.phoneValue = e.target.value
if (this.detectedPhoneValueDialCode) {
this.selectedCountry = this.countriesDialIndex[this.detectedPhoneValueDialCode]
}
},
onInputCode (e) { onInputCode (e) {
if (e.target.value.length === 6) { if (e.target.value.length === 6) {
this.emitSubmit() this.emitSubmit()
@@ -171,14 +256,14 @@ export default {
body: JSON.stringify({ body: JSON.stringify({
submitter_slug: this.submitterSlug, submitter_slug: this.submitterSlug,
locale: this.locale, locale: this.locale,
phone: this.$refs.phone.value phone: this.fullInternationalPhoneValue
}), }),
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}).then(async (resp) => { }).then(async (resp) => {
if (resp.status === 422) { if (resp.status === 422) {
const data = await resp.json() const data = await resp.json()
alert(this.t('number_phone_is_invalid').replace('{number}', this.$refs.phone.value)) alert(this.t('number_phone_is_invalid').replace('{number}', this.fullInternationalPhoneValue))
return Promise.reject(new Error(data.error)) return Promise.reject(new Error(data.error))
} }
@@ -189,13 +274,13 @@ export default {
return Promise.resolve({}) return Promise.resolve({})
} }
if (!this.$refs.phone.value.toString().startsWith('+')) { if (!this.fullInternationalPhoneValue.toString().startsWith('+')) {
alert(this.t('use_international_format')) alert(this.t('use_international_format'))
return Promise.reject(new Error('phone invalid')) return Promise.reject(new Error('phone invalid'))
} else if (!this.isCodeSent) { } else if (!this.isCodeSent) {
return this.sendVerificationCode().then(() => { return this.sendVerificationCode().then(() => {
this.$emit('update:model-value', this.$refs.phone.value) this.$emit('update:model-value', this.fullInternationalPhoneValue)
this.isCodeSent = true this.isCodeSent = true
@@ -197,6 +197,7 @@
<select <select
v-if="requireSigningReason && !isOtherReason" v-if="requireSigningReason && !isOtherReason"
class="select base-input !text-2xl w-full mt-6 text-center" class="select base-input !text-2xl w-full mt-6 text-center"
:class="{ 'text-gray-300': !reason }"
required required
:name="`values[${field.preferences.reason_field_uuid}]`" :name="`values[${field.preferences.reason_field_uuid}]`"
@change="$event.target.value === 'other' ? [reason = '', isOtherReason = true] : $emit('update:reason', $event.target.value)" @change="$event.target.value === 'other' ? [reason = '', isOtherReason = true] : $emit('update:reason', $event.target.value)"
@@ -205,6 +206,7 @@
value="" value=""
disabled disabled
:selected="!reason" :selected="!reason"
class="text-gray-300"
> >
{{ t('select_a_reason') }} {{ t('select_a_reason') }}
</option> </option>
@@ -213,10 +215,14 @@
:key="option" :key="option"
:value="option" :value="option"
:selected="reason === option" :selected="reason === option"
class="text-base-content"
> >
{{ label }} {{ label }}
</option> </option>
<option value="other"> <option
value="other"
class="text-base-content"
>
{{ t('other') }} {{ t('other') }}
</option> </option>
</select> </select>
@@ -693,6 +693,7 @@ export default {
'de-DE': 'DD.MM.YYYY', 'de-DE': 'DD.MM.YYYY',
'fr-FR': 'DD/MM/YYYY', 'fr-FR': 'DD/MM/YYYY',
'it-IT': 'DD/MM/YYYY', 'it-IT': 'DD/MM/YYYY',
'en-GB': 'DD/MM/YYYY',
'es-ES': 'DD/MM/YYYY' 'es-ES': 'DD/MM/YYYY'
} }
}, },
@@ -50,6 +50,7 @@
</div> </div>
<select <select
class="select select-bordered select-sm w-full bg-white h-11 pl-4 text-base font-normal" class="select select-bordered select-sm w-full bg-white h-11 pl-4 text-base font-normal"
:class="{ 'text-gray-300': !condition.field_uuid }"
required required
@change="[ @change="[
condition.field_uuid = $event.target.value, condition.field_uuid = $event.target.value,
@@ -68,6 +69,7 @@
v-for="f in fields" v-for="f in fields"
:key="f.uuid" :key="f.uuid"
:value="f.uuid" :value="f.uuid"
class="text-base-content"
:selected="condition.field_uuid === f.uuid" :selected="condition.field_uuid === f.uuid"
> >
{{ f.name || buildDefaultName(f, template.fields) }} {{ f.name || buildDefaultName(f, template.fields) }}
@@ -88,8 +90,9 @@
</option> </option>
</select> </select>
<select <select
v-if="conditionField(condition)?.options?.length" v-if="['radio', 'select', 'multiple'].includes(conditionField(condition)?.type) && conditionField(condition)?.options"
class="select select-bordered select-sm w-full bg-white h-11 pl-4 text-base font-normal" class="select select-bordered select-sm w-full bg-white h-11 pl-4 text-base font-normal"
:class="{ 'text-gray-300': !condition.value }"
required required
@change="condition.value = $event.target.value" @change="condition.value = $event.target.value"
> >
@@ -105,6 +108,7 @@
:key="option.uuid" :key="option.uuid"
:value="option.uuid" :value="option.uuid"
:selected="condition.value === option.uuid" :selected="condition.value === option.uuid"
class="text-base-content"
> >
{{ option.value || `${t('option')} ${index + 1}` }} {{ option.value || `${t('option')} ${index + 1}` }}
</option> </option>
+12
View File
@@ -1,5 +1,7 @@
const en = { const en = {
editable: 'Editable', editable: 'Editable',
recurrent: 'Recurrent',
one_off: 'One-off',
search_field: 'Search field', search_field: 'Search field',
field_not_found: 'Field not found', field_not_found: 'Field not found',
clear: 'Clear', clear: 'Clear',
@@ -151,6 +153,8 @@ const en = {
} }
const es = { const es = {
recurrent: 'Recurrente',
one_off: 'Único',
editable: 'Editable', editable: 'Editable',
search_field: 'Campo de búsqueda', search_field: 'Campo de búsqueda',
field_not_found: 'Campo no encontrado', field_not_found: 'Campo no encontrado',
@@ -303,6 +307,8 @@ const es = {
} }
const it = { const it = {
ricorrente: 'Ricorrente',
una_volta: 'Una volta',
editable: 'Modificabile', editable: 'Modificabile',
search_field: 'Campo di ricerca', search_field: 'Campo di ricerca',
field_not_found: 'Campo non trovato', field_not_found: 'Campo non trovato',
@@ -455,6 +461,8 @@ const it = {
} }
const pt = { const pt = {
recurrent: 'Recurrente',
one_off: 'Único',
editable: 'Editável', editable: 'Editável',
search_field: 'Campo de busca', search_field: 'Campo de busca',
field_not_found: 'Campo não encontrado', field_not_found: 'Campo não encontrado',
@@ -607,6 +615,8 @@ const pt = {
} }
const fr = { const fr = {
recurrent: 'Récurrent',
one_off: 'Ponctuel',
editable: 'Éditable', editable: 'Éditable',
search_field: 'Champ de recherche', search_field: 'Champ de recherche',
field_not_found: 'Champ non trouvé', field_not_found: 'Champ non trouvé',
@@ -759,6 +769,8 @@ const fr = {
} }
const de = { const de = {
wiederkehrend: 'Wiederkehrend',
einmalig: 'Einmalig',
editable: 'Bearbeitbar', editable: 'Bearbeitbar',
search_field: 'Suchfeld', search_field: 'Suchfeld',
field_not_found: 'Feld nicht gefunden', field_not_found: 'Feld nicht gefunden',
@@ -50,6 +50,7 @@
<div class="flex"> <div class="flex">
<select <select
class="base-select !select-sm !h-10" class="base-select !select-sm !h-10"
:class="{ '!text-gray-300': !mapping.field_name }"
required required
@change="mapping.field_name = $event.target.value" @change="mapping.field_name = $event.target.value"
> >
@@ -65,6 +66,7 @@
:key="index" :key="index"
:value="field.name" :value="field.name"
:selected="mapping.field_name === field.name" :selected="mapping.field_name === field.name"
class="text-base-content"
> >
{{ field.name }} {{ field.name }}
</option> </option>
@@ -75,6 +77,7 @@
<div class="w-full relative"> <div class="w-full relative">
<select <select
class="base-select !select-sm !h-10" class="base-select !select-sm !h-10"
:class="{ '!text-gray-300': !mapping.column_index }"
required required
@change="mapping.column_index = parseInt($event.target.value)" @change="mapping.column_index = parseInt($event.target.value)"
> >
@@ -93,6 +96,7 @@
v-if="column" v-if="column"
:value="index" :value="index"
:selected="index === mapping.column_index" :selected="index === mapping.column_index"
class="text-base-content"
> >
{{ column }} {{ column }}
</option> </option>
@@ -21,6 +21,7 @@
@click="closeDropdown" @click="closeDropdown"
> >
<div <div
v-if="!('price_id' in field.preferences)"
class="py-1.5 px-1 relative" class="py-1.5 px-1 relative"
@click.stop @click.stop
> >
@@ -58,6 +59,13 @@
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0" class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0"
@blur="save" @blur="save"
> >
<input
v-else-if="'price_id' in field.preferences"
v-model="field.preferences.price_id"
placeholder="Price ID: price_XXXXX"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0"
@blur="save"
>
<input <input
v-else v-else
v-model="field.preferences.price" v-model="field.preferences.price"
@@ -74,6 +82,23 @@
> >
{{ t('price') }} {{ t('price') }}
</label> </label>
<div class="flex items-center justify-center">
<a
href="#"
class="hover:underline"
style="font-size: 11px"
:class="{'underline': !('price_id' in field.preferences)}"
@click="delete field.preferences.price_id"
>{{ t('one_off') }}</a>
<span class="h-2.5 border-l border-base-content mx-1" />
<a
href="#"
class="hover:underline"
style="font-size: 11px"
:class="{'underline': ('price_id' in field.preferences)}"
@click="field.preferences.price_id ??= ''"
>{{ t('recurrent') }}</a>
</div>
</div> </div>
<div <div
v-if="!isConnected || isOauthSuccess" v-if="!isConnected || isOauthSuccess"
@@ -158,7 +183,10 @@
data-turbo="false" data-turbo="false"
>{{ t('learn_more') }}</a> >{{ t('learn_more') }}</a>
</div> </div>
<li class="mb-1"> <li
v-if="!('price_id' in field.preferences)"
class="mb-1"
>
<label <label
class="label-text cursor-pointer text-center w-full flex items-center" class="label-text cursor-pointer text-center w-full flex items-center"
@click="$emit('click-formula')" @click="$emit('click-formula')"
+17
View File
@@ -0,0 +1,17 @@
# frozen_string_literal: true
class GeneratePreviewImagesJob
include Sidekiq::Job
sidekiq_options queue: :images
def perform(params = {})
attachment = ActiveStorage::Attachment.find(params['attachment_id'])
max_page = [attachment.metadata['pdf']['number_of_pages'].to_i - 1,
Templates::ProcessDocument::MAX_NUMBER_OF_PAGES_PROCESSED].min
Templates::ProcessDocument.generate_document_preview_images(attachment, attachment.download, (1..max_page),
concurrency: 1)
end
end
@@ -6,6 +6,8 @@ class ProcessSubmitterCompletionJob
def perform(params = {}) def perform(params = {})
submitter = Submitter.find(params['submitter_id']) submitter = Submitter.find(params['submitter_id'])
create_completed_submitter!(submitter)
is_all_completed = !submitter.submission.submitters.exists?(completed_at: nil) is_all_completed = !submitter.submission.submitters.exists?(completed_at: nil)
if !is_all_completed && submitter.submission.submitters_order_preserved? if !is_all_completed && submitter.submission.submitters_order_preserved?
@@ -24,9 +26,42 @@ class ProcessSubmitterCompletionJob
enqueue_completed_emails(submitter) enqueue_completed_emails(submitter)
end end
create_completed_documents!(submitter)
enqueue_completed_webhooks(submitter, is_all_completed:) enqueue_completed_webhooks(submitter, is_all_completed:)
end end
def create_completed_submitter!(submitter)
completed_submitter = CompletedSubmitter.find_or_initialize_by(submitter_id: submitter.id)
return completed_submitter if completed_submitter.persisted?
submission = submitter.submission
completed_submitter.assign_attributes(
submission_id: submitter.submission_id,
account_id: submission.account_id,
template_id: submission.template_id,
source: submission.source,
sms_count: submitter.submission_events.where(event_type: %w[send_sms send_2fa_sms]).count,
completed_at: submitter.completed_at
)
completed_submitter.save!
completed_submitter
rescue ActiveRecord::RecordNotUnique
retry
end
def create_completed_documents!(submitter)
submitter.documents.filter_map do |attachment|
next if attachment.metadata['sha256'].blank?
CompletedDocument.find_or_create_by!(sha256: attachment.metadata['sha256'], submitter_id: submitter.id)
end
end
def enqueue_completed_webhooks(submitter, is_all_completed: false) def enqueue_completed_webhooks(submitter, is_all_completed: false)
webhook_config = Accounts.load_webhook_config(submitter.account) webhook_config = Accounts.load_webhook_config(submitter.account)
+1 -1
View File
@@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class ApplicationMailer < ActionMailer::Base class ApplicationMailer < ActionMailer::Base
default from: 'DocuSeal <info@docuseal.co>' default from: 'DocuSeal <info@docuseal.com>'
layout 'mailer' layout 'mailer'
register_interceptor ActionMailerConfigsInterceptor register_interceptor ActionMailerConfigsInterceptor
+1 -1
View File
@@ -2,6 +2,6 @@
class SettingsMailer < ApplicationMailer class SettingsMailer < ApplicationMailer
def smtp_successful_setup(email) def smtp_successful_setup(email)
mail(to: email, subject: 'SMTP has been configured') mail(to: email, from: email, subject: 'SMTP has been configured')
end end
end end
+2
View File
@@ -28,6 +28,7 @@ class AccountConfig < ApplicationRecord
FORCE_MFA = 'force_mfa' FORCE_MFA = 'force_mfa'
ALLOW_TYPED_SIGNATURE = 'allow_typed_signature' ALLOW_TYPED_SIGNATURE = 'allow_typed_signature'
ALLOW_TO_RESUBMIT = 'allow_to_resubmit' ALLOW_TO_RESUBMIT = 'allow_to_resubmit'
ALLOW_TO_DECLINE_KEY = 'allow_to_decline'
SUBMITTER_REMINDERS = 'submitter_reminders' SUBMITTER_REMINDERS = 'submitter_reminders'
FORM_COMPLETED_BUTTON_KEY = 'form_completed_button' FORM_COMPLETED_BUTTON_KEY = 'form_completed_button'
FORM_COMPLETED_MESSAGE_KEY = 'form_completed_message' FORM_COMPLETED_MESSAGE_KEY = 'form_completed_message'
@@ -41,6 +42,7 @@ class AccountConfig < ApplicationRecord
WITH_SIGNATURE_ID = 'with_signature_id' WITH_SIGNATURE_ID = 'with_signature_id'
WITH_AUDIT_VALUES_KEY = 'with_audit_values' WITH_AUDIT_VALUES_KEY = 'with_audit_values'
REQUIRE_SIGNING_REASON_KEY = 'require_signing_reason' REQUIRE_SIGNING_REASON_KEY = 'require_signing_reason'
REUSE_SIGNATURE_KEY = 'reuse_signature'
COMBINE_PDF_RESULT_KEY = 'combine_pdf_result_key' COMBINE_PDF_RESULT_KEY = 'combine_pdf_result_key'
DEFAULT_VALUES = { DEFAULT_VALUES = {
+22
View File
@@ -0,0 +1,22 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: completed_documents
#
# id :bigint not null, primary key
# sha256 :string not null
# created_at :datetime not null
# updated_at :datetime not null
# submitter_id :bigint not null
#
# Indexes
#
# index_completed_documents_on_sha256 (sha256)
# index_completed_documents_on_submitter_id (submitter_id)
#
class CompletedDocument < ApplicationRecord
belongs_to :submitter, optional: true
has_one :completed_submitter, primary_key: :submitter_id, inverse_of: :completed_documents, dependent: :destroy
end
+33
View File
@@ -0,0 +1,33 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: completed_submitters
#
# id :bigint not null, primary key
# completed_at :datetime not null
# sms_count :integer not null
# source :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# submission_id :bigint not null
# submitter_id :bigint not null
# template_id :bigint not null
#
# Indexes
#
# index_completed_submitters_on_account_id (account_id)
# index_completed_submitters_on_submitter_id (submitter_id) UNIQUE
#
class CompletedSubmitter < ApplicationRecord
belongs_to :submitter
belongs_to :submission
belongs_to :account
belongs_to :template
has_many :completed_documents, dependent: :destroy,
primary_key: :submitter_id,
foreign_key: :submitter_id,
inverse_of: :submitter
end
+2
View File
@@ -55,6 +55,8 @@ class Submission < ApplicationRecord
has_one_attached :audit_trail has_one_attached :audit_trail
has_one_attached :combined_document has_one_attached :combined_document
has_many_attached :preview_documents
has_many :template_schema_documents, has_many :template_schema_documents,
->(e) { where(uuid: (e.template_schema.presence || e.template.schema).pluck('attachment_uuid')) }, ->(e) { where(uuid: (e.template_schema.presence || e.template.schema).pluck('attachment_uuid')) },
through: :template, source: :documents_attachments through: :template, source: :documents_attachments
+2
View File
@@ -56,6 +56,8 @@ class Submitter < ApplicationRecord
has_many :document_generation_events, dependent: :destroy has_many :document_generation_events, dependent: :destroy
has_many :submission_events, dependent: :destroy has_many :submission_events, dependent: :destroy
has_many :start_form_submission_events, -> { where(event_type: :start_form) },
class_name: 'SubmissionEvent', dependent: :destroy, inverse_of: :submitter
scope :completed, -> { where.not(completed_at: nil) } scope :completed, -> { where.not(completed_at: nil) }
+3
View File
@@ -49,6 +49,9 @@ class User < ApplicationRecord
EMAIL_REGEXP = /[^@;,<>\s]+@[^@;,<>\s]+/ EMAIL_REGEXP = /[^@;,<>\s]+@[^@;,<>\s]+/
FULL_EMAIL_REGEXP =
/\A[a-z0-9][\.']?(?:(?:[a-z0-9_-]+[\.\+'])*[a-z0-9_-]+)*@(?:[a-z0-9]+[\.-])*[a-z0-9]+\.[a-z]{2,}\z/i
has_one_attached :signature has_one_attached :signature
has_one_attached :initials has_one_attached :initials
+12
View File
@@ -103,6 +103,18 @@
</div> </div>
<% end %> <% end %>
<% end %> <% end %>
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::ALLOW_TO_DECLINE_KEY) %>
<% if can?(:manage, account_config) %>
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
<%= f.hidden_field :key %>
<div class="flex items-center justify-between py-2.5">
<span>
<%= t('allow_to_decline_documents') %>
</span>
<%= f.check_box :value, class: 'toggle', checked: account_config.value != false, onchange: 'this.form.requestSubmit()' %>
</div>
<% end %>
<% end %>
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::FORM_PREFILL_SIGNATURE_KEY) %> <% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::FORM_PREFILL_SIGNATURE_KEY) %>
<% if can?(:manage, account_config) %> <% if can?(:manage, account_config) %>
<%= form_for account_config, url: account_configs_path, method: :post do |f| %> <%= form_for account_config, url: account_configs_path, method: :post do |f| %>
+5 -2
View File
@@ -10,8 +10,11 @@
<label for="api_key" class="text-sm font-semibold">X-Auth-Token</label> <label for="api_key" class="text-sm font-semibold">X-Auth-Token</label>
<div class="flex flex-col md:flex-row gap-4"> <div class="flex flex-col md:flex-row gap-4">
<div class="flex w-full space-x-4"> <div class="flex w-full space-x-4">
<input id="api_key" type="text" value="<%= current_user.access_token.token %>" class="input font-mono input-bordered w-full" autocomplete="off" readonly> <% token = current_user.access_token.token %>
<%= render 'shared/clipboard_copy', icon: 'copy', text: current_user.access_token.token, class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %> <masked-input class="block w-full" data-token="<%= token %>">
<input id="api_key" type="text" value="<%= token.sub(token[5..], '*' * token[5..].size) %>" class="input font-mono input-bordered w-full" autocomplete="off" readonly>
</masked-input>
<%= render 'shared/clipboard_copy', icon: 'copy', text: token, class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
</div> </div>
<%= button_to button_title(title: t('rotate'), disabled_with: t('rotate'), icon: svg_icon('reload', class: 'w-6 h-6')), settings_api_index_path, class: 'white-button w-full', data: { turbo_confirm: t('remove_existing_api_token_and_generated_a_new_one_are_you_sure_') } %> <%= button_to button_title(title: t('rotate'), disabled_with: t('rotate'), icon: svg_icon('reload', class: 'w-6 h-6')), settings_api_index_path, class: 'white-button w-full', data: { turbo_confirm: t('remove_existing_api_token_and_generated_a_new_one_are_you_sure_') } %>
</div> </div>
@@ -1,10 +1,10 @@
<div class="text-center"> <div class="text-center">
<div class="join"> <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' %>"> <a href="https://docuseal.com<%= request.fullpath.gsub('docuseal.eu', 'docuseal.com') %>" class="btn bg-base-200 join-item w-32 <%= 'bg-base-300' if request.host == 'docuseal.com' %>">
<%= svg_icon 'world', class: 'w-5 h-5' %> <%= svg_icon 'world', class: 'w-5 h-5' %>
Global Global
</a> </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' %>"> <a href="https://docuseal.eu<%= request.fullpath.gsub('docuseal.com', 'docuseal.eu') %>" class="btn bg-base-200 join-item w-32 <%= 'bg-base-300' if request.host == 'docuseal.eu' %>">
<%= svg_icon 'eu_flag', class: 'w-5 h-5' %> <%= svg_icon 'eu_flag', class: 'w-5 h-5' %>
Europe Europe
</a> </a>
+1 -1
View File
@@ -48,7 +48,7 @@
</div> </div>
<div class="form-control"> <div class="form-control">
<%= ff.label :from_email, t('send_from_email'), class: 'label' %> <%= ff.label :from_email, t('send_from_email'), class: 'label' %>
<%= ff.email_field :from_email, value: value['from_email'], required: true, class: 'base-input' %> <%= ff.email_field :from_email, value: value['from_email'], required: !Docuseal.multitenant?, class: 'base-input' %>
</div> </div>
<% end %> <% end %>
<div class="form-control pt-2"> <div class="form-control pt-2">
+1 -1
View File
@@ -10,7 +10,7 @@
<% if signed_in? %> <% if signed_in? %>
<div class="space-x-4 flex items-center"> <div class="space-x-4 flex items-center">
<% if Docuseal.demo? %> <% if Docuseal.demo? %>
<a href="https://docuseal.co/sign_up" class="btn btn-neutral btn-sm btn-outline inline-flex items-center justify-center" style="height: 37px"> <a href="https://docuseal.com/sign_up" class="btn btn-neutral btn-sm btn-outline inline-flex items-center justify-center" style="height: 37px">
<%= t('sign_up') %> <%= t('sign_up') %>
</a> </a>
<span class="hidden sm:inline"> <span class="hidden sm:inline">
+1 -1
View File
@@ -1,6 +1,6 @@
<div class="text-center px-2"> <div class="text-center px-2">
<% if local_assigns[:with_counter] %> <% if local_assigns[:with_counter] %>
<% count = Submitter.where.not(completed_at: nil).distinct.count(:submission_id) %> <% count = CompletedSubmitter.distinct.count(:submission_id) %>
<% if count > 1 %> <% if count > 1 %>
<%= t('count_documents_signed_with_html', count:) %> <%= t('count_documents_signed_with_html', count:) %>
<% else %> <% else %>
@@ -31,7 +31,7 @@
</submitters-autocomplete> </submitters-autocomplete>
<submitters-autocomplete data-field="phone"> <submitters-autocomplete data-field="phone">
<linked-input data-target-id="<%= "detailed_phone_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>"> <linked-input data-target-id="<%= "detailed_phone_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
<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="base-input !h-10 mt-1.5 w-full" placeholder="<%= "#{t('phone')} (#{t('optional')})" %>" id="detailed_phone_<%= item['uuid'] %>"> <input type="tel" pattern="^\+[0-9\s\-]+$" oninvalid="this.value ? this.setCustomValidity('<%= t('use_international_format_1xxx_') %>') : ''" oninput="this.setCustomValidity('')" name="submission[1][submitters][][phone]" autocomplete="off" class="base-input !h-10 mt-1.5 w-full" placeholder="<%= "#{t('phone')} (#{t('optional')})" %>" id="detailed_phone_<%= item['uuid'] %>">
</linked-input> </linked-input>
</submitters-autocomplete> </submitters-autocomplete>
</div> </div>
+1 -1
View File
@@ -21,7 +21,7 @@
<input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>"> <input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>">
<submitters-autocomplete data-field="phone"> <submitters-autocomplete data-field="phone">
<linked-input data-target-id="<%= "phone_phone_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>"> <linked-input data-target-id="<%= "phone_phone_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
<%= tag.input type: 'tel', pattern: '^\+[0-9\s\-]+$', oninvalid: "this.value ? this.setCustomValidity('Use internatioanl format: +1xxx...') : ''", oninput: "this.setCustomValidity('')", name: 'submission[1][submitters][][phone]', autocomplete: 'off', class: 'base-input !h-10 w-full', placeholder: t('phone'), required: index.zero?, id: "phone_phone_#{item['uuid']}" %> <%= tag.input type: 'tel', pattern: '^\+[0-9\s\-]+$', oninvalid: "this.value ? this.setCustomValidity('#{t('use_international_format_1xxx_')}') : ''", oninput: "this.setCustomValidity('')", name: 'submission[1][submitters][][phone]', autocomplete: 'off', class: 'base-input !h-10 w-full', placeholder: t('phone'), required: index.zero?, id: "phone_phone_#{item['uuid']}" %>
</linked-input> </linked-input>
</submitters-autocomplete> </submitters-autocomplete>
<% if submitters.size > 1 %> <% if submitters.size > 1 %>
+12 -10
View File
@@ -2,12 +2,12 @@
<% can_send_emails = Accounts.can_send_emails?(current_account) %> <% can_send_emails = Accounts.can_send_emails?(current_account) %>
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<%= f.label :send_email, for: uuid = SecureRandom.uuid, class: 'flex items-center cursor-pointer' do %> <%= 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: !can_send_emails, checked: can_send_emails %> <%= f.check_box :send_email, id: uuid, class: 'base-checkbox', disabled: !can_send_emails, checked: can_send_emails && !local_assigns.key?(:resend_email) %>
<span class="label"><%= t('send_emails') %></span> <span class="label"><%= local_assigns[:resend_email] ? t('re_send_email') : t('send_email') %></span>
<% end %> <% end %>
<div> <div>
<% if can_send_emails %> <% if can_send_emails %>
<%= render 'email_stats' %> <%= render 'submissions/email_stats' %>
<%= content_for(:edit_button) || capture do %> <%= content_for(:edit_button) || capture do %>
<label> <label>
<%= f.check_box :is_custom_message, onchange: "[this.form.querySelector('#message_field').classList.toggle('hidden', !event.currentTarget.checked)]", checked: false, class: 'hidden peer' %> <%= f.check_box :is_custom_message, onchange: "[this.form.querySelector('#message_field').classList.toggle('hidden', !event.currentTarget.checked)]", checked: false, class: 'hidden peer' %>
@@ -39,7 +39,7 @@
<div class="form-control space-y-2"> <div class="form-control space-y-2">
<div class="form-control"> <div class="form-control">
<%= f.label :subject, t('subject'), class: 'label' %> <%= f.label :subject, t('subject'), class: 'label' %>
<%= f.text_field :subject, value: @template.preferences['request_email_subject'].presence || config.value['subject'], required: true, class: '!text-sm base-input w-full', dir: 'auto' %> <%= f.text_field :subject, value: local_assigns[:submitter_email_message]&.subject.presence || template.preferences['request_email_subject'].presence || config.value['subject'], required: true, class: '!text-sm base-input w-full', dir: 'auto' %>
</div> </div>
<div class="form-control"> <div class="form-control">
<div class="flex items-center"> <div class="flex items-center">
@@ -49,14 +49,16 @@
</span> </span>
</div> </div>
<autoresize-textarea> <autoresize-textarea>
<%= f.text_area :body, value: @template.preferences['request_email_body'].presence || config.value['body'], required: true, class: 'base-textarea w-full', rows: 10, dir: 'auto' %> <%= f.text_area :body, value: local_assigns[:submitter_email_message]&.body.presence || template.preferences['request_email_body'].presence || config.value['body'], required: true, class: 'base-textarea w-full', rows: 10, dir: 'auto' %>
</autoresize-textarea> </autoresize-textarea>
<label for="<%= uuid = SecureRandom.uuid %>" class="flex items-center cursor-pointer"> <% unless local_assigns.fetch(:disable_save_as_default_template_option, false) %>
<%= check_box_tag :save_message, id: uuid, class: 'base-checkbox', checked: false %> <label for="<%= uuid = SecureRandom.uuid %>" class="flex items-center cursor-pointer">
<span class="label"><%= t('save_as_default_template_message') %></span> <%= check_box_tag :save_message, id: uuid, class: 'base-checkbox', checked: false %>
</label> <span class="label"><%= t('save_as_default_template_message') %></span>
</label>
<% end %>
</div> </div>
<%= render 'message_fields' %> <%= render 'submissions/message_fields' %>
</div> </div>
</div> </div>
</div> </div>
+1 -1
View File
@@ -1,5 +1,5 @@
<% align = field.dig('preferences', 'align') %> <% align = field.dig('preferences', 'align') %>
<field-value dir="auto" class="flex absolute text-[1.6vw] lg:text-base <%= 'font-serif' if field.dig('preferences', 'font') == 'Times' %> <%= align == 'right' ? 'justify-end' : (align == 'center' ? 'justify-center' : '') %>" style="width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%; <%= "font-size: #{field['preferences']['font_size']}pt" if field.dig('preferences', 'font_size') %>"> <field-value dir="auto" class="flex absolute text-[1.6vw] lg:text-base <%= 'font-serif' if field.dig('preferences', 'font') == 'Times' %> <%= align == 'right' ? 'justify-end' : (align == 'center' ? 'justify-center' : '') %>" style="width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%; <%= "font-size: clamp(4pt, 1.6vw, #{field['preferences']['font_size']}pt); line-height: `clamp(6pt, 2.0vw, #{field['preferences']['font_size'].to_i + 3}pt)`" if field.dig('preferences', 'font_size') %>">
<% if field['type'] == 'signature' %> <% if field['type'] == 'signature' %>
<div class="flex flex-col justify-between h-full overflow-hidden"> <div class="flex flex-col justify-between h-full overflow-hidden">
<div class="flex-grow flex overflow-hidden" style="min-height: 50%"> <div class="flex-grow flex overflow-hidden" style="min-height: 50%">
+52 -41
View File
@@ -17,41 +17,43 @@
<% end %> <% end %>
<% if last_submitter = @submission.submitters.to_a.select(&:completed_at?).max_by(&:completed_at) %> <% if last_submitter = @submission.submitters.to_a.select(&:completed_at?).max_by(&:completed_at) %>
<% is_all_completed = @submission.submitters.to_a.all?(&:completed_at?) %> <% is_all_completed = @submission.submitters.to_a.all?(&:completed_at?) %>
<div class="join relative"> <% if is_all_completed || !is_combined_enabled %>
<download-button data-src="<%= submitter_download_index_path(last_submitter.slug, { sig: params[:sig], combined: is_combined_enabled }.compact) %>" class="base-button <%= '!rounded-r-none !pr-2' if is_all_completed && !is_combined_enabled %>"> <div class="join relative">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton"> <download-button data-src="<%= submitter_download_index_path(last_submitter.slug, { sig: params[:sig], combined: is_combined_enabled }.compact) %>" class="base-button <%= '!rounded-r-none !pr-2' if is_all_completed && !is_combined_enabled %>">
<%= svg_icon('download', class: 'w-6 h-6') %> <span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
<span class="hidden md:inline"><%= t('download') %></span> <%= svg_icon('download', class: 'w-6 h-6') %>
</span> <span class="hidden md:inline"><%= t('download') %></span>
<span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton"> </span>
<%= svg_icon('loader', class: 'w-6 h-6 animate-spin') %> <span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton">
<span class="hidden md:inline"><%= t('downloading') %></span> <%= svg_icon('loader', class: 'w-6 h-6 animate-spin') %>
</span> <span class="hidden md:inline"><%= t('downloading') %></span>
</download-button> </span>
<% if is_all_completed && !is_combined_enabled %> </download-button>
<div class="dropdown dropdown-end"> <% if is_all_completed && !is_combined_enabled %>
<label tabindex="0" class="base-button !rounded-l-none !pl-1 !pr-2 !border-l-neutral-500"> <div class="dropdown dropdown-end">
<span class="text-sm align-text-top"> <label tabindex="0" class="base-button !rounded-l-none !pl-1 !pr-2 !border-l-neutral-500">
<%= svg_icon('chevron_down', class: 'w-6 h-6 flex-shrink-0 stroke-2') %> <span class="text-sm align-text-top">
</span> <%= svg_icon('chevron_down', class: 'w-6 h-6 flex-shrink-0 stroke-2') %>
</label> </span>
<ul tabindex="0" class="z-10 dropdown-content p-2 mt-2 shadow menu text-base bg-base-100 rounded-box text-right"> </label>
<li> <ul tabindex="0" class="z-10 dropdown-content p-2 mt-2 shadow menu text-base bg-base-100 rounded-box text-right">
<download-button data-src="<%= submitter_download_index_path(last_submitter.slug, { sig: params[:sig], combined: true }.compact) %>" class="flex items-center"> <li>
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton"> <download-button data-src="<%= submitter_download_index_path(last_submitter.slug, { sig: params[:sig], combined: true }.compact) %>" class="flex items-center">
<%= svg_icon('download', class: 'w-6 h-6 flex-shrink-0') %> <span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
<span class="whitespace-nowrap"><%= t('download_combined_pdf') %></span> <%= svg_icon('download', class: 'w-6 h-6 flex-shrink-0') %>
</span> <span class="whitespace-nowrap"><%= t('download_combined_pdf') %></span>
<span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton"> </span>
<%= svg_icon('loader', class: 'w-6 h-6 animate-spin') %> <span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton">
<span><%= t('downloading') %></span> <%= svg_icon('loader', class: 'w-6 h-6 animate-spin') %>
</span> <span><%= t('downloading') %></span>
</download-button> </span>
</li> </download-button>
</ul> </li>
</div> </ul>
<% end %> </div>
</div> <% end %>
</div>
<% end %>
<% elsif @submission.submitters.to_a.size == 1 && !@submission.expired? && !@submission.submitters.to_a.first.declined_at? %> <% elsif @submission.submitters.to_a.size == 1 && !@submission.expired? && !@submission.submitters.to_a.first.declined_at? %>
<%= render 'shared/clipboard_copy', text: submit_form_url(slug: @submission.submitters.to_a.first.slug), class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy_share_link'), copied_title: t('copied_to_clipboard') %> <%= render 'shared/clipboard_copy', text: submit_form_url(slug: @submission.submitters.to_a.first.slug), class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy_share_link'), copied_title: t('copied_to_clipboard') %>
<% end %> <% end %>
@@ -107,12 +109,21 @@
<% (@submission.template_submitters || @submission.template.submitters).each_with_index do |item, index| %> <% (@submission.template_submitters || @submission.template.submitters).each_with_index do |item, index| %>
<% submitter = @submission.submitters.find { |e| e.uuid == item['uuid'] } %> <% submitter = @submission.submitters.find { |e| e.uuid == item['uuid'] } %>
<div class="sticky -top-1 bg-base-100 pt-1 -mt-1"> <div class="sticky -top-1 bg-base-100 pt-1 -mt-1">
<div class="border border-base-300 rounded-md px-2 py-1 mb-1"> <div class="group border border-base-300 rounded-md px-2 py-1 mb-1">
<div class="flex items-center space-x-1"> <div class="flex items-center justify-between">
<span class="mx-1 w-3 h-3 rounded-full <%= colors[index] %>"></span> <div class="flex items-center space-x-1">
<span class="text-lg" dir="auto"> <span class="mx-1 w-3 h-3 shrink-0 rounded-full <%= colors[index] %>"></span>
<%= (@submission.template_submitters || @submission.template.submitters).find { |e| e['uuid'] == submitter&.uuid }&.dig('name') || "#{(index + 1).ordinalize} Submitter" %> <span class="text-lg" dir="auto">
</span> <%= (@submission.template_submitters || @submission.template.submitters).find { |e| e['uuid'] == submitter&.uuid }&.dig('name') || "#{(index + 1).ordinalize} Submitter" %>
</span>
</div>
<% if signed_in? && can?(:update, submitter) && !submitter.completed_at? && !submitter.declined_at? && !@submission.archived_at? && !@submission.expired? && !submitter.start_form_submission_events.any? %>
<span class="tooltip tooltip-left" data-tip="<%= t('edit') %>">
<%= link_to edit_submitter_path(submitter), class: 'shrink-0 inline md:hidden md:group-hover:inline', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('pencil', class: 'w-5 h-5') %>
<% end %>
</span>
<% end %>
</div> </div>
<% if submitter&.name.present? %> <% if submitter&.name.present? %>
<div class="flex items-center space-x-1 mt-1"> <div class="flex items-center space-x-1 mt-1">
@@ -1,4 +1,4 @@
<% data_attachments = attachments_index.values.select { |e| e.record_id == submitter.id }.to_json(only: %i[uuid created_at], methods: %i[url filename content_type]) %> <% data_attachments = attachments_index.values.select { |e| e.record_id == submitter.id }.to_json(only: %i[uuid created_at], methods: %i[url filename content_type]) %>
<% data_fields = (submitter.submission.template_fields || submitter.submission.template.fields).select { |f| f['submitter_uuid'] == submitter.uuid }.to_json %> <% data_fields = (submitter.submission.template_fields || submitter.submission.template.fields).select { |f| f['submitter_uuid'] == submitter.uuid }.to_json %>
<% invite_submitters = (submitter.submission.template_submitters || submitter.submission.template.submitters).select { |s| s['invite_by_uuid'] == submitter.uuid && submitter.submission.submitters.none? { |e| e.uuid == s['uuid'] } }.to_json %> <% invite_submitters = (submitter.submission.template_submitters || submitter.submission.template.submitters).select { |s| s['invite_by_uuid'] == submitter.uuid && submitter.submission.submitters.none? { |e| e.uuid == s['uuid'] } }.to_json %>
<submission-form data-is-demo="<%= Docuseal.demo? %>" data-require-signing-reason="<%= configs[:require_signing_reason] %>" data-with-signature-id="<%= configs[:with_signature_id] %>" data-with-confetti="<%= configs[:with_confetti] %>" data-completed-redirect-url="<%= submitter.preferences['completed_redirect_url'].presence || submitter.submission.template.preferences['completed_redirect_url'] %>" data-completed-message="<%= (configs[:completed_message]&.compact_blank.presence || submitter.submission.template.preferences['completed_message'] || {}).to_json %>" data-completed-button="<%= configs[:completed_button].to_json %>" data-go-to-last="<%= submitter.preferences.key?('go_to_last') ? submitter.preferences['go_to_last'] : submitter.opened_at? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(submitter.submission.account) %>" data-invite-submitters="<%= invite_submitters %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-values="<%= submitter.values.to_json %>" data-with-typed-signature="<%= configs[:with_typed_signature] %>" data-previous-signature-value="<%= local_assigns[:signature_attachment]&.uuid %>" data-remember-signature="<%= configs[:prefill_signature] %>" data-dry-run="<%= local_assigns[:dry_run] %>" data-expand="<%= local_assigns[:expand] %>" data-scroll-padding="<%= local_assigns[:scroll_padding] %>" data-language="<%= I18n.locale.to_s.split('-').first %>"></submission-form> <submission-form data-is-demo="<%= Docuseal.demo? %>" data-reuse-signature="<%= configs[:reuse_signature] %>" data-require-signing-reason="<%= configs[:require_signing_reason] %>" data-with-signature-id="<%= configs[:with_signature_id] %>" data-with-confetti="<%= configs[:with_confetti] %>" data-completed-redirect-url="<%= submitter.preferences['completed_redirect_url'].presence || submitter.submission.template.preferences['completed_redirect_url'] %>" data-completed-message="<%= (configs[:completed_message]&.compact_blank.presence || submitter.submission.template.preferences['completed_message'] || {}).to_json %>" data-completed-button="<%= configs[:completed_button].to_json %>" data-go-to-last="<%= submitter.preferences.key?('go_to_last') ? submitter.preferences['go_to_last'] : submitter.opened_at? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(submitter.submission.account) %>" data-invite-submitters="<%= invite_submitters %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-values="<%= submitter.values.to_json %>" data-with-typed-signature="<%= configs[:with_typed_signature] %>" data-previous-signature-value="<%= local_assigns[:signature_attachment]&.uuid %>" data-remember-signature="<%= configs[:prefill_signature] %>" data-dry-run="<%= local_assigns[:dry_run] %>" data-expand="<%= local_assigns[:expand] %>" data-scroll-padding="<%= local_assigns[:scroll_padding] %>" data-language="<%= I18n.locale.to_s.split('-').first %>"></submission-form>
+12 -9
View File
@@ -15,18 +15,21 @@
<%= @submitter.submission.template.name %> <%= @submitter.submission.template.name %>
</div> </div>
<div class="flex items-center space-x-2" style="margin-left: 20px; flex-shrink: 0"> <div class="flex items-center space-x-2" style="margin-left: 20px; flex-shrink: 0">
<div> <% if @form_configs[:with_decline] %>
<%= render 'shared/html_modal', title: t(:decline), btn_text: t(:decline), btn_class: 'btn btn-sm !px-5', button_id: 'decline_button' do %> <div>
<%= render 'submit_form/decline_form', submitter: @submitter %> <%= render 'shared/html_modal', title: t(:decline), btn_text: t(:decline), btn_class: 'btn btn-sm !px-5', button_id: 'decline_button' do %>
<% end %> <%= render 'submit_form/decline_form', submitter: @submitter %>
</div> <% end %>
</div>
<% end %>
<download-button data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm !px-4"> <download-button data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm !px-4">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton"> <span class="flex items-center justify-center" data-target="download-button.defaultButton">
<span><%= t('download') %></span> <%= svg_icon('download', class: 'w-6 h-6 inline md:hidden') %>
<span class="hidden md:inline"><%= t('download') %></span>
</span> </span>
<span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton"> <span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-6 h-6 animate-spin') %> <%= svg_icon('loader', class: 'w-6 h-6 animate-spin') %>
<span><%= t('downloading') %></span> <span class="hidden md:inline"><%= t('downloading') %></span>
</span> </span>
</download-button> </download-button>
</div> </div>
@@ -59,7 +62,7 @@
</div> </div>
<% end %> <% end %>
<% end %> <% end %>
<%= render 'shared/attribution', link_path: '/start', account: @submitter.account %> <%= render 'shared/attribution', link_path: '/start', account: @submitter.account, with_style: false %>
</div> </div>
</div> </div>
</div> </div>
@@ -23,7 +23,7 @@
</div> </div>
<div class="w-full bg-white rounded-2xl border relative" style="height: 300px"> <div class="w-full bg-white rounded-2xl border relative" style="height: 300px">
<canvas class="w-full"></canvas> <canvas class="w-full"></canvas>
<button aria-label="<%= t('clear') %>" class="btn btn-ghost btn-sm font-medium top-0 right-0 absolute mt-1 mr-1"> <button aria-label="Clear" class="btn btn-ghost btn-sm font-medium top-0 right-0 absolute mt-1 mr-1">
<%= svg_icon('reload', class: 'w-5 h-5') %> <%= svg_icon('reload', class: 'w-5 h-5') %>
<span class="inline"><%= t('clear') %></span> <span class="inline"><%= t('clear') %></span>
</button> </button>
+29
View File
@@ -0,0 +1,29 @@
<%= render 'shared/turbo_modal_large', title: t('edit_recipient') do %>
<div class="px-5 mb-5 mt-4">
<%= form_for '', url: submitter_path(@submitter), method: :patch, html: { autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
<div class="mb-2">
<submitter-item class="form-control">
<submitters-autocomplete data-field="name">
<%= text_field_tag 'submitter[name]', @submitter.name, autocomplete: 'off', class: 'base-input !h-10 w-full', placeholder: "#{t('name')} (#{t('optional')})", dir: 'auto' %>
</submitters-autocomplete>
<div class="grid md:grid-cols-2 gap-1.5 mt-1.5">
<submitters-autocomplete data-field="email">
<%= email_field_tag 'submitter[email]', @submitter.email, autocomplete: 'off', class: 'base-input !h-10 mt-1.5 w-full', placeholder: "#{t('email')} (#{t('optional')})" %>
</submitters-autocomplete>
<submitters-autocomplete data-field="phone">
<%= telephone_field_tag 'submitter[phone]', @submitter.phone, autocomplete: 'off', pattern: '^\+[0-9\s\-]+$', class: 'base-input !h-10 mt-1.5 w-full', placeholder: "#{t('phone')} (#{t('optional')})", oninvalid: "this.value ? this.setCustomValidity('#{t('use_international_format_1xxx_')}') : ''", oninput: "this.setCustomValidity('')" %>
</submitters-autocomplete>
</div>
</submitter-item>
</div>
<div>
<%= render 'submissions/send_email', f:, template: @submitter.template, submitter: @submitter, resend_email: @submitter.sent_at?, submitter_email_message: @submitter_email_message, disable_save_as_default_template_option: true %>
<%= render 'submissions/send_sms', f:, resend_sms: @submitter.sent_at? %>
</div>
<div class="form-control mt-4">
<%= f.button button_title(title: t('update_recipient'), disabled_with: t('updating')), class: 'base-button' %>
</div>
<% end %>
</div>
<%= content_for(:modal_extra) %>
<% end %>
+86 -71
View File
@@ -26,7 +26,7 @@
</div> </div>
</a> </a>
<% end %> <% end %>
<a href="<%= submission_path(submission) %>" class="w-full flex flex-col md:flex-row space-y-4 md:space-y-0 md:justify-between px-5 md:px-6 pb-5 md:items-center pt-5"> <div class="w-full flex flex-col md:flex-row space-y-4 md:space-y-0 md:justify-between px-5 md:px-6 pb-5 md:items-center pt-5 relative cursor-pointer">
<% submitters = (submission.template_submitters || submission.template.submitters).filter_map { |item| submission.submitters.find { |e| e.uuid == item['uuid'] } } %> <% 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?) && submitters.size.positive? %> <% is_submission_completed = submitters.all?(&:completed_at?) && submitters.size.positive? %>
<% if submitters.size == 1 %> <% if submitters.size == 1 %>
@@ -41,22 +41,31 @@
</span> </span>
</div> </div>
<% else %> <% else %>
<div class="tooltip flex" data-tip="<%= l(submitter.status_event_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %>"> <a href="<%= submission_path(submission) %>" class="flex z-[1]">
<span class="badge <%= status_badges[submitter.status] %> md:w-32 badge-lg bg-opacity-50 uppercase text-sm font-semibold"> <span class="badge <%= status_badges[submitter.status] %> md:w-32 badge-lg bg-opacity-50 uppercase text-sm font-semibold tooltip" data-tip="<%= l(submitter.status_event_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %>">
<%= t(submitter.status) %> <%= t(submitter.status) %>
</span> </span>
</div> </a>
<% end %> <% end %>
<span class="text-lg break-all flex items-center"> <span class="flex items-center z-[1]">
<%= submitter.name || submitter.email || submitter.phone %> <a href="<%= submission_path(submission) %>" class="text-lg break-all peer">
<%= submitter.name || submitter.email || submitter.phone %>
</a>
<% if can?(:update, submitter) && !submitter.start_form_submission_events.any? && !submission.archived_at? && !submission.expired? %>
<span class="pl-0.5 tooltip tooltip-top md:opacity-0 md:hover:opacity-100 md:peer-hover:opacity-100" data-tip="<%= t('edit') %>">
<%= link_to edit_submitter_path(submitter), class: 'shrink-0', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('pencil', class: 'w-5 h-5') %>
<% end %>
</span>
<% end %>
</span> </span>
</span> </span>
</div> </div>
</div> </div>
<div class="flex space-x-2 items-center"> <div class="flex space-x-2 items-center z-[1]">
<% if submitter.completed_at? %> <% if submitter.completed_at? %>
<form onsubmit="event.preventDefault()" class="flex-1 md:flex-none"> <div class="flex-1 md:flex-none">
<button onclick="event.stopPropagation()" class="w-full md:w-fit"> <div class="w-full md:w-fit">
<download-button data-src="<%= submitter_download_index_path(submitter.slug) %>" class="btn btn-sm btn-neutral text-white w-full md:w-36"> <download-button data-src="<%= submitter_download_index_path(submitter.slug) %>" class="btn btn-sm btn-neutral text-white w-full md:w-36">
<span class="flex items-center justify-center space-x-1 <%= 'md:space-x-2' if t('download').length < 11 %>" data-target="download-button.defaultButton"> <span class="flex items-center justify-center space-x-1 <%= 'md:space-x-2' if t('download').length < 11 %>" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-5 h-5 stroke-2') %> <%= svg_icon('download', class: 'w-5 h-5 stroke-2') %>
@@ -67,37 +76,39 @@
<span class="inline"><%= t('download')[..-2] %>...</span> <span class="inline"><%= t('download')[..-2] %>...</span>
</span> </span>
</download-button> </download-button>
</button> </div>
</form> </div>
<% elsif !submission.archived_at? && !template.archived_at? && !submission.expired? && !submitter.declined_at? %> <% elsif !submission.archived_at? && !template.archived_at? && !submission.expired? && !submitter.declined_at? %>
<% if current_user.email == submitter.email %> <% if current_user.email == submitter.email %>
<form data-turbo="false" method="get" action="<%= submit_form_url(slug: submitter.slug) %>" class="flex-1 md:flex-none"> <div class="flex-1 md:flex-none md:w-36 flex">
<button onclick="event.stopPropagation()" class="btn btn-sm btn-neutral btn-outline bg-white w-full md:w-36 flex"> <a href="<%= submit_form_url(slug: submitter.slug) %>" data-turbo="false" target="_blank" class="btn btn-sm btn-neutral btn-outline bg-white w-full md:w-36 flex z-[1]">
<span class="flex items-center justify-center space-x-1 md:space-x-2"> <span class="flex items-center justify-center space-x-1 md:space-x-2">
<% if t('sign_now').length < 12 %> <% if t('sign_now').length < 12 %>
<%= svg_icon('writing_sign', class: 'w-4 h-4 stroke-2') %> <%= svg_icon('writing_sign', class: 'w-4 h-4 stroke-2') %>
<% end %> <% end %>
<span class="inline shrink-0"><%= t('sign_now') %></span> <span class="inline shrink-0"><%= t('sign_now') %></span>
</span> </span>
</button> </a>
</form> </div>
<% else %> <% else %>
<div class="flex-1 md:flex-none"> <div class="flex-1 md:flex-none">
<%= 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: t('copy_link').length < 10 ? t('copy_link') : t('copy'), copy_title_md: t('copy'), copied_title_md: t('copied') %> <%= render 'shared/clipboard_copy', text: submit_form_url(slug: submitter.slug), class: 'btn btn-sm btn-neutral text-white md:w-36 flex z-[1]', icon_class: 'w-6 h-6 text-white', copy_title: t('copy_link').length < 10 ? t('copy_link') : t('copy'), copy_title_md: t('copy'), copied_title_md: t('copied') %>
</div> </div>
<% end %> <% end %>
<% end %> <% end %>
<div class="flex-1 md:flex-none"> <div class="flex-1 md:flex-none z-[1]">
<span class="btn btn-outline btn-sm w-full md:w-24"><%= t('view') %></span> <a href="<%= submission_path(submission) %>" class="btn btn-outline btn-sm w-full md:w-24">
<%= t('view') %>
</a>
</div> </div>
<% if !submission.archived_at? && !template.archived_at? && can?(:destroy, submission) %> <% if !submission.archived_at? && !template.archived_at? && can?(:destroy, submission) %>
<span data-tip="<%= t('archive') %>" class="sm:tooltip tooltip-top"> <span data-tip="<%= t('archive') %>" class="sm:tooltip tooltip-top">
<%= button_to button_title(title: nil, disabled_with: t(:archive).first(4), icon: svg_icon('archive', class: 'w-6 h-6')), submission_path(submission), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('archive'), method: :delete, onclick: 'event.stopPropagation()' %> <%= button_to button_title(title: nil, disabled_with: t(:archive).first(4), icon: svg_icon('archive', class: 'w-6 h-6')), submission_path(submission), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('archive'), method: :delete %>
</span> </span>
<% end %> <% end %>
<% if local_assigns[:archived] && can?(:destroy, submission) %> <% if local_assigns[:archived] && can?(:destroy, submission) %>
<span data-tip="<%= t('remove') %>" class="sm:tooltip tooltip-top"> <span data-tip="<%= t('remove') %>" class="sm:tooltip tooltip-top">
<%= button_to button_title(title: nil, disabled_with: t(:remove).first(3), icon: svg_icon('trash', class: 'w-6 h-6')), submission_path(submission, permanently: true), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('remove'), method: :delete, data: { turbo_confirm: t('submission_deletion_is_irreversible_and_will_permanently_remove_all_associated_signed_documents_with_it_are_you_sure_') }, onclick: 'event.stopPropagation()' %> <%= button_to button_title(title: nil, disabled_with: t(:remove).first(3), icon: svg_icon('trash', class: 'w-6 h-6')), submission_path(submission, permanently: true), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('remove'), method: :delete, data: { turbo_confirm: t('submission_deletion_is_irreversible_and_will_permanently_remove_all_associated_signed_documents_with_it_are_you_sure_') } %>
</span> </span>
<% end %> <% end %>
</div> </div>
@@ -106,11 +117,11 @@
<div class="flex flex-col md:flex-row md:items-center gap-3"> <div class="flex flex-col md:flex-row md:items-center gap-3">
<% if is_submission_completed %> <% if is_submission_completed %>
<% latest_submitter = submitters.select(&:completed_at?).max_by(&:completed_at) %> <% 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) %>"> <a href="<%= submission_path(submission) %>" class="z-[1] 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"> <span class="badge <%= status_badges[latest_submitter.status] %> md:w-32 bg-opacity-50 badge-lg uppercase text-sm font-semibold">
<%= t(latest_submitter.status) %> <%= t(latest_submitter.status) %>
</span> </span>
</div> </a>
<% elsif submission.expired? %> <% elsif submission.expired? %>
<div class="tooltip flex" data-tip="<%= l(submission.expire_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %>"> <div class="tooltip flex" data-tip="<%= l(submission.expire_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %>">
<span class="badge badge-error md:w-32 bg-opacity-50 badge-lg uppercase text-sm font-semibold"> <span class="badge badge-error md:w-32 bg-opacity-50 badge-lg uppercase text-sm font-semibold">
@@ -123,46 +134,49 @@
<div class="relative flex justify-between items-start md:items-center space-x-3"> <div class="relative flex justify-between items-start md:items-center space-x-3">
<span class="flex flex-col md:flex-row md:items-center gap-2"> <span class="flex flex-col md:flex-row md:items-center gap-2">
<% if !is_submission_completed && !submission.expired? %> <% if !is_submission_completed && !submission.expired? %>
<div class="tooltip flex" data-tip="<%= l(submitter.status_event_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %>"> <a href="<%= submission_path(submission) %>" class="z-[1] 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"> <span class="badge md:w-24 <%= status_badges[submitter.status] %> bg-opacity-50 uppercase text-xs font-semibold">
<%= t(submitter.status) %> <%= t(submitter.status) %>
</span> </span>
</div> </a>
<% end %> <% end %>
<span class="text-lg break-all"> <span class="flex items-center z-[1]">
<%= submitter.name || submitter.email || submitter.phone %> <a href="<%= submission_path(submission) %>" class="text-lg break-all peer">
<%= submitter.name || submitter.email || submitter.phone %>
</a>
<% if can?(:update, submitter) && !submitter.start_form_submission_events.any? && !submission.archived_at? && !submission.expired? %>
<span class="pl-0.5 tooltip tooltip-top md:opacity-0 md:hover:opacity-100 md:peer-hover:opacity-100" data-tip="<%= t('edit') %>">
<%= link_to edit_submitter_path(submitter), class: 'shrink-0', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('pencil', class: 'w-5 h-5') %>
<% end %>
</span>
<% end %>
</span> </span>
</span> </span>
<% if submitter.completed_at? && !is_submission_completed %> <% if submitter.completed_at? && !is_submission_completed %>
<form onsubmit="event.preventDefault()"> <download-button data-src="<%= submitter_download_index_path(submitter.slug) %>" class="absolute md:relative top-0 right-0 btn btn-xs btn-neutral text-white md:w-36 z-[1]">
<button onclick="event.stopPropagation()"> <span class="flex items-center justify-center space-x-1 md:space-x-2" data-target="download-button.defaultButton">
<download-button data-src="<%= submitter_download_index_path(submitter.slug) %>" class="absolute md:relative top-0 right-0 btn btn-xs btn-neutral text-white md:w-36"> <%= svg_icon('download', class: 'w-4 h-4 stroke-2') %>
<span class="flex items-center justify-center space-x-1 md:space-x-2" data-target="download-button.defaultButton"> <span class="inline"><%= t('download') %></span>
<%= svg_icon('download', class: 'w-4 h-4 stroke-2') %> </span>
<span class="inline"><%= t('download') %></span> <span class="flex items-center justify-center space-x-1 md:space-x-2 hidden" data-target="download-button.loadingButton">
</span> <%= svg_icon('loader', class: 'w-4 h-4 animate-spin') %>
<span class="flex items-center justify-center space-x-1 md:space-x-2 hidden" data-target="download-button.loadingButton"> <span class="inline"><%= t('download')[..-2] %>...</span>
<%= svg_icon('loader', class: 'w-4 h-4 animate-spin') %> </span>
<span class="inline"><%= t('download')[..-2] %>...</span> </download-button>
</span>
</download-button>
</button>
</form>
<% elsif !template.archived_at? && !submission.archived_at? && !is_submission_completed && !submission.expired? && !submitter.declined_at? %> <% elsif !template.archived_at? && !submission.archived_at? && !is_submission_completed && !submission.expired? && !submitter.declined_at? %>
<div class="relative flex items-center space-x-3"> <div class="relative flex items-center space-x-3">
<% if current_user.email == submitter.email %> <% if current_user.email == submitter.email %>
<form data-turbo="false" method="get" action="<%= submit_form_url(slug: submitter.slug) %>"> <a href="<%= submit_form_url(slug: submitter.slug) %>" data-turbo="false" target="_blank" class="absolute md:relative top-0 right-0 btn btn-xs btn-outline btn-neutral bg-white w-28 md:w-36 z-[1]">
<button onclick="event.stopPropagation()" class="absolute md:relative top-0 right-0 btn btn-xs btn-outline btn-neutral bg-white w-28 md:w-36"> <span class="flex items-center justify-center space-x-1 md:space-x-2">
<span class="flex items-center justify-center space-x-1 md:space-x-2"> <% if t('sign_now').length < 12 %>
<% if t('sign_now').length < 12 %> <%= svg_icon('writing_sign', class: 'w-4 h-4 stroke-2') %>
<%= svg_icon('writing_sign', class: 'w-4 h-4 stroke-2') %> <% end %>
<% end %> <span class="inline shrink-0"><%= t('sign_now') %></span>
<span class="inline shrink-0"><%= t('sign_now') %></span> </span>
</span> </a>
</button>
</form>
<% else %> <% else %>
<%= render 'shared/clipboard_copy', text: submit_form_url(slug: submitter.slug), class: 'absolute md:relative top-0 right-0 btn btn-xs text-xs btn-neutral text-white w-28 md:w-36 flex', icon_class: 'w-4 h-4 text-white', copy_title: t('copy_link'), copy_title_md: t('copy_link').length < 10 ? t('copy_link') : t('copy'), copied_title_md: t('copied') %> <%= render 'shared/clipboard_copy', text: submit_form_url(slug: submitter.slug), class: 'absolute md:relative top-0 right-0 btn btn-xs text-xs btn-neutral text-white w-28 md:w-36 flex z-[1]', icon_class: 'w-4 h-4 text-white', copy_title: t('copy_link'), copy_title_md: t('copy_link').length < 10 ? t('copy_link') : t('copy'), copied_title_md: t('copied') %>
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
@@ -171,40 +185,41 @@
</div> </div>
</div> </div>
</div> </div>
<div class="flex space-x-1 md:space-x-2 items-center"> <div class="flex space-x-1 md:space-x-2 items-center z-[1]">
<% if is_submission_completed %> <% if is_submission_completed %>
<% latest_submitter = submitters.select(&:completed_at?).max_by(&:completed_at) %> <% latest_submitter = submitters.select(&:completed_at?).max_by(&:completed_at) %>
<div class="flex-1 md:flex-none"> <div class="flex-1 md:flex-none">
<form onsubmit="event.preventDefault()" class="w-full md:w-fit"> <div class="w-full md:w-fit">
<button onclick="event.stopPropagation()" class="w-full"> <download-button data-src="<%= submitter_download_index_path(latest_submitter.slug) %>" class="btn btn-sm btn-neutral text-white w-full md:w-36 z-[1]">
<download-button data-src="<%= submitter_download_index_path(latest_submitter.slug) %>" class="btn btn-sm btn-neutral text-white w-full md:w-36"> <span class="flex items-center justify-center space-x-1 md:space-x-2" data-target="download-button.defaultButton">
<span class="flex items-center justify-center space-x-1 md:space-x-2" data-target="download-button.defaultButton"> <%= svg_icon('download', class: 'w-5 h-5 stroke-2') %>
<%= svg_icon('download', class: 'w-5 h-5 stroke-2') %> <span class="inline"><%= t('download') %></span>
<span class="inline"><%= t('download') %></span> </span>
</span> <span class="flex items-center justify-center space-x-1 md:space-x-2 hidden" data-target="download-button.loadingButton">
<span class="flex items-center justify-center space-x-1 md:space-x-2 hidden" data-target="download-button.loadingButton"> <%= svg_icon('loader', class: 'w-5 h-5 animate-spin') %>
<%= svg_icon('loader', class: 'w-5 h-5 animate-spin') %> <span class="inline"><%= t('download')[..-2] %>...</span>
<span class="inline"><%= t('download')[..-2] %>...</span> </span>
</span> </download-button>
</download-button> </div>
</button>
</form>
</div> </div>
<% end %> <% end %>
<div class="flex-1 md:flex-none"> <div class="flex-1 md:flex-none z-[1]">
<span class="btn btn-outline btn-sm w-full md:w-24"><%= t('view') %></span> <a href="<%= submission_path(submission) %>" class="btn btn-outline btn-sm w-full md:w-24">
<%= t('view') %>
</a>
</div> </div>
<% if !submission.archived_at? && !template.archived_at? %> <% if !submission.archived_at? && !template.archived_at? %>
<span data-tip="<%= t('archive') %>" class="sm:tooltip tooltip-top"> <span data-tip="<%= t('archive') %>" class="sm:tooltip tooltip-top">
<%= button_to button_title(title: nil, disabled_with: t(:archive).first(4), icon: svg_icon('archive', class: 'w-6 h-6')), submission_path(submission), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('archive'), method: :delete, onclick: 'event.stopPropagation()' %> <%= button_to button_title(title: nil, disabled_with: t(:archive).first(4), icon: svg_icon('archive', class: 'w-6 h-6')), submission_path(submission), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('archive'), method: :delete %>
</span> </span>
<% end %> <% end %>
<% if local_assigns[:archived] && can?(:destroy, submission) %> <% if local_assigns[:archived] && can?(:destroy, submission) %>
<span data-tip="<%= t('remove') %>" class="sm:tooltip tooltip-top"> <span data-tip="<%= t('remove') %>" class="sm:tooltip tooltip-top">
<%= button_to button_title(title: nil, disabled_with: t(:remove).first(3), icon: svg_icon('trash', class: 'w-6 h-6')), submission_path(submission, permanently: true), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('remove'), method: :delete, data: { turbo_confirm: t('submission_deletion_is_irreversible_and_will_permanently_remove_all_associated_signed_documents_with_it_are_you_sure_') }, onclick: 'event.stopPropagation()' %> <%= button_to button_title(title: nil, disabled_with: t(:remove).first(3), icon: svg_icon('trash', class: 'w-6 h-6')), submission_path(submission, permanently: true), class: 'btn btn-outline btn-sm w-full md:w-fit', form: { class: 'flex' }, title: t('remove'), method: :delete, data: { turbo_confirm: t('submission_deletion_is_irreversible_and_will_permanently_remove_all_associated_signed_documents_with_it_are_you_sure_') } %>
</span> </span>
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
</a> <a href="<%= submission_path(submission) %>" class="top-0 bottom-0 left-0 right-0 absolute"></a>
</div>
</div> </div>
+16
View File
@@ -0,0 +1,16 @@
<div class="flex items-center space-x-1">
<% if %w[archived integration].include?(params[:status]) %>
<%= link_to t('view_active'), settings_users_path, class: 'link text-sm' %>
<% else %>
<% archived_exists = current_account.users.archived.where.not(role: 'integration').exists? %>
<% if current_account.users.active.exists?(role: 'integration') %>
<%= link_to t('view_embedding_users'), settings_integration_users_path, class: 'link text-sm' %>
<% if archived_exists %>
<span class="text-neutral-700">|</span>
<% end %>
<% end %>
<% if archived_exists %>
<%= link_to t('view_archived'), settings_archived_users_path, class: 'link text-sm' %>
<% end %>
<% end %>
</div>
+14 -19
View File
@@ -1,12 +1,18 @@
<div class="flex-wrap space-y-4 md:flex md:flex-nowrap md:space-y-0 md:space-x-10"> <div class="flex-wrap space-y-4 md:flex md:flex-nowrap md:space-y-0 md:space-x-10">
<%= render 'shared/settings_nav' %> <%= render 'shared/settings_nav' %>
<div class="md:flex-grow"> <div class="md:flex-grow">
<div class="flex flex-col md:flex-row gap-2 md:justify-between md:items-end mb-4"> <div class="flex flex-col md:flex-row gap-2 md:justify-between md:items-end mb-4 min-h-12">
<h1 class="text-4xl font-bold"> <h1 class="text-4xl font-bold">
<%= t('status_users', status: params[:status].to_s.titleize) %> <% if params[:status] == 'archived' %>
<%= t('archived_users') %>
<% elsif params[:status] == 'integration' %>
<%= t('embedding_users') %>
<% else %>
<%= t('users') %>
<% end %>
</h1> </h1>
<div class="flex flex-col md:flex-row gap-y-2 gap-x-4 md:items-center"> <div class="flex flex-col md:flex-row gap-y-2 gap-x-4 md:items-center">
<% if can?(:create, User.new(account: current_account)) %> <% if params[:status].blank? && can?(:create, User.new(account: current_account)) %>
<%= render 'users/extra_buttons' %> <%= render 'users/extra_buttons' %>
<% if content_for(:add_user_button) %> <% if content_for(:add_user_button) %>
<%= content_for(:add_user_button) %> <%= content_for(:add_user_button) %>
@@ -57,17 +63,17 @@
<%= user.last_sign_in_at ? l(user.last_sign_in_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) : '-' %> <%= user.last_sign_in_at ? l(user.last_sign_in_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) : '-' %>
</td> </td>
<td class="flex items-center space-x-2 justify-end"> <td class="flex items-center space-x-2 justify-end">
<% if can?(:update, user) && user.archived_at.blank? %> <% if params[:status].blank? && can?(:update, user) && user.archived_at.blank? %>
<%= link_to edit_user_path(user), class: 'btn btn-outline btn-xs', title: t('edit'), data: { turbo_frame: 'modal' } do %> <%= link_to edit_user_path(user), class: 'btn btn-outline btn-xs', title: t('edit'), data: { turbo_frame: 'modal' } do %>
<%= t('edit') %> <%= t('edit') %>
<% end %> <% end %>
<% end %> <% end %>
<% if params[:status].blank? && can?(:destroy, user) && user != current_user %> <% if params[:status] != 'archived' && can?(:destroy, user) && user != current_user %>
<%= button_to user_path(user), method: :delete, class: 'btn btn-outline btn-error btn-xs', title: t('remove'), data: { turbo_confirm: t('are_you_sure_') } do %> <%= button_to user_path(user), method: :delete, class: 'btn btn-outline btn-error btn-xs', title: t('remove'), data: { turbo_confirm: t('are_you_sure_') } do %>
<%= t('remove') %> <%= t('remove') %>
<% end %> <% end %>
<% end %> <% end %>
<% if params[:status].present? && can?(:manage, user) && user != current_user %> <% if params[:status] == 'archived' && can?(:manage, user) && user != current_user && user.archived_at? %>
<%= button_to user_path(user), method: :put, params: { user: { archived_at: nil } }, class: 'btn btn-outline btn-xs', title: t('unarchive'), data: { turbo_confirm: t('are_you_sure_') } do %> <%= button_to user_path(user), method: :put, params: { user: { archived_at: nil } }, class: 'btn btn-outline btn-xs', title: t('unarchive'), data: { turbo_confirm: t('are_you_sure_') } do %>
<%= t('unarchive') %> <%= t('unarchive') %>
<% end %> <% end %>
@@ -78,22 +84,11 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<% view_archived_html = capture do %>
<% if current_account.users.archived.exists? %>
<div>
<% if params[:status] == 'archived' %>
<a href="<%= settings_users_path %>" class="link text-sm"><%= t('view_active') %></a>
<% else %>
<a href="<%= settings_archived_users_path %>" class="link text-sm"><%= t('view_archived') %></a>
<% end %>
</div>
<% end %>
<% end %>
<% if @pagy.pages > 1 %> <% if @pagy.pages > 1 %>
<%= render 'shared/pagination', pagy: @pagy, items_name: 'users', left_additional_html: view_archived_html %> <%= render 'shared/pagination', pagy: @pagy, items_name: 'users', left_additional_html: render('bottom_links') %>
<% else %> <% else %>
<div class="mt-2"> <div class="mt-2">
<%= view_archived_html %> <%= render 'bottom_links' %>
</div> </div>
<% end %> <% end %>
</div> </div>
+1 -1
View File
@@ -1,5 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
Rails.configuration.to_prepare do Rails.configuration.to_prepare do
ActiveRecord::Tasks::DatabaseTasks.migrate if ENV['RAILS_ENV'] == 'production' ActiveRecord::Tasks::DatabaseTasks.migrate if ENV['RAILS_ENV'] == 'production' && ENV['RUN_MIGRATIONS'] != 'false'
end end
+55 -6
View File
@@ -134,6 +134,7 @@ en: &en
require_signing_reason: Require signing reason require_signing_reason: Require signing reason
allow_typed_text_signatures: Allow typed text signatures allow_typed_text_signatures: Allow typed text signatures
allow_to_resubmit_completed_forms: Allow to resubmit completed forms allow_to_resubmit_completed_forms: Allow to resubmit completed forms
allow_to_decline_documents: Allow to decline documents
remember_and_pre_fill_signatures: Remember and pre-fill signatures remember_and_pre_fill_signatures: Remember and pre-fill signatures
require_authentication_for_file_download_links: Require authentication for file download links require_authentication_for_file_download_links: Require authentication for file download links
combine_completed_documents_and_audit_log: Combine completed documents and Audit Log combine_completed_documents_and_audit_log: Combine completed documents and Audit Log
@@ -473,7 +474,6 @@ en: &en
back: Back back: Back
add_secret: Add Secret add_secret: Add Secret
submission_example_payload: Submission example payload submission_example_payload: Submission example payload
status_users: '%{status} Users'
there_are_no_signatures: There are no signatures there_are_no_signatures: There are no signatures
signed_with_trusted_certificate: Signed with trusted certificate signed_with_trusted_certificate: Signed with trusted certificate
signed_with_external_certificate: Signed with external certificate signed_with_external_certificate: Signed with external certificate
@@ -613,6 +613,15 @@ en: &en
unverified: Unverified unverified: Unverified
document: Document document: Document
completed_at: Completed At completed_at: Completed At
edit_recipient: Edit Recipient
update_recipient: Update Recipient
use_international_format_1xxx_: 'Use internatioanl format: +1xxx...'
submitter_cannot_be_updated: Submitter cannot be updated.
at_least_one_field_must_be_filled: At least one field must be filled.
archived_users: Archived Users
embedding_users: Embedding Users
view_embedding_users: View Embedding Users
view_users: View Users
submission_event_names: submission_event_names:
send_email_to_html: '<b>Email sent</b> to %{submitter_name}' send_email_to_html: '<b>Email sent</b> to %{submitter_name}'
send_reminder_email_to_html: '<b>Reminder email sent</b> to %{submitter_name}' send_reminder_email_to_html: '<b>Reminder email sent</b> to %{submitter_name}'
@@ -765,6 +774,7 @@ es: &es
require_signing_reason: Requerir motivo de firma require_signing_reason: Requerir motivo de firma
allow_typed_text_signatures: Permitir firmas de texto mecanografiadas allow_typed_text_signatures: Permitir firmas de texto mecanografiadas
allow_to_resubmit_completed_forms: Permitir reenviar formularios completados allow_to_resubmit_completed_forms: Permitir reenviar formularios completados
allow_to_decline_documents: Permitir rechazar documentos
remember_and_pre_fill_signatures: Recordar y prellenar firmas remember_and_pre_fill_signatures: Recordar y prellenar firmas
require_authentication_for_file_download_links: Requerir autenticación para enlaces de descarga de archivos require_authentication_for_file_download_links: Requerir autenticación para enlaces de descarga de archivos
combine_completed_documents_and_audit_log: Combinar documentos completados y Registro de Auditoría combine_completed_documents_and_audit_log: Combinar documentos completados y Registro de Auditoría
@@ -1104,7 +1114,6 @@ es: &es
back: Atrás back: Atrás
add_secret: Agregar secreto add_secret: Agregar secreto
submission_example_payload: Ejemplo de payload de envío submission_example_payload: Ejemplo de payload de envío
status_users: '%{status} Usuarios'
there_are_no_signatures: No hay firmas there_are_no_signatures: No hay firmas
signed_with_trusted_certificate: Firmado con certificado de confianza signed_with_trusted_certificate: Firmado con certificado de confianza
signed_with_external_certificate: Firmado con certificado externo signed_with_external_certificate: Firmado con certificado externo
@@ -1244,6 +1253,14 @@ es: &es
unverified: No verificado unverified: No verificado
document: Documento document: Documento
completed_at: Completado el completed_at: Completado el
edit_recipient: Editar Destinatario
update_recipient: Actualizar Destinatario
use_international_format_1xxx_: 'Usa el formato internacional: +1xxx...'
submitter_cannot_be_updated: El remitente no puede ser actualizado.
at_least_one_field_must_be_filled: Al menos un campo debe estar completo.
archived_users: Usuarios Archivados
embedding_users: Usuarios Integrados
view_embedding_users: Ver Usuarios Integrado
submission_event_names: submission_event_names:
send_email_to_html: '<b>Correo electrónico enviado</b> a %{submitter_name}' send_email_to_html: '<b>Correo electrónico enviado</b> a %{submitter_name}'
send_reminder_email_to_html: '<b>Correo de recordatorio enviado</b> a %{submitter_name}' send_reminder_email_to_html: '<b>Correo de recordatorio enviado</b> a %{submitter_name}'
@@ -1396,6 +1413,7 @@ it: &it
require_signing_reason: Richiedere il motivo della firma require_signing_reason: Richiedere il motivo della firma
allow_typed_text_signatures: Permettere firme con testo digitato allow_typed_text_signatures: Permettere firme con testo digitato
allow_to_resubmit_completed_forms: Permettere di reinviare i moduli completati allow_to_resubmit_completed_forms: Permettere di reinviare i moduli completati
allow_to_decline_documents: Permettere di rifiutare i documenti
remember_and_pre_fill_signatures: Ricordare e precompilare le firme remember_and_pre_fill_signatures: Ricordare e precompilare le firme
require_authentication_for_file_download_links: "Richiedere l'autenticazione per i link di download dei file" require_authentication_for_file_download_links: "Richiedere l'autenticazione per i link di download dei file"
combine_completed_documents_and_audit_log: Combinare i documenti completati e il Registro di Audit combine_completed_documents_and_audit_log: Combinare i documenti completati e il Registro di Audit
@@ -1735,7 +1753,6 @@ it: &it
back: Indietro back: Indietro
add_secret: Aggiungi segreto add_secret: Aggiungi segreto
submission_example_payload: Esempio di payload di invio submission_example_payload: Esempio di payload di invio
status_users: '%{status} Utenti'
there_are_no_signatures: Non ci sono firme there_are_no_signatures: Non ci sono firme
signed_with_trusted_certificate: Firmato con certificato affidabile signed_with_trusted_certificate: Firmato con certificato affidabile
signed_with_external_certificate: Firmato con certificato esterno signed_with_external_certificate: Firmato con certificato esterno
@@ -1875,6 +1892,14 @@ it: &it
unverified: Non verificato unverified: Non verificato
document: Documento document: Documento
completed_at: Completato il completed_at: Completato il
edit_recipient: Modifica Destinatario
update_recipient: Aggiorna Destinatario
use_international_format_1xxx_: 'Utilizza il formato internazionale: +1xxx...'
submitter_cannot_be_updated: Il mittente non può essere aggiornato.
at_least_one_field_must_be_filled: Almeno un campo deve essere compilato.
archived_users: Utenti Archiviati
embedding_users: Utenti Incorporati
view_embedding_users: Visualizza Utenti Incorporati
submission_event_names: submission_event_names:
send_email_to_html: '<b>E-mail inviato</b> a %{submitter_name}' send_email_to_html: '<b>E-mail inviato</b> a %{submitter_name}'
send_reminder_email_to_html: '<b>E-mail di promemoria inviato</b> a %{submitter_name}' send_reminder_email_to_html: '<b>E-mail di promemoria inviato</b> a %{submitter_name}'
@@ -2028,6 +2053,7 @@ fr: &fr
require_signing_reason: Demander une raison pour la signature require_signing_reason: Demander une raison pour la signature
allow_typed_text_signatures: Autoriser les signatures en texte tapé allow_typed_text_signatures: Autoriser les signatures en texte tapé
allow_to_resubmit_completed_forms: Autoriser la resoumission des formulaires complétés allow_to_resubmit_completed_forms: Autoriser la resoumission des formulaires complétés
allow_to_decline_documents: Autoriser le refus des documents
remember_and_pre_fill_signatures: Se souvenir et préremplir les signatures remember_and_pre_fill_signatures: Se souvenir et préremplir les signatures
require_authentication_for_file_download_links: Exiger une authentification pour les liens de téléchargement des fichiers require_authentication_for_file_download_links: Exiger une authentification pour les liens de téléchargement des fichiers
combine_completed_documents_and_audit_log: "Combiner les documents complétés et le journal d'audit" combine_completed_documents_and_audit_log: "Combiner les documents complétés et le journal d'audit"
@@ -2367,7 +2393,6 @@ fr: &fr
back: Retour back: Retour
add_secret: Ajouter un secret add_secret: Ajouter un secret
submission_example_payload: Exemple de payload de soumission submission_example_payload: Exemple de payload de soumission
status_users: '%{status} Utilisateurs'
there_are_no_signatures: "Il n'y a pas de signatures" there_are_no_signatures: "Il n'y a pas de signatures"
signed_with_trusted_certificate: Signé avec un certificat de confiance signed_with_trusted_certificate: Signé avec un certificat de confiance
signed_with_external_certificate: Signé avec un certificat externe signed_with_external_certificate: Signé avec un certificat externe
@@ -2507,6 +2532,14 @@ fr: &fr
unverified: Non vérifié unverified: Non vérifié
document: Document document: Document
completed_at: Terminé le completed_at: Terminé le
edit_recipient: Modifica Destinatario
update_recipient: Aggiorna Destinatario
use_international_format_1xxx_: 'Utilizza il formato internazionale: +1xxx...'
submitter_cannot_be_updated: Il mittente non può essere aggiornato.
at_least_one_field_must_be_filled: Almeno un campo deve essere compilato.
archived_users: Utilisateurs Archivés
embedding_users: Utilisateurs Intégrés
view_embedding_users: Voir les Utilisateurs Intégrés
submission_event_names: submission_event_names:
send_email_to_html: '<b>E-mail envoyé</b> à %{submitter_name}' send_email_to_html: '<b>E-mail envoyé</b> à %{submitter_name}'
send_reminder_email_to_html: '<b>E-mail de rappel envoyé</b> à %{submitter_name}' send_reminder_email_to_html: '<b>E-mail de rappel envoyé</b> à %{submitter_name}'
@@ -2659,6 +2692,7 @@ pt: &pt
require_signing_reason: Requerer motivo para assinatura require_signing_reason: Requerer motivo para assinatura
allow_typed_text_signatures: Permitir assinaturas digitadas allow_typed_text_signatures: Permitir assinaturas digitadas
allow_to_resubmit_completed_forms: Permitir reenviar formulários concluídos allow_to_resubmit_completed_forms: Permitir reenviar formulários concluídos
allow_to_decline_documents: Permitir recusar documentos
remember_and_pre_fill_signatures: Lembrar e preencher assinaturas automaticamente remember_and_pre_fill_signatures: Lembrar e preencher assinaturas automaticamente
require_authentication_for_file_download_links: Requerer autenticação para links de download de arquivos require_authentication_for_file_download_links: Requerer autenticação para links de download de arquivos
combine_completed_documents_and_audit_log: Combinar documentos concluídos e log de auditoria combine_completed_documents_and_audit_log: Combinar documentos concluídos e log de auditoria
@@ -2998,7 +3032,6 @@ pt: &pt
back: Voltar back: Voltar
add_secret: Adicionar segredo add_secret: Adicionar segredo
submission_example_payload: Exemplo de payload de submissão submission_example_payload: Exemplo de payload de submissão
status_users: '%{status} Usuários'
there_are_no_signatures: Não há assinaturas there_are_no_signatures: Não há assinaturas
signed_with_trusted_certificate: Assinado com certificado confiável signed_with_trusted_certificate: Assinado com certificado confiável
signed_with_external_certificate: Assinado com certificado externo signed_with_external_certificate: Assinado com certificado externo
@@ -3138,6 +3171,14 @@ pt: &pt
unverified: Não verificado unverified: Não verificado
document: Documento document: Documento
completed_at: Concluído em completed_at: Concluído em
edit_recipient: Editar Destinatário
update_recipient: Atualizar Destinatário
use_international_format_1xxx_: 'Use o formato internacional: +1xxx...'
submitter_cannot_be_updated: O remetente não pode ser atualizado.
at_least_one_field_must_be_filled: Pelo menos um campo deve ser preenchido.
archived_users: Usuários Arquivados
embedding_users: Usuários Incorporados
view_embedding_users: Ver Usuários Incorporados
submission_event_names: submission_event_names:
send_email_to_html: '<b>E-mail enviado</b> para %{submitter_name}' send_email_to_html: '<b>E-mail enviado</b> para %{submitter_name}'
send_reminder_email_to_html: '<b>E-mail de lembrete enviado</b> para %{submitter_name}' send_reminder_email_to_html: '<b>E-mail de lembrete enviado</b> para %{submitter_name}'
@@ -3290,6 +3331,7 @@ de: &de
require_signing_reason: Grund für die Signatur erforderlich require_signing_reason: Grund für die Signatur erforderlich
allow_typed_text_signatures: Unterschriften mit getipptem Text zulassen allow_typed_text_signatures: Unterschriften mit getipptem Text zulassen
allow_to_resubmit_completed_forms: Erneutes Einreichen abgeschlossener Formulare zulassen allow_to_resubmit_completed_forms: Erneutes Einreichen abgeschlossener Formulare zulassen
allow_to_decline_documents: Erlauben, Dokumente abzulehnen
remember_and_pre_fill_signatures: Signaturen merken und vorab ausfüllen remember_and_pre_fill_signatures: Signaturen merken und vorab ausfüllen
require_authentication_for_file_download_links: Authentifizierung für Dateidownload-Links erforderlich require_authentication_for_file_download_links: Authentifizierung für Dateidownload-Links erforderlich
combine_completed_documents_and_audit_log: Abgeschlossene Dokumente und Prüfprotokoll kombinieren combine_completed_documents_and_audit_log: Abgeschlossene Dokumente und Prüfprotokoll kombinieren
@@ -3629,7 +3671,6 @@ de: &de
back: Zurück back: Zurück
add_secret: Geheimnis hinzufügen add_secret: Geheimnis hinzufügen
submission_example_payload: Beispiel-Payload für Einreichung submission_example_payload: Beispiel-Payload für Einreichung
status_users: '%{status} Benutzer'
there_are_no_signatures: Es gibt keine Unterschriften there_are_no_signatures: Es gibt keine Unterschriften
signed_with_trusted_certificate: Signiert mit vertrauenswürdigem Zertifikat signed_with_trusted_certificate: Signiert mit vertrauenswürdigem Zertifikat
signed_with_external_certificate: Signiert mit externem Zertifikat signed_with_external_certificate: Signiert mit externem Zertifikat
@@ -3769,6 +3810,14 @@ de: &de
unverified: Nicht verifiziert unverified: Nicht verifiziert
document: Dokument document: Dokument
completed_at: Abgeschlossen am completed_at: Abgeschlossen am
edit_recipient: Empfänger bearbeiten
update_recipient: Empfänger aktualisieren
use_international_format_1xxx_: 'Verwenden Sie das internationale Format: +1xxx...'
submitter_cannot_be_updated: Der Absender kann nicht aktualisiert werden.
at_least_one_field_must_be_filled: Mindestens ein Feld muss ausgefüllt werden.
archived_users: Archivierte Benutzer
embedding_users: Einbettende Benutzer
view_embedding_users: Einbettende Benutzer anzeigen
submission_event_names: submission_event_names:
send_email_to_html: '<b>E-Mail gesendet</b> an %{submitter_name}' send_email_to_html: '<b>E-Mail gesendet</b> an %{submitter_name}'
send_reminder_email_to_html: '<b>Erinnerungs-E-Mail gesendet</b> an %{submitter_name}' send_reminder_email_to_html: '<b>Erinnerungs-E-Mail gesendet</b> an %{submitter_name}'
+5 -1
View File
@@ -34,6 +34,7 @@ Rails.application.routes.draw do
resources :submitter_form_views, only: %i[create] resources :submitter_form_views, only: %i[create]
resources :submitters, only: %i[index show update] resources :submitters, only: %i[index show update]
resources :submissions, only: %i[index show create destroy] do resources :submissions, only: %i[index show create destroy] do
resources :documents, only: %i[index], controller: 'submission_documents'
collection do collection do
resources :init, only: %i[create], controller: 'submissions' resources :init, only: %i[create], controller: 'submissions'
resources :emails, only: %i[create], controller: 'submissions', as: :submissions_emails resources :emails, only: %i[create], controller: 'submissions', as: :submissions_emails
@@ -69,6 +70,7 @@ Rails.application.routes.draw do
resources :submissions_archived, only: %i[index], path: 'submissions/archived' resources :submissions_archived, only: %i[index], path: 'submissions/archived'
resources :submissions, only: %i[index], controller: 'submissions_dashboard' resources :submissions, only: %i[index], controller: 'submissions_dashboard'
resources :submissions, only: %i[show destroy] resources :submissions, only: %i[show destroy]
resources :submitters, only: %i[edit update]
resources :console_redirect, only: %i[index] resources :console_redirect, only: %i[index]
resources :upgrade, only: %i[index], controller: 'console_redirect' resources :upgrade, only: %i[index], controller: 'console_redirect'
resources :manage, only: %i[index], controller: 'console_redirect' resources :manage, only: %i[index], controller: 'console_redirect'
@@ -151,15 +153,17 @@ Rails.application.routes.draw do
scope '/settings', as: :settings do scope '/settings', as: :settings 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_smtp_settings'
resources :sms, only: %i[index], controller: 'sms_settings' resources :sms, only: %i[index], controller: 'sms_settings'
end end
resources :email, only: %i[index create], controller: 'email_smtp_settings'
resources :sso, only: %i[index], controller: 'sso_settings' resources :sso, only: %i[index], controller: 'sso_settings'
resources :notifications, only: %i[index create], controller: 'notifications_settings' resources :notifications, only: %i[index create], controller: 'notifications_settings'
resource :esign, only: %i[show create new update destroy], 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]
resources :archived_users, only: %i[index], path: 'users/:status', controller: 'users', resources :archived_users, only: %i[index], path: 'users/:status', controller: 'users',
defaults: { status: :archived } defaults: { status: :archived }
resources :integration_users, only: %i[index], path: 'users/:status', controller: 'users',
defaults: { status: :integration }
resource :personalization, only: %i[show create], controller: 'personalization_settings' resource :personalization, only: %i[show create], controller: 'personalization_settings'
resources :api, only: %i[index create], controller: 'api_settings' resources :api, only: %i[index create], controller: 'api_settings'
resource :webhooks, only: %i[show create update], controller: 'webhook_settings' resource :webhooks, only: %i[show create update], controller: 'webhook_settings'
+1
View File
@@ -1,6 +1,7 @@
queues: queues:
- [default, 1] - [default, 1]
- [webhooks, 1] - [webhooks, 1]
- [images, 1]
- [mailers, 1] - [mailers, 1]
- [recurrent, 1] - [recurrent, 1]
- [rollbar, 1] - [rollbar, 1]
+1
View File
@@ -11,6 +11,7 @@ const configs = generateWebpackConfig({
}, },
optimization: { optimization: {
runtimeChunk: false, runtimeChunk: false,
concatenateModules: !process.env.BUNDLE_ANALYZE,
splitChunks: { splitChunks: {
chunks (chunk) { chunks (chunk) {
return chunk.name !== 'rollbar' return chunk.name !== 'rollbar'
@@ -0,0 +1,24 @@
# frozen_string_literal: true
class CreateCompletedSubmittersAndDocuments < ActiveRecord::Migration[7.2]
def change
create_table :completed_submitters do |t|
t.bigint :submitter_id, null: false, index: true
t.bigint :submission_id, null: false
t.bigint :account_id, null: false, index: true
t.bigint :template_id, null: false
t.string :source, null: false
t.integer :sms_count, null: false
t.datetime :completed_at, null: false
t.timestamps
end
create_table :completed_documents do |t|
t.bigint :submitter_id, null: false, index: true
t.string :sha256, null: false, index: true
t.timestamps
end
end
end
@@ -0,0 +1,79 @@
# frozen_string_literal: true
class PopulateCompletedSubmittersAndDocuments < ActiveRecord::Migration[7.2]
disable_ddl_transaction
class MigrationSubmitter < ApplicationRecord
self.table_name = 'submitters'
belongs_to :submission, class_name: 'MigrationSubmission'
has_many :submission_sms_events, -> { where(event_type: %w[send_sms send_2fa_sms]) },
class_name: 'MigrationSubmissionEvent', foreign_key: :submitter_id
end
class MigrationSubmission < ApplicationRecord
self.table_name = 'submissions'
end
class MigrationSubmissionEvent < ApplicationRecord
self.table_name = 'submission_events'
end
class MigrationCompletedSubmitter < ApplicationRecord
self.table_name = 'completed_submitters'
end
class MigrationCompletedDocument < ApplicationRecord
self.table_name = 'completed_documents'
end
def up
submitters = MigrationSubmitter.where.not(completed_at: nil)
.preload(:submission, :submission_sms_events)
count = submitters.count
puts "Updating the database - it might take ~#{(count / 1000 * 3) + 1} seconds" if count > 2000
submitters.find_each do |submitter|
completed_submitter = MigrationCompletedSubmitter.find_or_initialize_by(submitter_id: submitter.id)
next if completed_submitter.persisted?
submission = submitter.submission
completed_submitter.assign_attributes(
submission_id: submitter.submission_id,
account_id: submission.account_id,
template_id: submission.template_id,
source: submission.source,
sms_count: submitter.submission_sms_events.size,
completed_at: submitter.completed_at,
created_at: submitter.completed_at,
updated_at: submitter.completed_at
)
completed_submitter.save!
end
attachments = ActiveStorage::Attachment.where(record_type: 'Submitter', name: 'documents').preload(:blob)
attachments.find_each do |attachment|
sha256 = attachment.metadata['sha256']
next if sha256.blank?
completed_document = MigrationCompletedDocument.find_or_initialize_by(submitter_id: attachment.record_id, sha256:)
next if completed_document.persisted?
completed_document.assign_attributes(created_at: attachment.created_at, updated_at: attachment.created_at)
completed_document.save!
end
end
def down
nil
end
end
@@ -0,0 +1,8 @@
# frozen_string_literal: true
class AddUniqueIndexOnCompletedSubmitters < ActiveRecord::Migration[7.2]
def change
remove_index :completed_submitters, :submitter_id
add_index :completed_submitters, :submitter_id, unique: true
end
end
+24 -1
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2024_08_20_180922) do ActiveRecord::Schema[7.2].define(version: 2024_10_26_161207) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@@ -89,6 +89,29 @@ ActiveRecord::Schema[7.1].define(version: 2024_08_20_180922) do
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
end end
create_table "completed_documents", force: :cascade do |t|
t.bigint "submitter_id", null: false
t.string "sha256", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["sha256"], name: "index_completed_documents_on_sha256"
t.index ["submitter_id"], name: "index_completed_documents_on_submitter_id"
end
create_table "completed_submitters", force: :cascade do |t|
t.bigint "submitter_id", null: false
t.bigint "submission_id", null: false
t.bigint "account_id", null: false
t.bigint "template_id", null: false
t.string "source", null: false
t.integer "sms_count", null: false
t.datetime "completed_at", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_completed_submitters_on_account_id"
t.index ["submitter_id"], name: "index_completed_submitters_on_submitter_id", unique: true
end
create_table "document_generation_events", force: :cascade do |t| create_table "document_generation_events", force: :cascade do |t|
t.bigint "submitter_id", null: false t.bigint "submitter_id", null: false
t.string "event_name", null: false t.string "event_name", null: false
+1 -1
View File
@@ -11,7 +11,7 @@ module Accounts
new_user.uuid = SecureRandom.uuid new_user.uuid = SecureRandom.uuid
new_user.account = new_account new_user.account = new_account
new_user.encrypted_password = SecureRandom.hex new_user.encrypted_password = SecureRandom.hex
new_user.email = "#{SecureRandom.hex}@docuseal.co" new_user.email = "#{SecureRandom.hex}@docuseal.com"
account.templates.each do |template| account.templates.each do |template|
new_template = template.dup new_template = template.dup
+15 -7
View File
@@ -16,19 +16,27 @@ module ActionMailerConfigsInterceptor
end end
if Rails.env.production? && Rails.application.config.action_mailer.delivery_method if Rails.env.production? && Rails.application.config.action_mailer.delivery_method
message.from = ENV.fetch('SMTP_FROM') from = ENV.fetch('SMTP_FROM').to_s.split(',').sample
if from.match?(User::FULL_EMAIL_REGEXP)
message[:from] = message[:from].to_s.sub(User::EMAIL_REGEXP, from)
else
message.from = from
end
return message return message
end end
email_configs = EncryptedConfig.find_by(key: EncryptedConfig::EMAIL_SMTP_KEY) unless Docuseal.multitenant?
email_configs = EncryptedConfig.find_by(key: EncryptedConfig::EMAIL_SMTP_KEY)
if email_configs if email_configs
message.delivery_method(:smtp, build_smtp_configs_hash(email_configs)) message.delivery_method(:smtp, build_smtp_configs_hash(email_configs))
message.from = %("#{email_configs.account.name.to_s.delete('"')}" <#{email_configs.value['from_email']}>) message.from = %("#{email_configs.account.name.to_s.delete('"')}" <#{email_configs.value['from_email']}>)
else else
message.delivery_method(:test) message.delivery_method(:test)
end
end end
message message
+1 -1
View File
@@ -22,7 +22,7 @@ module ActionMailerEventsObserver
emailable_type:, emailable_type:,
event_type: :send, event_type: :send,
email:, email:,
data: { method: mail.delivery_method.class.name.underscore }, data: { from: mail.from, method: mail.delivery_method.class.name.underscore },
event_datetime: Time.current event_datetime: Time.current
) )
end end
+4 -4
View File
@@ -12,7 +12,7 @@ module Docuseal
TWITTER_URL = 'https://twitter.com/docusealco' TWITTER_URL = 'https://twitter.com/docusealco'
TWITTER_HANDLE = '@docusealco' TWITTER_HANDLE = '@docusealco'
CHATGPT_URL = 'https://chatgpt.com/g/g-9hg8AAw0r-docuseal' CHATGPT_URL = 'https://chatgpt.com/g/g-9hg8AAw0r-docuseal'
SUPPORT_EMAIL = 'support@docuseal.co' SUPPORT_EMAIL = 'support@docuseal.com'
HOST = ENV.fetch('HOST', 'localhost') HOST = ENV.fetch('HOST', 'localhost')
AATL_CERT_NAME = 'docuseal_aatl' AATL_CERT_NAME = 'docuseal_aatl'
CONSOLE_URL = if Rails.env.development? CONSOLE_URL = if Rails.env.development?
@@ -20,19 +20,19 @@ module Docuseal
elsif ENV['MULTITENANT'] == 'true' elsif ENV['MULTITENANT'] == 'true'
"https://console.#{HOST}" "https://console.#{HOST}"
else else
'https://console.docuseal.co' 'https://console.docuseal.com'
end end
CLOUD_URL = if Rails.env.development? CLOUD_URL = if Rails.env.development?
'http://localhost:3000' 'http://localhost:3000'
else else
'https://docuseal.co' 'https://docuseal.com'
end end
CDN_URL = if Rails.env.development? CDN_URL = if Rails.env.development?
'http://localhost:3000' 'http://localhost:3000'
elsif ENV['MULTITENANT'] == 'true' elsif ENV['MULTITENANT'] == 'true'
"https://cdn.#{HOST}" "https://cdn.#{HOST}"
else else
'https://cdn.docuseal.co' 'https://cdn.docuseal.com'
end end
CERTS = JSON.parse(ENV.fetch('CERTS', '{}')) CERTS = JSON.parse(ENV.fetch('CERTS', '{}'))
+24
View File
@@ -2,6 +2,8 @@
module Params module Params
class BaseValidator class BaseValidator
EMAIL_REGEXP = User::FULL_EMAIL_REGEXP
InvalidParameterError = Class.new(StandardError) InvalidParameterError = Class.new(StandardError)
def self.call(...) def self.call(...)
@@ -65,6 +67,28 @@ module Params
raise_error(message || "#{key} must follow the #{regexp.source} format") raise_error(message || "#{key} must follow the #{regexp.source} format")
end end
def email_format(params, key, message: nil)
return if params.blank?
return if params[key].blank?
return if params[key].to_s.include?('<')
if params[key].to_s.strip.split(/\s*[;,]\s*/).compact_blank
.all? { |email| EmailTypo::DotCom.call(email).match?(EMAIL_REGEXP) }
return
end
raise_error(message || "#{key} must follow the email format: '#{params[key]}'")
end
def unique_value(params, key, message: nil)
return unless params.is_a?(Array)
return if params.none?
return if params.all? { |p| p[key].blank? }
return if params.pluck(key).compact_blank.uniq.size == params.pluck(key).compact_blank.size
raise_error(message || "#{key} must be unique")
end
def in_path(params, path = []) def in_path(params, path = [])
old_path = @current_path old_path = @current_path
+7 -5
View File
@@ -22,6 +22,7 @@ module Params
required(params, %i[emails email]) required(params, %i[emails email])
type(params, :emails, String) type(params, :emails, String)
email_format(params, :emails, message: 'emails are invalid')
boolean(params, :send_email) boolean(params, :send_email)
type(params, :message, Hash) type(params, :message, Hash)
@@ -43,8 +44,8 @@ module Params
type(params, :completed_redirect_url, String) type(params, :completed_redirect_url, String)
type(params, :bcc_completed, String) type(params, :bcc_completed, String)
type(params, :reply_to, String) type(params, :reply_to, String)
format(params, :bcc_completed, /@/, message: 'bcc_completed email is invalid') email_format(params, :bcc_completed, message: 'bcc_completed email is invalid')
format(params, :reply_to, /@/, message: 'reply_to email is invalid') email_format(params, :reply_to, message: 'reply_to email is invalid')
type(params, :message, Hash) type(params, :message, Hash)
type(params, :submitters, Array) type(params, :submitters, Array)
@@ -60,6 +61,7 @@ module Params
if params[:submitters].present? if params[:submitters].present?
in_path(params, :submitters) do |submitters_params| in_path(params, :submitters) do |submitters_params|
type(submitters_params, 0, Hash) type(submitters_params, 0, Hash)
unique_value(submitters_params, :role)
end end
end end
@@ -74,8 +76,8 @@ module Params
type(submitter_params, :name, String) type(submitter_params, :name, String)
type(submitter_params, :reply_to, String) type(submitter_params, :reply_to, String)
type(submitter_params, :email, String) type(submitter_params, :email, String)
format(submitter_params, :email, /@/, message: 'email is invalid') email_format(submitter_params, :email, message: 'email is invalid')
format(submitter_params, :reply_to, /@/, message: 'reply_to email is invalid') email_format(submitter_params, :reply_to, message: 'reply_to email is invalid')
type(submitter_params, :phone, String) type(submitter_params, :phone, String)
format(submitter_params, :phone, /\A\+\d+\z/, format(submitter_params, :phone, /\A\+\d+\z/,
message: 'phone should start with +<country code> and contain only digits') message: 'phone should start with +<country code> and contain only digits')
@@ -105,7 +107,7 @@ module Params
type(params, :order, String) type(params, :order, String)
type(params, :completed_redirect_url, String) type(params, :completed_redirect_url, String)
type(params, :bcc_completed, String) type(params, :bcc_completed, String)
format(params, :bcc_completed, /@/, message: 'bcc_completed email is invalid') email_format(params, :bcc_completed, message: 'bcc_completed email is invalid')
type(params, :message, Hash) type(params, :message, Hash)
in_path(params, :message) do |message_params| in_path(params, :message) do |message_params|
@@ -2,8 +2,11 @@
module Submissions module Submissions
module CreateFromSubmitters module CreateFromSubmitters
BaseError = Class.new(StandardError)
module_function module_function
# rubocop:disable Metrics/BlockLength
def call(template:, user:, submissions_attrs:, source:, submitters_order:, params: {}) def call(template:, user:, submissions_attrs:, source:, submitters_order:, params: {})
preferences = Submitters.normalize_preferences(user.account, user, params) preferences = Submitters.normalize_preferences(user.account, user, params)
@@ -37,6 +40,10 @@ module Submissions
preferences: preferences.merge(submission_preferences)) preferences: preferences.merge(submission_preferences))
end end
if submission.submitters.size > template.submitters.size
raise BaseError, 'Defined more signing parties than in template'
end
next if submission.submitters.blank? next if submission.submitters.blank?
maybe_add_invite_submitters(submission, template) maybe_add_invite_submitters(submission, template)
@@ -44,6 +51,7 @@ module Submissions
submission.tap(&:save!) submission.tap(&:save!)
end end
end end
# rubocop:enable Metrics/BlockLength
def maybe_add_invite_submitters(submission, template) def maybe_add_invite_submitters(submission, template)
template.submitters.each do |item| template.submitters.each do |item|
+1 -1
View File
@@ -393,7 +393,7 @@ module Submissions
end end
def sign_reason def sign_reason
'Signed with DocuSeal.co' 'Signed with DocuSeal.com'
end end
def maybe_add_background(_canvas, _submission, _page_size); end def maybe_add_background(_canvas, _submission, _page_size); end
@@ -0,0 +1,95 @@
# frozen_string_literal: true
module Submissions
module GeneratePreviewAttachments
module_function
# rubocop:disable Metrics
def call(submission, values_hash: nil)
values_hash ||= build_values_hash(submission)
configs = submission.account.account_configs.where(key: [AccountConfig::FLATTEN_RESULT_PDF_KEY,
AccountConfig::WITH_SIGNATURE_ID])
with_signature_id = configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID }&.value == true
is_flatten = configs.find { |c| c.key == AccountConfig::FLATTEN_RESULT_PDF_KEY }&.value != false
pdfs_index = GenerateResultAttachments.build_pdfs_index(submission, flatten: is_flatten)
submission.submitters.where(completed_at: nil).preload(attachments_attachments: :blob).each do |submitter|
GenerateResultAttachments.fill_submitter_fields(submitter, submission.account, pdfs_index,
with_signature_id:, is_flatten:)
end
template = submission.template
image_pdfs = []
original_documents = template.documents.preload(:blob)
result_attachments =
(submission.template_schema || template.schema).map do |item|
pdf = pdfs_index[item['attachment_uuid']]
if original_documents.find { |a| a.uuid == item['attachment_uuid'] }.image?
pdf = GenerateResultAttachments.normalize_image_pdf(pdf)
image_pdfs << pdf
end
build_pdf_attachment(pdf:, submission:,
uuid: item['attachment_uuid'],
values_hash:,
name: item['name'])
end
return ApplicationRecord.no_touching { result_attachments.map { |e| e.tap(&:save!) } } if image_pdfs.size < 2
images_pdf =
image_pdfs.each_with_object(HexaPDF::Document.new) do |pdf, doc|
pdf.pages.each { |page| doc.pages << doc.import(page) }
end
images_pdf = GenerateResultAttachments.normalize_image_pdf(images_pdf)
images_pdf_attachment =
build_pdf_attachment(
pdf: images_pdf,
submission:,
uuid: GenerateResultAttachments.images_pdf_uuid(original_documents.select(&:image?)),
values_hash:,
name: template.name
)
ApplicationRecord.no_touching do
(result_attachments + [images_pdf_attachment]).map { |e| e.tap(&:save!) }
end
end
def build_values_hash(submission)
submission.submitters.reduce({}) { |acc, s| acc.merge(s.values) }.hash
end
def build_pdf_attachment(pdf:, submission:, uuid:, name:, values_hash:)
io = StringIO.new
begin
pdf.write(io, incremental: true, validate: false)
rescue HexaPDF::MalformedPDFError => e
Rollbar.error(e) if defined?(Rollbar)
pdf.write(io, incremental: false, validate: false)
end
ActiveStorage::Attachment.new(
blob: ActiveStorage::Blob.create_and_upload!(io: io.tap(&:rewind), filename: "#{name}.pdf"),
metadata: { original_uuid: uuid,
values_hash:,
analyzed: true,
sha256: Base64.urlsafe_encode64(Digest::SHA256.digest(io.string)) },
name: 'preview_documents',
record: submission
)
end
# rubocop:enable Metrics
end
end
+25 -19
View File
@@ -92,19 +92,13 @@ module Submissions
end end
def generate_pdfs(submitter) def generate_pdfs(submitter)
cell_layouter = HexaPDF::Layout::TextLayouter.new(text_valign: :center, text_align: :center) configs = submitter.account.account_configs.where(key: [AccountConfig::FLATTEN_RESULT_PDF_KEY,
AccountConfig::WITH_SIGNATURE_ID])
with_signature_id = submitter.account.account_configs with_signature_id = configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID }&.value == true
.exists?(key: AccountConfig::WITH_SIGNATURE_ID, value: true) is_flatten = configs.find { |c| c.key == AccountConfig::FLATTEN_RESULT_PDF_KEY }&.value != false
is_flatten = pdfs_index = build_pdfs_index(submitter.submission, submitter:, flatten: is_flatten)
submitter.account.account_configs
.find_or_initialize_by(key: AccountConfig::FLATTEN_RESULT_PDF_KEY).value != false
account = submitter.account
attachments_data_cache = {}
pdfs_index = build_pdfs_index(submitter, flatten: is_flatten)
if with_signature_id || submitter.account.testing? if with_signature_id || submitter.account.testing?
pdfs_index.each_value do |pdf| pdfs_index.each_value do |pdf|
@@ -145,6 +139,16 @@ module Submissions
end end
end end
fill_submitter_fields(submitter, submitter.account, pdfs_index, with_signature_id:, is_flatten:)
end
def fill_submitter_fields(submitter, account, pdfs_index, with_signature_id:, is_flatten:)
cell_layouter = HexaPDF::Layout::TextLayouter.new(text_valign: :center, text_align: :center)
attachments_data_cache = {}
return pdfs_index if submitter.submission.template_fields.blank?
submitter.submission.template_fields.each do |field| submitter.submission.template_fields.each do |field|
next if field['submitter_uuid'] != submitter.uuid next if field['submitter_uuid'] != submitter.uuid
@@ -459,6 +463,8 @@ module Submissions
**build_signing_params(submitter, pkcs, tsa_url) **build_signing_params(submitter, pkcs, tsa_url)
} }
pdf.pages.first[:Annots] = [] unless pdf.pages.first[:Annots].respond_to?(:<<)
begin begin
pdf.sign(io, write_options: { validate: false }, **sign_params) pdf.sign(io, write_options: { validate: false }, **sign_params)
rescue HexaPDF::MalformedPDFError => e rescue HexaPDF::MalformedPDFError => e
@@ -512,13 +518,13 @@ module Submissions
Digest::UUID.uuid_v5(Digest::UUID::OID_NAMESPACE, attachments.map(&:uuid).sort.join(':')) Digest::UUID.uuid_v5(Digest::UUID::OID_NAMESPACE, attachments.map(&:uuid).sort.join(':'))
end end
def build_pdfs_index(submitter, flatten: true) def build_pdfs_index(submission, submitter: nil, flatten: true)
latest_submitter = find_last_submitter(submitter) latest_submitter = find_last_submitter(submission, submitter:)
Submissions::EnsureResultGenerated.call(latest_submitter) if latest_submitter Submissions::EnsureResultGenerated.call(latest_submitter) if latest_submitter
documents = latest_submitter&.documents&.preload(:blob).to_a.presence documents = latest_submitter&.documents&.preload(:blob).to_a.presence
documents ||= submitter.submission.template_schema_documents.preload(:blob) documents ||= submission.template_schema_documents.preload(:blob)
documents.to_h do |attachment| documents.to_h do |attachment|
pdf = pdf =
@@ -580,11 +586,11 @@ module Submissions
font_wrapper.custom_glyph(replace_with, character) font_wrapper.custom_glyph(replace_with, character)
end end
def find_last_submitter(submitter) def find_last_submitter(submission, submitter: nil)
submitter.submission.submitters submission.submitters
.select(&:completed_at?) .select(&:completed_at?)
.select { |e| e.id != submitter.id && e.completed_at <= submitter.completed_at } .select { |e| submitter.nil? ? true : e.id != submitter.id && e.completed_at <= submitter.completed_at }
.max_by(&:completed_at) .max_by(&:completed_at)
end end
def build_pdf_from_image(attachment) def build_pdf_from_image(attachment)
+15 -7
View File
@@ -7,7 +7,9 @@ module Submitters
AccountConfig::FORM_WITH_CONFETTI_KEY, AccountConfig::FORM_WITH_CONFETTI_KEY,
AccountConfig::FORM_PREFILL_SIGNATURE_KEY, AccountConfig::FORM_PREFILL_SIGNATURE_KEY,
AccountConfig::WITH_SIGNATURE_ID, AccountConfig::WITH_SIGNATURE_ID,
AccountConfig::ALLOW_TO_DECLINE_KEY,
AccountConfig::REQUIRE_SIGNING_REASON_KEY, AccountConfig::REQUIRE_SIGNING_REASON_KEY,
AccountConfig::REUSE_SIGNATURE_KEY,
AccountConfig::ALLOW_TYPED_SIGNATURE].freeze AccountConfig::ALLOW_TYPED_SIGNATURE].freeze
module_function module_function
@@ -20,16 +22,22 @@ module Submitters
with_typed_signature = find_safe_value(configs, AccountConfig::ALLOW_TYPED_SIGNATURE) != false with_typed_signature = find_safe_value(configs, AccountConfig::ALLOW_TYPED_SIGNATURE) != false
with_confetti = find_safe_value(configs, AccountConfig::FORM_WITH_CONFETTI_KEY) != false with_confetti = find_safe_value(configs, AccountConfig::FORM_WITH_CONFETTI_KEY) != false
prefill_signature = find_safe_value(configs, AccountConfig::FORM_PREFILL_SIGNATURE_KEY) != false prefill_signature = find_safe_value(configs, AccountConfig::FORM_PREFILL_SIGNATURE_KEY) != false
reuse_signature = find_safe_value(configs, AccountConfig::REUSE_SIGNATURE_KEY) != false
with_decline = find_safe_value(configs, AccountConfig::ALLOW_TO_DECLINE_KEY) != false
with_signature_id = find_safe_value(configs, AccountConfig::WITH_SIGNATURE_ID) == true with_signature_id = find_safe_value(configs, AccountConfig::WITH_SIGNATURE_ID) == true
require_signing_reason = find_safe_value(configs, AccountConfig::REQUIRE_SIGNING_REASON_KEY) == true require_signing_reason = find_safe_value(configs, AccountConfig::REQUIRE_SIGNING_REASON_KEY) == true
attrs = { completed_button:, attrs = {
with_typed_signature:, completed_button:,
with_confetti:, with_typed_signature:,
completed_message:, with_confetti:,
require_signing_reason:, reuse_signature:,
prefill_signature:, with_decline:,
with_signature_id: } completed_message:,
require_signing_reason:,
prefill_signature:,
with_signature_id:
}
keys.each do |key| keys.each do |key|
attrs[key.to_sym] = configs.find { |e| e.key == key.to_s }&.value attrs[key.to_sym] = configs.find { |e| e.key == key.to_s }&.value
+1 -1
View File
@@ -220,7 +220,7 @@ module Submitters
end end
when 'date' when 'date'
if with_time if with_time
I18n.l(Time.current.in_time_zone(submission.account.timezone).to_date) Time.current.in_time_zone(submission.account.timezone).to_date.to_s
else else
e e
end end
+3
View File
@@ -25,6 +25,8 @@ module Templates
media_box_start = [media_box[0], media_box[1]] media_box_start = [media_box[0], media_box[1]]
crop_shift = [crop_box[0] - media_box[0], crop_box[1] - media_box[1]] crop_shift = [crop_box[0] - media_box[0], crop_box[1] - media_box[1]]
next unless child_field[:Rect]
x0, y0, x1, y1 = child_field[:Rect] x0, y0, x1, y1 = child_field[:Rect]
x0, y0 = correct_coordinates(x0, y0, crop_shift, media_box_start) x0, y0 = correct_coordinates(x0, y0, crop_shift, media_box_start)
@@ -202,6 +204,7 @@ module Templates
field = annot[:Parent] field = annot[:Parent]
field = field[:Parent] while field[:Parent] field = field[:Parent] while field[:Parent]
annots_index[field.hash] ||= page
fields_index[field.hash] ||= HexaPDF::Type::AcroForm::Field.wrap(pdf, field) fields_index[field.hash] ||= HexaPDF::Type::AcroForm::Field.wrap(pdf, field)
end end
end end
+10 -6
View File
@@ -17,7 +17,7 @@ module Templates
module_function module_function
def call(attachment, data, extract_fields: false) def call(attachment, data, extract_fields: false, max_pages: MAX_NUMBER_OF_PAGES_PROCESSED)
if attachment.content_type == PDF_CONTENT_TYPE if attachment.content_type == PDF_CONTENT_TYPE
if extract_fields && data.size < MAX_FLATTEN_FILE_SIZE if extract_fields && data.size < MAX_FLATTEN_FILE_SIZE
pdf = HexaPDF::Document.new(io: StringIO.new(data)) pdf = HexaPDF::Document.new(io: StringIO.new(data))
@@ -25,7 +25,7 @@ module Templates
fields = Templates::FindAcroFields.call(pdf, attachment) fields = Templates::FindAcroFields.call(pdf, attachment)
end end
generate_pdf_preview_images(attachment, data, pdf) generate_pdf_preview_images(attachment, data, pdf, max_pages:)
attachment.metadata['pdf']['fields'] = fields if fields attachment.metadata['pdf']['fields'] = fields if fields
elsif attachment.image? elsif attachment.image?
@@ -56,7 +56,7 @@ module Templates
) )
end end
def generate_pdf_preview_images(attachment, data, pdf = nil) def generate_pdf_preview_images(attachment, data, pdf = nil, max_pages: MAX_NUMBER_OF_PAGES_PROCESSED)
ActiveStorage::Attachment.where(name: ATTACHMENT_NAME, record: attachment).destroy_all ActiveStorage::Attachment.where(name: ATTACHMENT_NAME, record: attachment).destroy_all
pdf ||= HexaPDF::Document.new(io: StringIO.new(data)) pdf ||= HexaPDF::Document.new(io: StringIO.new(data))
@@ -71,12 +71,16 @@ module Templates
attachment.save! attachment.save!
end end
max_pages_to_process = data.size < GENERATE_PREVIEW_SIZE_LIMIT ? MAX_NUMBER_OF_PAGES_PROCESSED : 1 max_pages_to_process = data.size < GENERATE_PREVIEW_SIZE_LIMIT ? max_pages : 1
pool = Concurrent::FixedThreadPool.new(CONCURRENCY) generate_document_preview_images(attachment, data, (0..[number_of_pages - 1, max_pages_to_process].min))
end
def generate_document_preview_images(attachment, data, range, concurrency: CONCURRENCY)
pool = Concurrent::FixedThreadPool.new(concurrency)
promises = promises =
(0..[number_of_pages - 1, max_pages_to_process].min).map do |page_number| range.map do |page_number|
Concurrent::Promise.execute(executor: pool) { build_and_upload_blob(data, page_number) } Concurrent::Promise.execute(executor: pool) { build_and_upload_blob(data, page_number) }
end end
+9
View File
@@ -5,5 +5,14 @@ FactoryBot.define do
name { Faker::Company.name } name { Faker::Company.name }
locale { 'en-US' } locale { 'en-US' }
timezone { 'UTC' } timezone { 'UTC' }
trait :with_testing_account do
after(:create) do |account|
testing_account = account.dup.tap { |a| a.name = "Testing - #{account.name}" }
testing_account.uuid = SecureRandom.uuid
account.testing_accounts << testing_account
account.save!
end
end
end end
end end
+8
View File
@@ -0,0 +1,8 @@
# frozen_string_literal: true
FactoryBot.define do
factory :completed_document do
submitter
sha256 { SecureRandom.hex(32) }
end
end
+43 -32
View File
@@ -7,7 +7,11 @@ FactoryBot.define do
author factory: %i[user] author factory: %i[user]
name { Faker::Book.title } name { Faker::Book.title }
after(:create) do |template| transient do
submitter_count { 1 }
end
after(:create) do |template, evaluator|
blob = ActiveStorage::Blob.create_and_upload!( blob = ActiveStorage::Blob.create_and_upload!(
io: Rails.root.join('spec/fixtures/sample-document.pdf').open, io: Rails.root.join('spec/fixtures/sample-document.pdf').open,
filename: 'sample-document.pdf', filename: 'sample-document.pdf',
@@ -22,39 +26,46 @@ FactoryBot.define do
Templates::ProcessDocument.call(attachment, attachment.download) Templates::ProcessDocument.call(attachment, attachment.download)
template.schema = [{ attachment_uuid: attachment.uuid, name: 'sample-document' }] template.schema = [{ attachment_uuid: attachment.uuid, name: 'sample-document' }]
template.submitters = [ number_words = %w[first second third fourth fifth sixth seventh eighth ninth tenth]
template.submitters = Array.new(evaluator.submitter_count) do |i|
{ {
'name' => 'First Party', 'name' => "#{number_words[i]&.capitalize} Party",
'uuid' => '513848eb-1096-4abc-a743-68596b5aaa4c' 'uuid' => SecureRandom.uuid
} }
] end
template.fields = [
{ template.fields = template.submitters.reduce([]) do |fields, submitter|
'uuid' => '21637fc9-0655-45df-8952-04ec64949e85', fields += [
'submitter_uuid' => '513848eb-1096-4abc-a743-68596b5aaa4c', {
'name' => 'First Name', 'uuid' => SecureRandom.uuid,
'type' => 'text', 'submitter_uuid' => submitter['uuid'],
'required' => true, 'name' => 'First Name',
'areas' => [ 'type' => 'text',
{ 'required' => true,
'x' => 0.09027777777777778, 'areas' => [
'y' => 0.1197252208047105, {
'w' => 0.3069444444444444, 'x' => 0.09027777777777778,
'h' => 0.03336604514229637, 'y' => 0.1197252208047105,
'attachment_uuid' => attachment.uuid, 'w' => 0.3069444444444444,
'page' => 0 'h' => 0.03336604514229637,
} 'attachment_uuid' => attachment.uuid,
] 'page' => 0
}, }
{ ]
'uuid' => '1f97f8e3-dc82-4586-aeea-6ebed6204e46', },
'submitter_uuid' => '513848eb-1096-4abc-a743-68596b5aaa4c', {
'name' => '', 'uuid' => SecureRandom.uuid,
'type' => 'signature', 'submitter_uuid' => submitter['uuid'],
'required' => true, 'name' => '',
'areas' => [] 'type' => 'signature',
} 'required' => true,
] 'areas' => []
}
]
fields
end
template.save! template.save!
end end
@@ -0,0 +1,51 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ProcessSubmitterCompletionJob do
let(:account) { create(:account) }
let(:user) { create(:user, account:) }
let(:template) { create(:template, account:, author: user) }
let(:submission) { create(:submission, template:, created_by_user: user) }
let(:submitter) { create(:submitter, submission:, uuid: SecureRandom.uuid, completed_at: Time.current) }
before do
create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY,
value: GenerateCertificate.call.transform_values(&:to_pem))
end
describe '#perform' do
it 'creates a completed submitter' do
expect do
described_class.new.perform('submitter_id' => submitter.id)
end.to change(CompletedSubmitter, :count).by(1)
completed_submitter = CompletedSubmitter.last
submitter.reload
expect(completed_submitter.submitter_id).to eq(submitter.id)
expect(completed_submitter.submission_id).to eq(submitter.submission_id)
expect(completed_submitter.account_id).to eq(submitter.submission.account_id)
expect(completed_submitter.template_id).to eq(submitter.submission.template_id)
expect(completed_submitter.source).to eq(submitter.submission.source)
end
it 'creates a completed document' do
expect do
described_class.new.perform('submitter_id' => submitter.id)
end.to change(CompletedDocument, :count).by(1)
completed_document = CompletedDocument.last
expect(completed_document.submitter_id).to eq(submitter.id)
expect(completed_document.sha256).to be_present
expect(completed_document.sha256).to eq(submitter.documents.first.metadata['sha256'])
end
it 'raises an error if the submitter is not found' do
expect do
described_class.new.perform('submitter_id' => 'invalid_id')
end.to raise_error(ActiveRecord::RecordNotFound)
end
end
end
+109
View File
@@ -0,0 +1,109 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Params::BaseValidator do
let(:validator) { described_class.new({}) }
describe '#email_format' do
it 'when email is valid' do
emails = [
' john.doe@example.com ',
'john.doe@example.com',
'Test <john.doe@example.com>',
'jane+newsletter@domain.org',
'mike_smith@company.net',
'lisa-wong@sub.example.co.uk',
'peter@webmail.com',
'anna.jones123@my-domain.com',
'contact@company.email',
'info@my-company123.org',
'hello.world@business.info',
'feedback@new-domain.com',
'alerts+user@localdomain.net',
'webmaster@industry.biz',
'services@agency.example',
'george123@consultant.pro',
'sales-team@company.io'
]
emails.each do |email|
expect { validator.email_format({ email: }, :email) }.not_to raise_error
end
end
it 'when signle email is invalid' do
emails = [
'jone.doe@',
'this...is@strange.but.valid.com',
'user@-weird-domain-.com',
'user.name@[IPv6:2001:db8::1]',
'tricky.email@sub.example-.com'
]
emails.each do |email|
expect do
validator.email_format({ email: }, :email)
end.to raise_error(described_class::InvalidParameterError, "email must follow the email format: '#{email}'")
end
end
it 'when multiple emails are valid' do
emails = [
'john.doe@example.com, jane.doe+newsletter@domain.org',
'joshua@automobile.car ; chloe+fashion@food.delivery',
'mike-smith@company.net;lisa.wong-sales@sub.example.co.uk',
'peter.parker+info@webmail.com,laura.martin-office@company.co',
'anna.jones123@my-domain.com, jack.brown+work@college.edu',
'susan.green@business-info.org; dave.clark+personal@nonprofit.org',
'chris.lee+team@new-domain.com;jenny.king.marketing@localdomain.net',
'george.morris@consultant.pro; nancy.davis-office@company.io',
'joshua-jones@automobile.car; chloe.taylor+fashion@food.delivery',
'ryan.moore+alerts@music-band.com,isabella.walker.design@fashion.design',
'support-team@company.com, contact.us@domain.org',
'admin.office@industry.biz, hr.department@service.pro',
'feedback@agency-example.org; hello.world@creative-studio.net',
'sales-team@e-commerce.shop, support.department@technology.co',
'media.contact@financial.servicesl; events-coordinator@food.delivery',
'order@music-band.com; info.support@creative.example',
'design.team@webmail.com , admin-office@company.co',
'contact.sales@sub-example.co.uk, support+info@legal.gov',
'support@media.group;subscribe-updates@concert.events'
]
emails.each do |email|
expect { validator.email_format({ email: }, :email) }.not_to raise_error
end
end
it 'when multiple emails are invalid' do
emails = [
'john.doe@example.com dave@nonprofit.org',
'amy.baker@ example.com, george.morris@ example.com',
'jenny.king@example.com . diana.robinson@example.com',
'nancy.davis@.com, henry.ford@.com',
'jack.brown@example.com, laura.martin@example .com',
'anna.jones@example,com lisa.wong@example.com',
'dave.clark@example.com kevin.white@example;com',
'susan.green@ example.com; john.doe@example.com',
'amy.baker@sub_domain.com george.morris@-example.com',
'nancy.davis@example..com john.doe@example.c',
'peter.parker@example.com, .henry.ford@example.com',
'diana.robinson@.example..com, mike.smith@.',
'oliver.scott@example.com; laura.martin@ example.com, jane.doe@@example.com'
]
emails.each do |email|
expect do
validator.email_format({ email: }, :email)
end.to raise_error(described_class::InvalidParameterError, "email must follow the email format: '#{email}'")
end
end
it 'when email is invalid with custom message' do
expect do
validator.email_format({ email: 'jone.doe@' }, :email, message: 'email is invalid')
end.to raise_error(described_class::InvalidParameterError, 'email is invalid')
end
end
end
+134 -10
View File
@@ -3,10 +3,17 @@
require 'rails_helper' require 'rails_helper'
describe 'Submission API', type: :request do describe 'Submission API', type: :request do
let!(:account) { create(:account) } let(:account) { create(:account, :with_testing_account) }
let!(:author) { create(:user, account:) } let(:testing_account) { account.testing_accounts.first }
let!(:folder) { create(:template_folder, account:) } let(:author) { create(:user, account:) }
let!(:templates) { create_list(:template, 2, account:, author:, folder:) } let(:testing_author) { create(:user, account: testing_account) }
let(:folder) { create(:template_folder, account:) }
let(:testing_folder) { create(:template_folder, account: testing_account) }
let(:templates) { create_list(:template, 2, account:, author:, folder:) }
let(:multiple_submitters_template) { create(:template, submitter_count: 3, account:, author:, folder:) }
let(:testing_templates) do
create_list(:template, 2, account: testing_account, author: testing_author, folder: testing_folder)
end
describe 'GET /api/submissions' do describe 'GET /api/submissions' do
it 'returns a list of submissions' do it 'returns a list of submissions' do
@@ -40,6 +47,31 @@ describe 'Submission API', type: :request do
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(response.parsed_body).to eq(JSON.parse(show_submission_body(submission).to_json)) expect(response.parsed_body).to eq(JSON.parse(show_submission_body(submission).to_json))
end end
it 'returns an authorization error if test account API token is used with a production submission' do
submission = create(:submission, :with_submitters, :with_events, template: templates[0], created_by_user: author)
get "/api/submissions/#{submission.id}", headers: { 'x-auth-token': testing_author.access_token.token }
expect(response).to have_http_status(:forbidden)
expect(response.parsed_body).to eq(
JSON.parse({ error: "Submission #{submission.id} not found using testing API key; " \
'Use production API key to access production submissions.' }.to_json)
)
end
it 'returns an authorization error if production account API token is used with a test submission' do
submission = create(:submission, :with_submitters, :with_events, template: testing_templates[0],
created_by_user: testing_author)
get "/api/submissions/#{submission.id}", headers: { 'x-auth-token': author.access_token.token }
expect(response).to have_http_status(:forbidden)
expect(response.parsed_body).to eq(
JSON.parse({ error: "Submission #{submission.id} not found using production API key; " \
'Use testing API key to access testing submissions.' }.to_json)
)
end
end end
describe 'POST /api/submissions' do describe 'POST /api/submissions' do
@@ -47,7 +79,7 @@ describe 'Submission API', type: :request do
post '/api/submissions', headers: { 'x-auth-token': author.access_token.token }, params: { post '/api/submissions', headers: { 'x-auth-token': author.access_token.token }, params: {
template_id: templates[0].id, template_id: templates[0].id,
send_email: true, send_email: true,
submitters: [{ role: 'First Role', email: 'john.doe@example.com' }] submitters: [{ role: 'First Party', email: 'john.doe@example.com' }]
}.to_json }.to_json
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -56,13 +88,16 @@ describe 'Submission API', type: :request do
expect(response.parsed_body).to eq(JSON.parse(create_submission_body(submission).to_json)) expect(response.parsed_body).to eq(JSON.parse(create_submission_body(submission).to_json))
end end
end
describe 'POST /api/submissions/emails' do it 'creates a submission when some submitter roles are not provided' do
it 'creates a submission using email' do
post '/api/submissions', headers: { 'x-auth-token': author.access_token.token }, params: { post '/api/submissions', headers: { 'x-auth-token': author.access_token.token }, params: {
template_id: templates[0].id, template_id: multiple_submitters_template.id,
emails: 'john.doe@example.com' send_email: true,
submitters: [
{ role: 'First Party', email: 'john.doe@example.com' },
{ email: 'jane.doe@example.com' },
{ email: 'mike.doe@example.com' }
]
}.to_json }.to_json
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
@@ -70,6 +105,95 @@ describe 'Submission API', type: :request do
submission = Submission.last submission = Submission.last
expect(response.parsed_body).to eq(JSON.parse(create_submission_body(submission).to_json)) expect(response.parsed_body).to eq(JSON.parse(create_submission_body(submission).to_json))
expect(response.parsed_body).to eq(JSON.parse(create_submission_body(submission).to_json))
expect(response.parsed_body[0]['role']).to eq('First Party')
expect(response.parsed_body[0]['email']).to eq('john.doe@example.com')
expect(response.parsed_body[1]['role']).to eq('Second Party')
expect(response.parsed_body[1]['email']).to eq('jane.doe@example.com')
expect(response.parsed_body[2]['role']).to eq('Third Party')
expect(response.parsed_body[2]['email']).to eq('mike.doe@example.com')
end
it 'returns an error if the submitter email is invalid' do
post '/api/submissions', headers: { 'x-auth-token': author.access_token.token }, params: {
template_id: templates[0].id,
send_email: true,
submitters: [
{ role: 'First Party', email: 'john@example' }
]
}.to_json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body).to eq({ 'error' => 'email is invalid in `submitters[0]`.' })
end
it 'returns an error if the template fields are missing' do
templates[0].update(fields: [])
post '/api/submissions', headers: { 'x-auth-token': author.access_token.token }, params: {
template_id: templates[0].id,
send_email: true,
submitters: [{ role: 'First Party', email: 'john.doe@example.com' }]
}.to_json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body).to eq({ 'error' => 'Template does not contain fields' })
end
it 'returns an error if submitter roles are not unique' do
post '/api/submissions', headers: { 'x-auth-token': author.access_token.token }, params: {
template_id: multiple_submitters_template.id,
send_email: true,
submitters: [
{ role: 'First Party', email: 'john.doe@example.com' },
{ role: 'First Party', email: 'jane.doe@example.com' }
]
}.to_json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body).to eq({ 'error' => 'role must be unique in `submitters`.' })
end
it 'returns an error if number of submitters more than in the template' do
post '/api/submissions', headers: { 'x-auth-token': author.access_token.token }, params: {
template_id: templates[0].id,
send_email: true,
submitters: [
{ email: 'jane.doe@example.com' },
{ role: 'First Party', email: 'john.doe@example.com' }
]
}.to_json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body).to eq({ 'error' => 'Defined more signing parties than in template' })
end
end
describe 'POST /api/submissions/emails' do
it 'creates a submission using email' do
post '/api/submissions/emails', headers: { 'x-auth-token': author.access_token.token }, params: {
template_id: templates[0].id,
emails: 'john.doe@example.com,jane.doe@example.com'
}.to_json
expect(response).to have_http_status(:ok)
submissions = Submission.last(2)
submissions_body = submissions.reduce([]) { |acc, submission| acc + create_submission_body(submission) }
expect(response.parsed_body).to eq(JSON.parse(submissions_body.to_json))
end
it 'returns an error if emails are invalid' do
post '/api/submissions/emails', headers: { 'x-auth-token': author.access_token.token }, params: {
template_id: templates[0].id,
emails: 'amy.baker@example.com, george.morris@example.com@gmail.com'
}.to_json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body).to eq({ 'error' => 'emails are invalid' })
end end
end end
+38 -4
View File
@@ -3,10 +3,16 @@
require 'rails_helper' require 'rails_helper'
describe 'Submitter API', type: :request do describe 'Submitter API', type: :request do
let!(:account) { create(:account) } let(:account) { create(:account, :with_testing_account) }
let!(:author) { create(:user, account:) } let(:testing_account) { account.testing_accounts.first }
let!(:folder) { create(:template_folder, account:) } let(:author) { create(:user, account:) }
let!(:templates) { create_list(:template, 2, account:, author:, folder:) } let(:testing_author) { create(:user, account: testing_account) }
let(:folder) { create(:template_folder, account:) }
let(:testing_folder) { create(:template_folder, account: testing_account) }
let(:templates) { create_list(:template, 2, account:, author:, folder:) }
let(:testing_templates) do
create_list(:template, 2, account: testing_account, author: testing_author, folder: testing_folder)
end
describe 'GET /api/submitters' do describe 'GET /api/submitters' do
it 'returns a list of submitters' do it 'returns a list of submitters' do
@@ -42,6 +48,34 @@ describe 'Submitter API', type: :request do
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(response.parsed_body).to eq(JSON.parse(submitter_body(submitter).to_json)) expect(response.parsed_body).to eq(JSON.parse(submitter_body(submitter).to_json))
end end
it 'returns an authorization error if test account API token is used with a production submitter' do
submitter = create(:submission, :with_submitters, :with_events,
template: templates[0],
created_by_user: author).submitters.first
get "/api/submitters/#{submitter.id}", headers: { 'x-auth-token': testing_author.access_token.token }
expect(response).to have_http_status(:forbidden)
expect(response.parsed_body).to eq(
JSON.parse({ error: "Submitter #{submitter.id} not found using " \
'testing API key; Use production API key to access production submitters.' }.to_json)
)
end
it 'returns an authorization error if production account API token is used with a test submitter' do
submitter = create(:submission, :with_submitters, :with_events,
template: testing_templates[0],
created_by_user: testing_author).submitters.first
get "/api/submitters/#{submitter.id}", headers: { 'x-auth-token': author.access_token.token }
expect(response).to have_http_status(:forbidden)
expect(response.parsed_body).to eq(
JSON.parse({ error: "Submitter #{submitter.id} not found using production API key; " \
'Use testing API key to access testing submitters.' }.to_json)
)
end
end end
describe 'PUT /api/submitters' do describe 'PUT /api/submitters' do
+42 -8
View File
@@ -3,10 +3,12 @@
require 'rails_helper' require 'rails_helper'
describe 'Templates API', type: :request do describe 'Templates API', type: :request do
let!(:account) { create(:account) } let(:account) { create(:account, :with_testing_account) }
let!(:author) { create(:user, account:) } let(:testing_account) { account.testing_accounts.first }
let!(:folder) { create(:template_folder, account:) } let(:author) { create(:user, account:) }
let!(:template_preferences) { { 'request_email_subject' => 'Subject text', 'request_email_body' => 'Body Text' } } let(:testing_author) { create(:user, account: testing_account) }
let(:folder) { create(:template_folder, account:) }
let(:template_preferences) { { 'request_email_subject' => 'Subject text', 'request_email_body' => 'Body Text' } }
describe 'GET /api/templates' do describe 'GET /api/templates' do
it 'returns a list of templates' do it 'returns a list of templates' do
@@ -48,6 +50,38 @@ describe 'Templates API', type: :request do
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(response.parsed_body).to eq(JSON.parse(template_body(template).to_json)) expect(response.parsed_body).to eq(JSON.parse(template_body(template).to_json))
end end
it 'returns an authorization error if test account API token is used with a production template' do
template = create(:template, account:,
author:,
folder:,
external_id: SecureRandom.base58(10),
preferences: template_preferences)
get "/api/templates/#{template.id}", headers: { 'x-auth-token': testing_author.access_token.token }
expect(response).to have_http_status(:forbidden)
expect(response.parsed_body).to eq(
JSON.parse({ error: "Template #{template.id} not found using testing API key; " \
'Use production API key to access production templates.' }.to_json)
)
end
it 'returns an authorization error if production account API token is used with a test template' do
template = create(:template, account: testing_account,
author: testing_author,
folder:,
external_id: SecureRandom.base58(10),
preferences: template_preferences)
get "/api/templates/#{template.id}", headers: { 'x-auth-token': author.access_token.token }
expect(response).to have_http_status(:forbidden)
expect(response.parsed_body).to eq(
JSON.parse({ error: "Template #{template.id} not found using production API key; " \
'Use testing API key to access testing templates.' }.to_json)
)
end
end end
describe 'PUT /api/templates' do describe 'PUT /api/templates' do
@@ -144,8 +178,8 @@ describe 'Templates API', type: :request do
name: template.name, name: template.name,
fields: [ fields: [
{ {
'uuid' => '21637fc9-0655-45df-8952-04ec64949e85', 'uuid' => template.fields[0]['uuid'],
'submitter_uuid' => '513848eb-1096-4abc-a743-68596b5aaa4c', 'submitter_uuid' => template.submitters[0]['uuid'],
'name' => 'First Name', 'name' => 'First Name',
'type' => 'text', 'type' => 'text',
'required' => true, 'required' => true,
@@ -161,8 +195,8 @@ describe 'Templates API', type: :request do
] ]
}, },
{ {
'uuid' => '1f97f8e3-dc82-4586-aeea-6ebed6204e46', 'uuid' => template.fields[1]['uuid'],
'submitter_uuid' => '513848eb-1096-4abc-a743-68596b5aaa4c', 'submitter_uuid' => template.submitters[0]['uuid'],
'name' => '', 'name' => '',
'type' => 'signature', 'type' => 'signature',
'required' => true, 'required' => true,
+41
View File
@@ -0,0 +1,41 @@
# frozen_string_literal: true
require 'rails_helper'
describe 'Tools API', type: :request do
let(:account) { create(:account) }
let(:author) { create(:user, account:) }
let(:file_path) { Rails.root.join('spec/fixtures/sample-document.pdf') }
before do
create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY,
value: GenerateCertificate.call.transform_values(&:to_pem))
end
describe 'POST /api/tools/verify' do
it 'returns a verification result' do
template = create(:template, account:, author:)
submission = create(:submission, :with_submitters, :with_events, template:, created_by_user: author)
blob = ActiveStorage::Blob.create_and_upload!(
io: file_path.open,
filename: 'sample-document.pdf',
content_type: 'application/pdf'
)
create(:completed_document, submitter: submission.submitters.first,
sha256: Base64.urlsafe_encode64(Digest::SHA256.digest(blob.download)))
ActiveStorage::Attachment.create!(
blob:,
name: :documents,
record: submission.submitters.first
)
post '/api/tools/verify', headers: { 'x-auth-token': author.access_token.token }, params: {
file: Base64.encode64(File.read(file_path))
}.to_json
expect(response).to have_http_status(:ok)
expect(response.parsed_body['checksum_status']).to eq('verified')
end
end
end
+2 -1
View File
@@ -13,6 +13,7 @@ RSpec.describe 'API Settings' do
it 'shows verify signed PDF page' do it 'shows verify signed PDF page' do
expect(page).to have_content('API') expect(page).to have_content('API')
expect(page).to have_field('X-Auth-Token', with: user.access_token.token) token = user.access_token.token
expect(page).to have_field('X-Auth-Token', with: token.sub(token[5..], '*' * token[5..].size))
end end
end end
+5 -5
View File
@@ -3,9 +3,9 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Submit Form' do RSpec.describe 'Submit Form' do
let!(:account) { create(:account) } let(:account) { create(:account) }
let!(:user) { create(:user, account:) } let(:user) { create(:user, account:) }
let!(:template) { create(:template, account:, author: user) } let(:template) { create(:template, account:, author: user) }
before do before do
sign_in(user) sign_in(user)
@@ -48,8 +48,8 @@ RSpec.describe 'Submit Form' do
end end
context 'when initialized by shared email address' do context 'when initialized by shared email address' do
let!(:submission) { create(:submission, template:, created_by_user: user) } let(:submission) { create(:submission, template:, created_by_user: user) }
let!(:submitters) { template.submitters.map { |s| create(:submitter, submission:, uuid: s['uuid']) } } let(:submitters) { template.submitters.map { |s| create(:submitter, submission:, uuid: s['uuid']) } }
let(:submitter) { submitters.first } let(:submitter) { submitters.first }
before do before do
+62 -2
View File
@@ -18,13 +18,19 @@ RSpec.describe 'Team Settings' do
visit settings_users_path visit settings_users_path
end end
it 'shows all users' do it 'shows only active users' do
within '.table' do within '.table' do
users.each do |user| users.each do |user|
expect(page).to have_content(user.full_name) expect(page).to have_content(user.full_name)
expect(page).to have_content(user.email) expect(page).to have_content(user.email)
expect(page).to have_no_content(other_user.email) expect(page).to have_link('Edit', href: edit_user_path(user))
end end
expect(page).to have_button('Remove')
expect(page).to have_no_button('Unarchive')
expect(page).to have_no_content(other_user.full_name)
expect(page).to have_no_content(other_user.email)
end end
end end
@@ -89,4 +95,58 @@ RSpec.describe 'Team Settings' do
expect(page).to have_no_content('User has been removed') expect(page).to have_no_content('User has been removed')
end end
end end
context 'when some users are archived' do
let!(:users) { create_list(:user, 2, account:) }
let!(:archived_users) { create_list(:user, 2, account:, archived_at: Time.current) }
let!(:other_user) { create(:user) }
it 'shows only active users' do
visit settings_users_path
within '.table' do
users.each do |user|
expect(page).to have_content(user.full_name)
expect(page).to have_content(user.email)
end
archived_users.each do |user|
expect(page).to have_no_content(user.full_name)
expect(page).to have_no_content(user.email)
end
expect(page).to have_no_content(other_user.full_name)
expect(page).to have_no_content(other_user.email)
end
expect(page).to have_link('View Archived', href: settings_archived_users_path)
end
it 'shows only archived users' do
visit settings_archived_users_path
within '.table' do
archived_users.each do |user|
expect(page).to have_content(user.full_name)
expect(page).to have_content(user.email)
expect(page).to have_no_link('Edit', href: edit_user_path(user))
end
users.each do |user|
expect(page).to have_no_content(user.full_name)
expect(page).to have_no_content(user.email)
expect(page).to have_no_link('Edit', href: edit_user_path(user))
end
expect(page).to have_button('Unarchive')
expect(page).to have_no_button('Remove')
expect(page).to have_no_content(other_user.full_name)
expect(page).to have_no_content(other_user.email)
end
expect(page).to have_content('Archived Users')
expect(page).to have_link('View Active', href: settings_users_path)
end
end
end end