Compare commits

...
32 Commits
Author SHA1 Message Date
DocuSeal df561a6211 update rubocop 2023-10-30 00:56:44 +02:00
DocuSeal 50d3404f3b force ssl only if host passed 2023-10-29 21:36:30 +02:00
DocuSeal 607fc8d338 allow to permanently remove templates 2023-10-29 21:36:30 +02:00
DocuSeal 8c366dfc38 use account timezone in audit trail 2023-10-29 21:36:30 +02:00
DocuSeal 5ed08fa9ab add sso settings placeholder 2023-10-29 21:36:30 +02:00
DocuSeal ddb557ebef upload template from URL 2023-10-29 21:36:30 +02:00
DocuSeal b48615df3f use handwritten font for typed signature 2023-10-29 21:36:29 +02:00
DocuSeal 86c7b92675 apply previous signature and initials on the next field 2023-10-28 01:56:22 +03:00
DocuSeal 041f362cc4 do not allow to edit phone if already specified 2023-10-28 00:53:16 +03:00
DocuSeal 65f491325e adjust configs loading 2023-10-28 00:42:33 +03:00
DocuSeal 57aef5f5e5 refactor date step 2023-10-26 21:26:08 +03:00
Alex Turchyn e704e6fa04 show edit icon on hover the outer box 2023-10-25 20:09:34 +03:00
DocuSeal 4c99bbb58a add onComplete form callback 2023-10-25 02:06:47 +03:00
DocuSeal c0647eef2c process 15 pages after pdf upload 2023-10-24 23:48:26 +03:00
DocuSeal c619b9be93 move plans link 2023-10-24 23:16:02 +03:00
DocuSeal abab3f9688 do not use libvips pdf on large files 2023-10-24 22:31:09 +03:00
DocuSeal 7b15111e8f use temp file for puma pid 2023-10-24 13:36:50 +03:00
DocuSeal 2c7f38c503 optimize mailer attachments 2023-10-24 12:41:12 +03:00
Alex Turchyn ed80df1372 fix audit trail images generation 2023-10-24 00:46:47 +03:00
DocuSeal 3614d1796c do not split signature pad js in form 2023-10-22 23:26:43 +03:00
DocuSeal ce39494f84 optimize for large files 2023-10-22 22:33:54 +03:00
DocuSeal c639a3f733 do not sign in after reset password if 2fa
https://github.com/devise-two-factor/devise-two-factor#disabling-automatic-login-after-password-resets
thanks Greg Molnar
2023-10-22 13:19:47 +03:00
DocuSeal 74bef133b3 add enqueries checkbox 2023-10-21 15:33:10 +03:00
DocuSeal 33142966dc allow to resubmit form via shared link 2023-10-21 13:06:59 +03:00
DocuSeal 42ec8d5fc9 do not allow to skip required fields 2023-10-21 12:09:50 +03:00
DocuSeal 01d752e811 fix webhook serializer 2023-10-21 12:09:37 +03:00
DocuSeal 99b33d4165 allow to resubmit form via shared link 2023-10-20 22:55:25 +03:00
DocuSeal 11a90d797a add user encrypted configs 2023-10-20 13:16:39 +03:00
DocuSeal 9da7463657 add submission status filters 2023-10-20 00:13:17 +03:00
Alex Turchyn b6d350ebbd fix no error messages when sign in with wrong credentials 2023-10-18 22:56:28 +03:00
DocuSeal 9e87a0373f add application key to submitters 2023-10-18 02:14:44 +03:00
DocuSeal aee55165c5 use start page 2023-10-16 21:21:04 +03:00
87 changed files with 927 additions and 246 deletions

No files matched your search

+6
View File
@@ -67,8 +67,14 @@ RSpec/MultipleMemoizedHelpers:
Rails/I18nLocaleTexts:
Enabled: false
Rails/FindEach:
Enabled: false
Rails/SkipsModelValidations:
Enabled: false
Rails/ApplicationController:
Enabled: false
Capybara/ClickLinkOrButtonStyle:
Enabled: false
+2 -1
View File
@@ -31,7 +31,7 @@ ENV BUNDLE_WITHOUT="development:test"
WORKDIR /app
RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler vips-heif libc6-compat ttf-freefont ttf-liberation && cp /usr/share/fonts/liberation/LiberationSans-Regular.ttf /usr/share/fonts/liberation/LiberationSans-Bold.ttf / && apk del ttf-liberation
RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils vips-heif libc6-compat ttf-freefont ttf-liberation && mkdir /fonts && cp /usr/share/fonts/liberation/LiberationSans-Regular.ttf /usr/share/fonts/liberation/LiberationSans-Bold.ttf /fonts && apk del ttf-liberation && wget -O /fonts/DancingScript.otf "https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf" && wget -O /fonts/DancingScript-License.txt https://github.com/impallari/DancingScript/blob/master/OFL.txt
COPY ./Gemfile ./Gemfile.lock ./
@@ -49,6 +49,7 @@ COPY LICENSE README.md Rakefile config.ru ./
COPY --from=webpack /app/public/packs ./public/packs
RUN ln -s /fonts /app/public/fonts
RUN bundle exec bootsnap precompile --gemfile app/ lib/
WORKDIR /data/docuseal
+13 -15
View File
@@ -98,7 +98,6 @@ GEM
faraday_middleware (~> 1.0, >= 1.0.0.rc1)
net-http-persistent (~> 4.0)
nokogiri (~> 1, >= 1.10.8)
base64 (0.1.1)
bcrypt (3.1.19)
better_html (2.0.2)
actionview (>= 6.0)
@@ -294,7 +293,7 @@ GEM
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.4)
minitest (5.19.0)
minitest (5.20.0)
msgpack (1.7.2)
multi_json (1.15.0)
multi_xml (0.6.0)
@@ -345,7 +344,7 @@ GEM
os (1.1.4)
pagy (6.0.4)
parallel (1.23.0)
parser (3.2.2.3)
parser (3.2.2.4)
ast (~> 2.4.1)
racc
pdf-reader (2.11.0)
@@ -418,7 +417,7 @@ GEM
rake (13.0.6)
redis-client (0.16.0)
connection_pool
regexp_parser (2.8.1)
regexp_parser (2.8.2)
reline (0.3.8)
io-console (~> 0.5)
representable (3.2.0)
@@ -455,33 +454,32 @@ GEM
rspec-mocks (~> 3.12)
rspec-support (~> 3.12)
rspec-support (3.12.1)
rubocop (1.56.1)
base64 (~> 0.1.1)
rubocop (1.57.2)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.2.2.3)
parser (>= 3.2.2.4)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.28.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.29.0)
rubocop-ast (1.30.0)
parser (>= 3.2.1.0)
rubocop-capybara (2.18.0)
rubocop-capybara (2.19.0)
rubocop (~> 1.41)
rubocop-factory_bot (2.23.1)
rubocop-factory_bot (2.24.0)
rubocop (~> 1.33)
rubocop-performance (1.19.0)
rubocop-performance (1.19.1)
rubocop (>= 1.7.0, < 2.0)
rubocop-ast (>= 0.4.0)
rubocop-rails (2.20.2)
rubocop-rails (2.22.1)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0)
rubocop-rspec (2.23.2)
rubocop (~> 1.33)
rubocop-rspec (2.25.0)
rubocop (~> 1.40)
rubocop-capybara (~> 2.17)
rubocop-factory_bot (~> 2.22)
ruby-progressbar (1.13.0)
@@ -537,7 +535,7 @@ GEM
tzinfo-data (1.2023.3)
tzinfo (>= 1.0.0)
uber (0.1.0)
unicode-display_width (2.4.2)
unicode-display_width (2.5.0)
uniform_notifier (1.16.0)
version_gem (1.1.3)
warden (1.2.9)
@@ -106,7 +106,7 @@ module Api
def submissions_params
params.permit(submission: [{
submitters: [[:uuid, :name, :email, :role, :completed, :phone,
submitters: [[:uuid, :name, :email, :role, :completed, :phone, :application_key,
{ values: {}, readonly_fields: [],
fields: [%i[name default_value readonly validation_pattern invalid_message]] }]]
}])
@@ -4,6 +4,27 @@ module Api
class SubmittersController < ApiBaseController
load_and_authorize_resource :submitter
def index
submitters = Submitters.search(@submitters, params[:q])
submitters = submitters.where(application_key: params[:application_key]) if params[:application_key].present?
submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present?
submitters = paginate(
submitters.preload(:template, :submission, :submission_events,
documents_attachments: :blob, attachments_attachments: :blob)
)
render json: {
data: submitters.map { |s| Submitters::SerializeForApi.call(s, with_template: true, with_events: true) },
pagination: {
count: submitters.size,
next: submitters.last&.id,
prev: submitters.first&.id
}
}
end
def show
Submissions::EnsureResultGenerated.call(@submitter) if @submitter.completed_at?
@@ -16,6 +16,7 @@ module Api
render json: {
schema:,
documents: documents.as_json(
methods: [:metadata],
include: {
preview_images: { methods: %i[url metadata filename] }
}
+22
View File
@@ -0,0 +1,22 @@
# frozen_string_literal: true
class EnquiriesController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
def create
if params[:talk_to_sales] == 'on'
Faraday.post(Docuseal::ENQUIRIES_URL,
enquiry_params.merge(type: :talk_to_sales).to_json,
'Content-Type' => 'application/json')
end
head :ok
end
private
def enquiry_params
params.require(:user).permit(:email)
end
end
+13
View File
@@ -0,0 +1,13 @@
# frozen_string_literal: true
class PasswordsController < Devise::PasswordsController
class Current < ActiveSupport::CurrentAttributes
attribute :user
end
def update
super do |resource|
Current.user = resource
end
end
end
@@ -0,0 +1,46 @@
# frozen_string_literal: true
class PreviewDocumentPageController < ActionController::API
include ActiveStorage::SetCurrent
FORMAT = Templates::ProcessDocument::FORMAT
def show
attachment = ActiveStorage::Attachment.find_by(uuid: params[:attachment_uuid])
return head :not_found unless attachment
preview_image = attachment.preview_images.joins(:blob).find_by(blob: { filename: "#{params[:id]}#{FORMAT}" })
return redirect_to preview_image.url, allow_other_host: true if preview_image
file_path =
if attachment.service.name == :disk
ActiveStorage::Blob.service.path_for(attachment.key)
else
find_or_create_document_tempfile_path(attachment)
end
io = Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i)
render plain: io.tap(&:rewind).read
end
def find_or_create_document_tempfile_path(attachment)
file_path = "#{Dir.tmpdir}/#{attachment.uuid}"
File.open(file_path, File::RDWR | File::CREAT, 0o644) do |f|
f.flock(File::LOCK_EX)
# rubocop:disable Style/ZeroLengthPredicate
if f.size.zero?
f.binmode
f.write(attachment.download)
end
# rubocop:enable Style/ZeroLengthPredicate
end
file_path
end
end
+3 -3
View File
@@ -31,10 +31,10 @@ class RegistrationsController < Devise::RegistrationsController
redirect_to after_sign_up_path_for(current_user), allow_other_host: true
end
def require_no_authentication
super
def set_flash_message(key, kind, options = {})
return if key == :alert && kind == 'already_authenticated'
flash.clear
super
end
def build_resource(_hash = {})
+3 -3
View File
@@ -32,9 +32,9 @@ class SessionsController < Devise::SessionsController
devise_parameter_sanitizer.permit(:sign_in, keys: [:otp_attempt])
end
def require_no_authentication
super
def set_flash_message(key, kind, options = {})
return if key == :alert && kind == 'already_authenticated'
flash.clear
super
end
end
@@ -0,0 +1,16 @@
# frozen_string_literal: true
class SsoSettingsController < ApplicationController
before_action :load_encrypted_config
authorize_resource :encrypted_config, only: :index
authorize_resource :encrypted_config, parent: false, except: :index
def index; end
private
def load_encrypted_config
@encrypted_config =
EncryptedConfig.find_or_initialize_by(account: current_account, key: 'saml_configs')
end
end
+3 -2
View File
@@ -14,7 +14,8 @@ class StartFormController < ApplicationController
def update
@submitter = Submitter.where(submission: @template.submissions.where(deleted_at: nil))
.find_or_initialize_by(email: submitter_params[:email])
.then { |rel| params[:resubmit].present? ? rel.where(completed_at: nil) : rel }
.find_or_initialize_by(**submitter_params.compact_blank)
if @submitter.completed_at?
redirect_to start_form_completed_path(@template.slug, email: submitter_params[:email])
@@ -46,7 +47,7 @@ class StartFormController < ApplicationController
private
def submitter_params
params.require(:submitter).permit(:email).tap do |attrs|
params.require(:submitter).permit(:email, :phone).tap do |attrs|
attrs[:email] = Submissions.normalize_email(attrs[:email])
end
end
+13 -1
View File
@@ -6,12 +6,24 @@ class SubmissionsController < ApplicationController
load_and_authorize_resource :submission, only: %i[show destroy]
PRELOAD_ALL_PAGES_AMOUNT = 200
def show
ActiveRecord::Associations::Preloader.new(
records: [@submission],
associations: [:template, { template_schema_documents: [:blob, { preview_images_attachments: :blob }] }]
associations: [:template, { template_schema_documents: :blob }]
).call
total_pages =
@submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i }
if total_pages < PRELOAD_ALL_PAGES_AMOUNT
ActiveRecord::Associations::Preloader.new(
records: @submission.template_schema_documents,
associations: [:blob, { preview_images_attachments: :blob }]
).call
end
render :show, layout: 'plain'
end
+18 -5
View File
@@ -6,15 +6,28 @@ class SubmitFormController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
PRELOAD_ALL_PAGES_AMOUNT = 200
def show
@submitter =
Submitter.preload(submission: [
:template, { template_schema_documents: [:blob, { preview_images_attachments: :blob }] }
])
.find_by!(slug: params[:slug])
@submitter = Submitter.find_by!(slug: params[:slug])
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
ActiveRecord::Associations::Preloader.new(
records: [@submitter],
associations: [submission: [:template, { template_schema_documents: :blob }]]
).call
total_pages =
@submitter.submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i }
if total_pages < PRELOAD_ALL_PAGES_AMOUNT
ActiveRecord::Associations::Preloader.new(
records: @submitter.submission.template_schema_documents,
associations: [:blob, { preview_images_attachments: :blob }]
).call
end
Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user)
cookies[:submitter_sid] = @submitter.signed_id
+24 -2
View File
@@ -10,6 +10,11 @@ class TemplatesController < ApplicationController
submissions = submissions.active if @template.deleted_at.blank?
submissions = Submissions.search(submissions, params[:q])
@base_submissions = submissions
submissions = submissions.pending if params[:status] == 'pending'
submissions = submissions.completed if params[:status] == 'completed'
@pagy, @submissions = pagy(submissions.preload(:submitters).order(id: :desc))
rescue ActiveRecord::RecordNotFound
redirect_to root_path
@@ -25,6 +30,14 @@ class TemplatesController < ApplicationController
associations: [schema_documents: { preview_images_attachments: :blob }]
).call
@template_data =
@template.as_json.merge(
documents: @template.schema_documents.as_json(
methods: [:metadata],
include: { preview_images: { methods: %i[url metadata filename] } }
)
).to_json
render :edit, layout: 'plain'
end
@@ -44,9 +57,18 @@ class TemplatesController < ApplicationController
end
def destroy
@template.update!(deleted_at: Time.current)
notice =
if !Docuseal.multitenant? && params[:permanently].present?
@template.destroy!
redirect_back(fallback_location: root_path, notice: 'Template has been archived.')
'Template has been removed.'
else
@template.update!(deleted_at: Time.current)
'Template has been archived.'
end
redirect_back(fallback_location: root_path, notice:)
end
private
@@ -3,20 +3,55 @@
class TemplatesUploadsController < ApplicationController
load_and_authorize_resource :template, parent: false
layout 'plain'
def show; end
def create
url_params = create_file_params_from_url if params[:url].present?
@template.account = current_account
@template.author = current_user
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
@template.name = File.basename(params[:files].first.original_filename, '.*')
@template.name = File.basename((url_params || params)[:files].first.original_filename, '.*')
@template.save!
documents = Templates::CreateAttachments.call(@template, params)
documents = Templates::CreateAttachments.call(@template, url_params || params)
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
@template.update!(schema:)
redirect_to edit_template_path(@template)
rescue StandardError => e
Rollbar.error(e) if defined?(Rollbar)
redirect_to root_path, alert: 'Unable to upload file'
end
private
def create_file_params_from_url
tempfile = Tempfile.new
tempfile.binmode
tempfile.write(conn.get(params[:url]).body)
tempfile.rewind
file = ActionDispatch::Http::UploadedFile.new(
tempfile:,
filename: File.basename(
URI.decode_www_form_component(params[:filename].presence || params[:url])
),
type: Marcel::MimeType.for(tempfile)
)
{ files: [file] }
end
def conn
Faraday.new do |faraday|
faraday.response :follow_redirects
end
end
end
@@ -12,7 +12,7 @@ class VerifyPdfSignatureController < ApplicationController
cert_data = if Docuseal.multitenant?
Docuseal::CERTS
else
EncryptedConfig.find_by(account: current_account, key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || {}
EncryptedConfig.find_by(key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || {}
end
default_pkcs = GenerateCertificate.load_pkcs(cert_data)
+2
View File
@@ -18,6 +18,7 @@ import AutoresizeTextarea from './elements/autoresize_textarea'
import SubmittersAutocomplete from './elements/submitter_autocomplete'
import FolderAutocomplete from './elements/folder_autocomplete'
import SignatureForm from './elements/signature_form'
import SubmitForm from './elements/submit_form'
import * as TurboInstantClick from './lib/turbo_instant_click'
@@ -49,6 +50,7 @@ window.customElements.define('autoresize-textarea', AutoresizeTextarea)
window.customElements.define('submitters-autocomplete', SubmittersAutocomplete)
window.customElements.define('folder-autocomplete', FolderAutocomplete)
window.customElements.define('signature-form', SignatureForm)
window.customElements.define('submit-form', SubmitForm)
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
document.addEventListener('turbo:submit-end', async (event) => {
+5
View File
@@ -0,0 +1,5 @@
export default class extends HTMLElement {
connectedCallback () {
this.querySelector('form').requestSubmit()
}
}
+1 -1
View File
@@ -78,7 +78,7 @@
>
{{ t('signed_with') }}
<a
href="https://www.docuseal.co"
href="https://www.docuseal.co/start"
target="_blank"
class="underline"
>DocuSeal</a> - {{ t('open_source_documents_software') }}
@@ -0,0 +1,73 @@
<template>
<div>
<div class="flex justify-between items-center w-full mb-2">
<label
:for="field.uuid"
class="label text-2xl"
>{{ field.name || t('date') }}
<template v-if="!field.required">({{ t('optional') }})</template>
</label>
<button
class="btn btn-outline btn-sm !normal-case font-normal"
@click.prevent="setCurrentDate"
>
<IconCalendarCheck :width="16" />
{{ t('set_today') }}
</button>
</div>
<div class="text-center">
<input
ref="input"
v-model="value"
class="base-input !text-2xl text-center w-full"
:required="field.required"
type="date"
:name="`values[${field.uuid}]`"
@focus="$emit('focus')"
>
</div>
</div>
</template>
<script>
import { IconCalendarCheck } from '@tabler/icons-vue'
export default {
name: 'DateStep',
components: {
IconCalendarCheck
},
inject: ['t'],
props: {
field: {
type: Object,
required: true
},
modelValue: {
type: String,
required: false,
default: ''
}
},
emits: ['update:model-value', 'focus'],
computed: {
value: {
set (value) {
this.$emit('update:model-value', value)
},
get () {
return this.modelValue
}
}
},
methods: {
setCurrentDate () {
const inputEl = this.$refs.input
inputEl.valueAsDate = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000)
inputEl.dispatchEvent(new Event('input', { bubbles: true }))
}
}
}
</script>
+37 -40
View File
@@ -65,34 +65,13 @@
@focus="$refs.areas.scrollIntoField(currentField)"
/>
</div>
<div v-else-if="currentField.type === 'date'">
<div class="flex justify-between items-center w-full mb-2">
<label
:for="currentField.uuid"
class="label text-2xl"
>{{ currentField.name || t('date') }}
<template v-if="!currentField.required">({{ t('optional') }})</template>
</label>
<button
class="btn btn-outline btn-sm !normal-case font-normal"
@click.prevent="setCurrentDate"
>
<IconCalendarCheck :width="16" />
{{ t('set_today') }}
</button>
</div>
<div class="text-center">
<input
:id="currentField.uuid"
v-model="values[currentField.uuid]"
class="base-input !text-2xl text-center w-full"
:required="currentField.required"
type="date"
:name="`values[${currentField.uuid}]`"
@focus="$refs.areas.scrollIntoField(currentField)"
>
</div>
</div>
<DateStep
v-else-if="currentField.type === 'date'"
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:field="currentField"
@focus="$refs.areas.scrollIntoField(currentField)"
/>
<div v-else-if="currentField.type === 'select'">
<label
v-if="currentField.name"
@@ -242,6 +221,7 @@
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:field="currentField"
:previous-value="previousSignatureValue"
:is-direct-upload="isDirectUpload"
:attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug"
@@ -255,6 +235,7 @@
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:field="currentField"
:previous-value="previousInitialsValue"
:is-direct-upload="isDirectUpload"
:attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug"
@@ -350,8 +331,9 @@ import AttachmentStep from './attachment_step'
import MultiSelectStep from './multi_select_step'
import PhoneStep from './phone_step'
import TextStep from './text_step'
import DateStep from './date_step'
import FormCompleted from './completed'
import { IconInnerShadowTop, IconArrowsDiagonal, IconArrowsDiagonalMinimize2, IconCalendarCheck } from '@tabler/icons-vue'
import { IconInnerShadowTop, IconArrowsDiagonal, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import { t } from './i18n'
export default {
@@ -364,10 +346,10 @@ export default {
InitialsStep,
MultiSelectStep,
IconInnerShadowTop,
DateStep,
IconArrowsDiagonal,
TextStep,
PhoneStep,
IconCalendarCheck,
IconArrowsDiagonalMinimize2,
FormCompleted
},
@@ -392,6 +374,13 @@ export default {
required: false,
default: () => []
},
onComplete: {
type: Function,
required: false,
default () {
return () => {}
}
},
withConfetti: {
type: Boolean,
required: false,
@@ -425,7 +414,7 @@ export default {
allowToSkip: {
type: Boolean,
required: false,
default: true
default: false
},
goToLast: {
type: Boolean,
@@ -461,7 +450,6 @@ export default {
currentStep: 0,
isSubmitting: false,
submittedValues: {},
isSecondWalkthrough: false,
recalculateButtonDisabledKey: ''
}
},
@@ -472,6 +460,16 @@ export default {
submitterSlug () {
return this.submitter.slug
},
previousSignatureValue () {
const signatureField = [...this.fields].reverse().find((field) => field.type === 'signature' && !!this.values[field.uuid])
return this.values[signatureField?.uuid]
},
previousInitialsValue () {
const initialsField = [...this.fields].reverse().find((field) => field.type === 'initials' && !!this.values[field.uuid])
return this.values[initialsField?.uuid]
},
isAnonymousChecboxes () {
return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name) && this.currentStepFields.length > 4
},
@@ -636,13 +634,6 @@ export default {
}
})
},
setCurrentDate () {
const inputEl = document.getElementById(this.currentField.uuid)
inputEl.valueAsDate = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000)
inputEl.dispatchEvent(new Event('input', { bubbles: true }))
},
saveStep (formData) {
if (this.isCompleted) {
return Promise.resolve({})
@@ -662,7 +653,7 @@ export default {
stepPromise().then(async () => {
const emptyRequiredField = this.stepFields.find((fields, index) => {
return index < this.currentStep && fields[0].required && (fields[0].type === 'phone' || !this.allowToSkip || !this.isSecondWalkthrough) && !this.submittedValues[fields[0].uuid]
return index < this.currentStep && fields[0].required && (fields[0].type === 'phone' || !this.allowToSkip) && !this.submittedValues[fields[0].uuid]
})
const formData = new FormData(this.$refs.form)
@@ -697,6 +688,12 @@ export default {
}
} else {
this.isCompleted = true
const respData = await response.text()
if (respData) {
this.onComplete(JSON.parse(respData))
}
}
}).catch(error => {
console.error('Error submitting form:', error)
@@ -20,7 +20,7 @@
</a>
</span>
<a
v-if="modelValue"
v-if="modelValue || computedPreviousValue"
href="#"
class="btn btn-outline btn-sm"
@click.prevent="remove"
@@ -51,26 +51,26 @@
</div>
</div>
<input
:value="modelValue"
:value="modelValue || computedPreviousValue"
type="hidden"
:name="`values[${field.uuid}]`"
>
<img
v-if="modelValue"
:src="attachmentsIndex[modelValue].url"
v-if="modelValue || computedPreviousValue"
:src="attachmentsIndex[modelValue || computedPreviousValue].url"
class="mx-auto bg-white border border-base-300 rounded max-h-72"
>
<canvas
v-show="!modelValue"
v-show="!modelValue && !computedPreviousValue"
ref="canvas"
class="bg-white border border-base-300 rounded"
/>
<input
v-if="!isDrawInitials"
v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
id="initials_text_input"
ref="textInput"
class="base-input !text-2xl w-full mt-6 text-center"
:required="field.required && !isInitialsStarted && !modelValue"
:required="field.required && !isInitialsStarted"
:placeholder="`${t('type_initial_here')}...`"
type="text"
@focus="$emit('focus')"
@@ -82,6 +82,7 @@
<script>
import { cropCanvasAndExportToPNG } from './crop_canvas'
import { IconReload, IconSignature, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import SignaturePad from 'signature_pad'
export default {
name: 'InitialsStep',
@@ -110,6 +111,11 @@ export default {
required: false,
default: () => ({})
},
previousValue: {
type: String,
required: false,
default: ''
},
modelValue: {
type: String,
required: false,
@@ -119,10 +125,20 @@ export default {
emits: ['attached', 'update:model-value', 'start', 'minimize', 'focus'],
data () {
return {
isInitialsStarted: false,
isInitialsStarted: !!this.previousValue,
isUsePreviousValue: true,
isDrawInitials: false
}
},
computed: {
computedPreviousValue () {
if (this.isUsePreviousValue) {
return this.previousValue
} else {
return null
}
}
},
async mounted () {
this.$nextTick(() => {
if (this.$refs.canvas) {
@@ -137,8 +153,6 @@ export default {
import('@rails/activestorage')
}
const { default: SignaturePad } = await import('signature_pad')
if (this.$refs.canvas) {
this.pad = new SignaturePad(this.$refs.canvas)
@@ -152,6 +166,9 @@ export default {
methods: {
remove () {
this.$emit('update:model-value', '')
this.isUsePreviousValue = false
this.isInitialsStarted = false
},
clear () {
this.pad.clear()
@@ -193,7 +210,11 @@ export default {
}
},
async submit () {
if (this.modelValue) {
if (this.modelValue || this.computedPreviousValue) {
if (this.computedPreviousValue) {
this.$emit('update:model-value', this.computedPreviousValue)
}
return Promise.resolve({})
}
@@ -28,6 +28,7 @@
>
<div class="flex justify-between mt-2 -mb-2 md:-mb-4">
<a
v-if="!defaultValue"
href="#"
class="link"
@click.prevent="isCodeSent = false"
@@ -48,6 +49,7 @@
:id="field.uuid"
ref="phone"
:value="modelValue || defaultValue"
:readonly="!!defaultValue"
class="base-input !text-2xl w-full"
autocomplete="tel"
pattern="^\+[0-9\s\-]+$"
@@ -9,14 +9,15 @@
class="tooltip"
data-tip="Type text"
>
<button
<a
id="type_text_button"
href="#"
class="btn btn-sm btn-circle"
:class="{ 'btn-neutral': isTextSignature, 'btn-outline': !isTextSignature }"
@click.prevent="toggleTextInput"
>
<IconTextSize :width="16" />
</button>
</a>
</span>
<span
class="tooltip"
@@ -34,23 +35,26 @@
>
</label>
</span>
<button
v-if="modelValue"
<a
v-if="modelValue || computedPreviousValue"
href="#"
class="btn btn-outline btn-sm"
@click.prevent="remove"
>
<IconReload :width="16" />
{{ t('redraw') }}
</button>
<button
</a>
<a
v-else
href="#"
class="btn btn-outline btn-sm"
@click.prevent="clear"
>
<IconReload :width="16" />
{{ t('clear') }}
</button>
<button
</a>
<a
href="#"
title="Minimize"
class="py-1.5 inline md:hidden"
@click.prevent="$emit('minimize')"
@@ -59,21 +63,21 @@
:width="20"
:height="20"
/>
</button>
</a>
</div>
</div>
<input
:value="modelValue"
:value="modelValue || computedPreviousValue"
type="hidden"
:name="`values[${field.uuid}]`"
>
<img
v-if="modelValue"
:src="attachmentsIndex[modelValue].url"
v-if="modelValue || computedPreviousValue"
:src="attachmentsIndex[modelValue || computedPreviousValue].url"
class="mx-auto bg-white border border-base-300 rounded max-h-72"
>
<canvas
v-show="!modelValue"
v-show="!modelValue && !computedPreviousValue"
ref="canvas"
class="bg-white border border-base-300 rounded"
/>
@@ -93,6 +97,9 @@
<script>
import { IconReload, IconCamera, IconTextSize, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import { cropCanvasAndExportToPNG } from './crop_canvas'
import SignaturePad from 'signature_pad'
let isFontLoaded = false
export default {
name: 'SignatureStep',
@@ -122,6 +129,11 @@ export default {
required: false,
default: () => ({})
},
previousValue: {
type: String,
required: false,
default: ''
},
modelValue: {
type: String,
required: false,
@@ -131,10 +143,20 @@ export default {
emits: ['attached', 'update:model-value', 'start', 'minimize'],
data () {
return {
isSignatureStarted: false,
isSignatureStarted: !!this.previousValue,
isUsePreviousValue: true,
isTextSignature: false
}
},
computed: {
computedPreviousValue () {
if (this.isUsePreviousValue) {
return this.previousValue
} else {
return null
}
}
},
async mounted () {
this.$nextTick(() => {
if (this.$refs.canvas) {
@@ -147,8 +169,6 @@ export default {
import('@rails/activestorage')
}
const { default: SignaturePad } = await import('signature_pad')
if (this.$refs.canvas) {
this.pad = new SignaturePad(this.$refs.canvas)
@@ -162,6 +182,22 @@ export default {
methods: {
remove () {
this.$emit('update:model-value', '')
this.isUsePreviousValue = false
this.isSignatureStarted = false
},
loadFont () {
if (!isFontLoaded) {
const font = new FontFace('Dancing Script', `url(${this.baseUrl}/fonts/DancingScript.otf) format("opentype")`)
font.load().then((loadedFont) => {
document.fonts.add(loadedFont)
isFontLoaded = true
}).catch((error) => {
console.error('Font loading failed:', error)
})
}
},
clear () {
this.pad.clear()
@@ -178,8 +214,8 @@ export default {
const canvas = this.$refs.canvas
const context = canvas.getContext('2d')
const fontFamily = 'Arial'
const fontSize = '44px'
const fontFamily = 'Dancing Script'
const fontSize = '38px'
const fontStyle = 'italic'
const fontWeight = ''
@@ -197,6 +233,8 @@ export default {
this.$nextTick(() => {
this.$refs.textInput.focus()
this.loadFont()
this.$emit('start')
})
}
@@ -250,7 +288,11 @@ export default {
}
},
async submit () {
if (this.modelValue) {
if (this.modelValue || this.computedPreviousValue) {
if (this.computedPreviousValue) {
this.$emit('update:model-value', this.computedPreviousValue)
}
return Promise.resolve({})
}
+19 -7
View File
@@ -31,6 +31,7 @@
:href="`/templates/${template.id}/submissions/new`"
data-turbo-frame="modal"
class="btn btn-primary text-base"
@click="maybeShowEmptyTemplateAlert"
>
<IconUsersPlus
width="20"
@@ -691,14 +692,25 @@ export default {
this.save()
},
onSaveClick () {
this.isSaving = true
maybeShowEmptyTemplateAlert (e) {
if (!this.template.fields.length) {
e.preventDefault()
this.save().then(() => {
window.Turbo.visit(`/templates/${this.template.id}`)
}).finally(() => {
this.isSaving = false
})
alert('Please draw fields to prepare the document.')
}
},
onSaveClick () {
if (this.template.fields.length) {
this.isSaving = true
this.save().then(() => {
window.Turbo.visit(`/templates/${this.template.id}`)
}).finally(() => {
this.isSaving = false
})
} else {
alert('Please draw fields to prepare the document.')
}
},
scrollToArea (area) {
const documentRef = this.documentRefs.find((a) => a.document.uuid === area.attachment_uuid)
@@ -24,7 +24,7 @@
*
</span>
<IconPencil
class="cursor-pointer flex-none opacity-0 group-hover/contenteditable:opacity-100 align-middle peer-focus:hidden"
class="cursor-pointer flex-none opacity-0 group-hover/contenteditable-container:opacity-100 group-hover/contenteditable:opacity-100 align-middle peer-focus:hidden"
:style="iconInline ? {} : { right: -(1.1 * iconWidth) + 'px' }"
title="Edit"
:class="{ 'ml-1': !withRequired, 'absolute': !iconInline, 'inline align-bottom': iconInline }"
+19 -1
View File
@@ -56,8 +56,26 @@ export default {
}
},
computed: {
numberOfPages () {
return this.document.metadata?.pdf?.number_of_pages || this.document.preview_images.length
},
sortedPreviewImages () {
return [...this.document.preview_images].sort((a, b) => parseInt(a.filename) - parseInt(b.filename))
const lazyloadMetadata = this.document.preview_images[this.document.preview_images.length - 1].metadata
return [...Array(this.numberOfPages).keys()].map((i) => {
return this.previewImagesIndex[i] || {
metadata: lazyloadMetadata,
id: Math.random().toString(),
url: `/preview/${this.document.uuid}/${i}.jpg`
}
})
},
previewImagesIndex () {
return this.document.preview_images.reduce((acc, e) => {
acc[parseInt(e.filename)] = e
return acc
}, {})
}
},
beforeUpdate () {
+1 -1
View File
@@ -5,7 +5,7 @@
<div
class="border border-base-300 rounded rounded-tr-none relative group"
>
<div class="flex items-center justify-between relative">
<div class="flex items-center justify-between relative group/contenteditable-container">
<div
class="absolute top-0 bottom-0 right-0 left-0 cursor-pointer"
@click="scrollToFirstArea"
@@ -14,7 +14,7 @@
<label
v-else
tabindex="0"
class="cursor-pointer rounded-md p-2 border border-base-300 w-full flex justify-between"
class="cursor-pointer group/contenteditable-container rounded-md p-2 border border-base-300 w-full flex justify-between"
>
<div class="flex items-center space-x-2">
<span
+7 -2
View File
@@ -5,11 +5,12 @@
>
<img
ref="image"
loading="lazy"
:src="image.url"
:width="width"
class="border rounded mb-4"
:height="height"
loading="lazy"
class="border rounded mb-4"
@load="onImageLoad"
>
<div
class="top-0 bottom-0 left-0 right-0 absolute"
@@ -117,6 +118,10 @@ export default {
this.areaRefs = []
},
methods: {
onImageLoad (e) {
e.target.setAttribute('width', e.target.naturalWidth)
e.target.setAttribute('height', e.target.naturalHeight)
},
setAreaRefs (el) {
if (el) {
this.areaRefs.push(el)
+1 -1
View File
@@ -114,7 +114,7 @@ export default {
emits: ['scroll-to', 'change', 'remove', 'up', 'down', 'replace'],
computed: {
previewImage () {
return this.document.preview_images[0]
return [...this.document.preview_images].sort((a, b) => parseInt(a.filename) - parseInt(b.filename))[0]
}
},
mounted () {
+16 -3
View File
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class SubmitterMailer < ApplicationMailer
MAX_ATTACHMENTS_SIZE = 10.megabytes
def invitation_email(submitter, body: nil, subject: nil)
@current_account = submitter.submission.template.account
@submitter = submitter
@@ -75,13 +77,24 @@ class SubmitterMailer < ApplicationMailer
def add_completed_email_attachments!(submitter)
documents = Submitters.select_attachments_for_download(submitter)
total_size = 0
audit_trail_data = nil
if submitter.submission.audit_trail.present?
audit_trail_data = submitter.submission.audit_trail.download
total_size = audit_trail_data.size
end
documents.each do |attachment|
total_size += attachment.byte_size
break if total_size >= MAX_ATTACHMENTS_SIZE
attachments[attachment.filename.to_s] = attachment.download
end
if submitter.submission.audit_trail.present?
attachments[submitter.submission.audit_trail.filename.to_s] = submitter.submission.audit_trail.download
end
attachments[submitter.submission.audit_trail.filename.to_s] = audit_trail_data if audit_trail_data
documents
end
+29
View File
@@ -0,0 +1,29 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: encrypted_user_configs
#
# id :bigint not null, primary key
# key :string not null
# value :text not null
# created_at :datetime not null
# updated_at :datetime not null
# user_id :bigint not null
#
# Indexes
#
# index_encrypted_user_configs_on_user_id (user_id)
# index_encrypted_user_configs_on_user_id_and_key (user_id,key) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (user_id => users.id)
#
class EncryptedUserConfig < ApplicationRecord
belongs_to :user
encrypts :value
serialize :value, JSON
end
+2
View File
@@ -48,6 +48,8 @@ class Submission < ApplicationRecord
through: :template, source: :documents_attachments
scope :active, -> { where(deleted_at: nil) }
scope :pending, -> { joins(:submitters).where(submitters: { completed_at: nil }).distinct }
scope :completed, -> { left_joins(:submitters).where.not(submitters: { completed_at: nil }).distinct }
enum :source, {
invite: 'invite',
+16 -15
View File
@@ -4,21 +4,22 @@
#
# Table name: submitters
#
# id :bigint not null, primary key
# completed_at :datetime
# email :string
# ip :string
# name :string
# opened_at :datetime
# phone :string
# sent_at :datetime
# slug :string not null
# ua :string
# uuid :string not null
# values :text not null
# created_at :datetime not null
# updated_at :datetime not null
# submission_id :bigint not null
# id :bigint not null, primary key
# application_key :string
# completed_at :datetime
# email :string
# ip :string
# name :string
# opened_at :datetime
# phone :string
# sent_at :datetime
# slug :string not null
# ua :string
# uuid :string not null
# values :text not null
# created_at :datetime not null
# updated_at :datetime not null
# submission_id :bigint not null
#
# Indexes
#
+9
View File
@@ -53,6 +53,7 @@ class User < ApplicationRecord
has_one :access_token, dependent: :destroy
has_many :templates, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
has_many :user_configs, dependent: :destroy
has_many :encrypted_configs, dependent: :destroy, class_name: 'EncryptedUserConfig'
devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable
devise :registerable, :omniauthable, omniauth_providers: [:google_oauth2] if Docuseal.multitenant?
@@ -75,6 +76,14 @@ class User < ApplicationRecord
true
end
def self.sign_in_after_reset_password
if PasswordsController::Current.user.present?
!PasswordsController::Current.user.otp_required_for_login
else
true
end
end
def initials
[first_name&.first, last_name&.first].compact_blank.join.upcase
end
@@ -41,6 +41,13 @@
</div>
<div class="form-control">
<%= f.button button_title(title: 'Sign up', disabled_with: 'Signing up'), class: 'base-button' %>
<% if Docuseal.multitenant? %>
<button id="talk_to_sales_button" class="hidden" type="submit" formaction="<%= enquiries_path %>" formnovalidate="true" formmethod="post"></button>
<label class="flex items-center cursor-pointer">
<input type="checkbox" name="talk_to_sales" class="base-checkbox" onchange="window.talk_to_sales_button.click()">
<span class="label">I would like to talk to sales</span>
</label>
<% end %>
</div>
<% end %>
<%= render 'devise/shared/links' %>
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" />
<path d="M12 7v5l3 3" />
</svg>

After

Width:  |  Height:  |  Size: 362 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M9 6l11 0" />
<path d="M9 12l11 0" />
<path d="M9 18l11 0" />
<path d="M5 6l0 .01" />
<path d="M5 12l0 .01" />
<path d="M5 18l0 .01" />
</svg>

After

Width:  |  Height:  |  Size: 440 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="2" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M19.933 13.041a8 8 0 1 1 -9.925 -8.788c3.899 -1 7.935 1.007 9.425 4.747"></path>
<path d="M20 4v5h-5"></path>
</svg>

After

Width:  |  Height:  |  Size: 409 B

Whitespace-only changes.
+1
View File
@@ -21,6 +21,7 @@
<%= f.button button_title(title: 'Update', disabled_with: 'Updating'), class: 'base-button' %>
</div>
<% end %>
<%= render 'email_configs' %>
<p class="text-2xl font-bold mt-8 mb-4">Signature</p>
<% signature = UserConfigs.load_signature(current_user) %>
<% if signature %>
@@ -10,4 +10,4 @@
</div>
</div>
</div>
<%= render 'shared/attribution' %>
<%= render 'shared/attribution', link_path: '/start' %>
+1 -1
View File
@@ -1 +1 @@
<%= render 'shared/powered_by', with_counter: local_assigns[:with_counter] %>
<%= render 'shared/powered_by', with_counter: local_assigns[:with_counter], link_path: local_assigns[:link_path] %>
+1 -1
View File
@@ -2,5 +2,5 @@
---
</p>
<p>
Sent using <a href="<%= Docuseal::PRODUCT_URL %>"><%= Docuseal.product_name %></a> free document signing.
Sent using <a href="<%= Docuseal::PRODUCT_URL %>/start"><%= Docuseal.product_name %></a> free document signing.
</p>
+1 -1
View File
@@ -9,5 +9,5 @@
<% else %>
Powered by
<% end %>
<a href="<%= Docuseal::PRODUCT_URL %>" class="underline"><%= Docuseal.product_name %></a> - open source documents software
<a href="<%= Docuseal::PRODUCT_URL %><%= local_assigns[:link_path] %>" class="underline"><%= Docuseal.product_name %></a> - open source documents software
</div>
+5 -2
View File
@@ -1,7 +1,10 @@
<form action="<%= request.path %>" method="get" class="items-center hidden md:flex">
<form action="<%= url_for %>" method="get" class="items-center hidden md:flex">
<% if params[:status].present? %>
<input name="status" value="<%= params[:status] %>" class="hidden">
<% end %>
<% if params[:q].present? %>
<div class="relative">
<a href="<%= request.path %>" title="Clear" class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-auto text-neutral text-2xl font-extralight">
<a href="<%= url_for(params.to_unsafe_h.except(:q)) %>" title="Clear" class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-auto text-neutral text-2xl font-extralight">
&times;
</a>
</div>
+13 -6
View File
@@ -41,12 +41,12 @@
<%= link_to 'Team', settings_users_path, class: 'text-base hover:bg-base-300' %>
</li>
<% end %>
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) %>
<li>
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CONSOLE_URL}/on_premise", class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
Plans
<% end %>
</li>
<% unless Docuseal.multitenant? %>
<% if can?(:read, EncryptedConfig.new(key: 'saml_configs', account: current_account)) %>
<li>
<%= link_to 'SSO', settings_sso_index_path, class: 'text-base hover:bg-base-300' %>
</li>
<% end %>
<% end %>
<% if Docuseal.demo? || !Docuseal.multitenant? %>
<% if can?(:read, AccessToken) %>
@@ -66,6 +66,13 @@
</li>
<% end %>
<%= render 'shared/settings_nav_extra' %>
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) %>
<li>
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CONSOLE_URL}/on_premise", class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
Plans
<% end %>
</li>
<% end %>
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) %>
<li>
<%= link_to Docuseal.multitenant? ? console_redirect_index_path : Docuseal::CONSOLE_URL, class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
@@ -0,0 +1,11 @@
<div class="alert">
<%= svg_icon('info_circle', class: 'w-6 h-6') %>
<div>
<p class="font-bold">Single Sign On with SAML 2.0</p>
<p class="text-gray-700">
Unlock with DocuSeal Enterprise
<br>
<a class="link font-medium" target="_blank" href="<%= "#{Docuseal::CONSOLE_URL}/#{Docuseal.multitenant? ? 'plans' : 'on_premise'}" %>">Learn More</a>
</p>
</div>
</div>
+8
View File
@@ -0,0 +1,8 @@
<div class="flex flex-wrap space-y-4 md:flex-nowrap md:space-y-0">
<%= render 'shared/settings_nav' %>
<div class="flex-grow max-w-xl mx-auto">
<h1 class="text-4xl font-bold mb-4">SMS</h1>
<%= render 'placeholder' %>
</div>
<div class="w-0 md:w-52"></div>
</div>
+4 -1
View File
@@ -24,6 +24,9 @@
<%= button_to button_title(title: 'Send copy to Email', disabled_with: 'Sending', icon: svg_icon('mail_forward', class: 'w-6 h-6')), send_submission_email_index_path, params: { submitter_slug: @submitter.slug }, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'base-button w-full' %>
</div>
<% end %>
<div>
<%= button_to button_title(title: 'Resubmit', disabled_with: 'Resubmit', icon: svg_icon('reload', class: 'w-6 h-6')), start_form_path(@template.slug), params: { submitter: { email: params[:email] }, resubmit: true }, method: :put, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'white-button w-full' %>
</div>
</div>
</div>
<%= render 'shared/attribution' %>
<%= render 'shared/attribution', link_path: '/start' %>
+2 -2
View File
@@ -24,7 +24,7 @@
</div>
</div>
<% unless @template.deleted_at? %>
<%= form_for @submitter, url: start_form_path(@template.slug), data: { turbo_frame: :_top }, method: :put, html: { class: 'space-y-4' } do |f| %>
<%= form_for @submitter, url: start_form_path(@template.slug), data: { turbo_frame: :_top }, method: :put, html: { class: 'space-y-4', onsubmit: 'event.submitter.disabled = true' } do |f| %>
<div class="form-control !mt-0">
<%= f.label :email, class: 'label' %>
<%= f.email_field :email, value: current_user&.email, required: true, class: 'base-input', placeholder: 'Provide your email to start' %>
@@ -36,4 +36,4 @@
<% end %>
</div>
</div>
<%= render 'shared/attribution' %>
<%= render 'shared/attribution', link_path: '/start' %>
Whitespace-only changes.
@@ -41,6 +41,7 @@
<%= f.text_area :body, value: config.value['body'], required: true, class: 'base-textarea w-full', rows: 10 %>
</autoresize-textarea>
</div>
<%= render 'message_fields' %>
</div>
</div>
</div>
+1
View File
@@ -23,4 +23,5 @@
<%= render 'detailed_form', template: @template %>
</div>
</div>
<%= content_for(:modal_extra) %>
<% end %>
+6 -2
View File
@@ -44,12 +44,16 @@
<% fields_index = Templates.build_field_areas_index(@submission.template_fields || @submission.template.fields) %>
<% values = @submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } %>
<% attachments_index = ActiveStorage::Attachment.where(record: @submission.submitters, name: :attachments).preload(:blob).index_by(&:uuid) %>
<% page_blob_struct = Struct.new(:url, :metadata, keyword_init: true) %>
<% (@submission.template_schema || @submission.template.schema).each do |item| %>
<% document = @submission.template_schema_documents.find { |e| e.uuid == item['attachment_uuid'] } %>
<% document_annots_index = document.metadata.dig('pdf', 'annotations')&.group_by { |e| e['page'] } || {} %>
<% document.preview_images.sort_by { |a| a.filename.base.to_i }.each_with_index do |page, index| %>
<% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %>
<% lazyload_metadata = document.preview_images.first.metadata %>
<% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %>
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.uuid, "#{index}.jpg")) %>
<div id="<%= "page-#{document.uuid}-#{index}" %>" class="relative">
<img src="<%= page.url %>" width="<%= page.metadata['width'] %>" class="shadow-md mb-4" height="<%= page.metadata['height'] %>" loading="lazy">
<img loading="lazy" src="<%= page.url %>" width="<%= page.metadata['width'] %>" class="shadow-md mb-4" height="<%= page.metadata['height'] %>">
<div class="top-0 bottom-0 left-0 right-0 absolute">
<% document_annots_index[index]&.each do |annot| %>
<%= render 'submissions/annotation', annot: %>
+1 -1
View File
@@ -18,4 +18,4 @@
</div>
</div>
</div>
<%= render 'shared/attribution' %>
<%= render 'shared/attribution', link_path: '/start' %>
+12 -2
View File
@@ -19,7 +19,11 @@
<div>
<% if Accounts.can_send_emails?(@submitter.submission.template.account) && @submitter.email.present? %>
<%= button_to button_title(title: 'Send copy to Email', disabled_with: 'Sending', icon: svg_icon('mail_forward', class: 'w-6 h-6')), send_submission_email_index_path, params: { submitter_slug: @submitter.slug }, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'white-button w-full' %>
<div class="divider">OR</div>
<% if @submitter.submission.template.submitters.size != 1 %>
<div class="divider">OR</div>
<% else %>
<div class="py-2"></div>
<% end %>
<% end %>
<download-button data-src="<%= submitter_download_index_path(@submitter.slug) %>" class="base-button w-full">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
@@ -32,6 +36,12 @@
</span>
</download-button>
</div>
<% if @submitter.submission.template.submitters.size == 1 %>
<div class="divider">OR</div>
<div>
<%= button_to button_title(title: 'Resubmit', disabled_with: 'Resubmit', icon: svg_icon('reload', class: 'w-6 h-6')), start_form_path(@submitter.submission.template.slug), params: { submitter: { email: @submitter.email, phone: @submitter.phone }, resubmit: true }, method: :put, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'white-button w-full' %>
</div>
<% end %>
</div>
</div>
<%= render 'shared/attribution' %>
<%= render 'shared/attribution', link_path: '/start' %>
+7 -3
View File
@@ -1,6 +1,7 @@
<% fields_index = Templates.build_field_areas_index(@submitter.submission.template_fields || @submitter.submission.template.fields) %>
<% values = @submitter.submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } %>
<% attachments_index = ActiveStorage::Attachment.where(record: @submitter.submission.submitters, name: :attachments).preload(:blob).index_by(&:uuid) %>
<% page_blob_struct = Struct.new(:url, :metadata, keyword_init: true) %>
<div style="max-height: -webkit-fill-available;">
<div id="scrollbox">
<div class="mx-auto block pb-72" style="max-width: 1000px">
@@ -10,9 +11,12 @@
<% (@submitter.submission.template_schema || @submitter.submission.template.schema).each do |item| %>
<% document = @submitter.submission.template_schema_documents.find { |a| a.uuid == item['attachment_uuid'] } %>
<% document_annots_index = document.metadata.dig('pdf', 'annotations')&.group_by { |e| e['page'] } || {} %>
<% document.preview_images.sort_by { |a| a.filename.base.to_i }.each_with_index do |page, index| %>
<% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %>
<% lazyload_metadata = document.preview_images.last.metadata %>
<% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %>
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.uuid, "#{index}.jpg")) %>
<div class="relative my-4 shadow-md">
<img src="<%= page.url %>" width="<%= page.metadata['width'] %>" height="<%= page.metadata['height'] %>" loading="lazy">
<img loading="lazy" src="<%= page.url %>" width="<%= page.metadata['width'] %>" height="<%= page.metadata['height'] %>">
<div id="page-<%= [document.uuid, index].join('-') %>" class="top-0 bottom-0 left-0 right-0 absolute">
<% document_annots_index[index]&.each do |annot| %>
<%= render 'submissions/annotation', annot: %>
@@ -27,7 +31,7 @@
</div>
<% end %>
<% end %>
<%= render 'shared/attribution' %>
<%= render 'shared/attribution', link_path: '/start' %>
</div>
</div>
</div>
+3 -3
View File
@@ -52,9 +52,9 @@
</a>
</span>
<% end %>
<% if !template.deleted_at? && can?(:destroy, template) %>
<span class="tooltip tooltip-left" data-tip="Archive">
<%= button_to template_path(template), data: { turbo_confirm: 'Are you sure?' }, method: :delete, class: 'btn btn-xs hover:btn-outline bg-base-200 btn-circle', aria_label: 'Restore' do %>
<% if (!template.deleted_at? || !Docuseal.multitenant?) && can?(:destroy, template) %>
<span class="tooltip tooltip-left" data-tip="<%= template.deleted_at? ? 'Remove' : 'Archive' %>">
<%= button_to template_path(template), data: { turbo_confirm: 'Are you sure?' }, params: { permanently: template.deleted_at? }.compact_blank, method: :delete, class: 'btn btn-xs hover:btn-outline bg-base-200 btn-circle', aria_label: 'Restore' do %>
<%= svg_icon('trash', class: 'w-4 h-4 enabled') %>
<%= svg_icon('loader', class: 'w-4 h-4 animate-spin disabled') %>
<% end %>
+1 -1
View File
@@ -1 +1 @@
<template-builder class="grid" data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-template="<%= @template.as_json.merge(documents: @template.schema_documents.as_json(include: { preview_images: { methods: %i[url metadata filename] } })).to_json %>"></template-builder>
<template-builder class="grid" data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-template="<%= @template_data %>"></template-builder>
+35 -3
View File
@@ -1,9 +1,10 @@
<%= render 'title', template: @template %>
<% is_show_tabs = @pagy.pages > 1 || params[:status].present? %>
<% if !@pagy.count.zero? || @template.submitters.to_a.size == 1 || params[:q].present? %>
<div class="flex justify-between mb-6 md:items-end flex-col md:flex-row">
<div class="flex justify-between md:items-end flex-col md:flex-row <%= is_show_tabs ? 'mb-4' : 'mb-6' %>">
<p class="text-3xl font-bold">Submissions</p>
<div class="flex space-x-2 mt-3 md:mt-0">
<% if params[:q].present? || @pagy.pages > 1 %>
<div class="flex md:space-x-2 mt-3 md:mt-0">
<% if params[:q].present? || params[:status].present? || @pagy.pages > 1 %>
<%= render 'shared/search_input' %>
<% end %>
<%= link_to new_template_submissions_export_path(@template), class: 'order-3 md:order-1 btn btn-ghost text-base', data: { turbo_frame: 'modal' } do %>
@@ -24,6 +25,37 @@
</div>
</div>
<% end %>
<% if is_show_tabs %>
<div class="flex items-center md:items-end flex-col space-y-2 md:space-y-0 md:flex-row md:space-x-2 mb-4">
<a href="<%= url_for(params.to_unsafe_h.except(:status)) %>" class="<%= params[:status].blank? ? 'border-neutral-700' : 'border-neutral-300' %> flex h-10 px-2 py-1 text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl w-full md:w-48 hover:border-neutral-600">
<div class="flex items-center space-x-1">
<%= svg_icon('list', class: 'w-5 h-5') %>
<span class="font-normal">All</span>
</div>
<div class="badge badge-neutral badge-outline font-medium">
<%= params[:status].blank? ? @pagy.count : @base_submissions.count %>
</div>
</a>
<a href="<%= url_for(params.to_unsafe_h.merge(status: :pending)) %>" class="<%= params[:status] == 'pending' ? 'border-neutral-700' : 'border-neutral-300' %> flex h-10 px-2 py-1 text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl w-full md:w-48 hover:border-neutral-600">
<div class="flex items-center space-x-1">
<%= svg_icon('clock', class: 'w-5 h-5') %>
<span class="font-normal">Pending</span>
</div>
<div class="badge badge-neutral badge-outline font-medium">
<%= params[:status] == 'pending' ? @pagy.count : @base_submissions.pending.count %>
</div>
</a>
<a href="<%= url_for(params.to_unsafe_h.merge(status: :completed)) %>" class="<%= params[:status] == 'completed' ? 'border-neutral-700' : 'border-neutral-300' %> flex h-10 px-2 py-1 text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl w-full md:w-48 hover:border-neutral-600">
<div class="flex items-center space-x-1">
<%= svg_icon('circle_check', class: 'w-5 h-5') %>
<span class="font-normal">Completed</span>
</div>
<div class="badge badge-neutral badge-outline font-medium">
<%= params[:status] == 'completed' ? @pagy.count : @base_submissions.completed.count %>
</div>
</a>
</div>
<% end %>
<% if @submissions.present? %>
<div class="space-y-4">
<%= render partial: 'submission', collection: @submissions %>
+18
View File
@@ -0,0 +1,18 @@
<div class="h-screen">
<div
class="text-center p-8 h-full flex items-center justify-center">
<div>
<%= render 'shared/logo', width: 50, height: 50, class: 'mx-auto animate-bounce' %>
<span>
Processing...
</span>
</div>
</div>
</div>
<submit-form>
<%= form_for '', url: templates_upload_path, method: :post, class: 'hidden' do %>
<button type="submit"></button>
<input name="url" value="<%= params[:url] %>">
<input name="filename" value="<%= params[:filename] %>">
<% end %>
</submit-form>
+1 -1
View File
@@ -59,7 +59,7 @@ Rails.application.configure do
# config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ]
# Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
config.force_ssl = ENV['FORCE_SSL'] == 'true'
config.force_ssl = ENV['FORCE_SSL'].present?
# Include generic and useful information about system operation, but avoid logging too much
# information to avoid inadvertent exposure of personally identifiable information (PII).
+12
View File
@@ -66,4 +66,16 @@ Rails.application.configure do
deterministic_key: 'test deterministic key',
key_derivation_salt: 'test key derivation salt'
}
config.middleware.use(Class.new do
def initialize(app)
@app = app
end
def call(env)
return [200, {}, []] if env['PATH_INFO'].starts_with?('/fonts')
@app.call(env)
end
end)
end
+1 -1
View File
@@ -24,7 +24,7 @@ port ENV.fetch('PORT', 3000)
environment ENV.fetch('RAILS_ENV', 'development')
# Specifies the `pidfile` that Puma will use.
# pidfile ENV.fetch('PIDFILE', 'tmp/pids/server.pid')
pidfile ENV.fetch('PIDFILE', "#{Dir.tmpdir}/server.pid")
# Specifies the number of `workers` to boot in clustered mode.
# Workers are forked web server processes. If using threads and workers together
+8 -2
View File
@@ -9,7 +9,7 @@ Rails.application.routes.draw do
devise_for :users,
path: '/', only: %i[sessions passwords omniauth_callbacks],
controllers: begin
options = { sessions: 'sessions' }
options = { sessions: 'sessions', passwords: 'passwords' }
options[:omniauth_callbacks] = 'omniauth_callbacks' if Docuseal.multitenant?
options
end
@@ -35,7 +35,7 @@ Rails.application.routes.draw do
resources :template_folders_autocomplete, only: %i[index]
resources :submitter_email_clicks, only: %i[create]
resources :submitter_form_views, only: %i[create]
resources :submitters, only: %i[show]
resources :submitters, only: %i[index show]
resources :submissions, only: %i[index show create destroy] do
collection do
resources :emails, only: %i[create], controller: 'submissions', as: :submissions_emails
@@ -52,11 +52,15 @@ Rails.application.routes.draw do
resources :dashboard, only: %i[index]
resources :setup, only: %i[index create]
resource :newsletter, only: %i[show update]
resources :enquiries, only: %i[create]
resources :users, only: %i[new create edit update destroy]
resource :user_signature, only: %i[edit update]
resources :submissions, only: %i[show destroy]
resources :console_redirect, only: %i[index]
resource :templates_upload, only: %i[create]
authenticated do
resource :templates_upload, only: %i[show], path: 'new'
end
resources :templates_archived, only: %i[index], path: 'archived'
resources :folders, only: %i[show edit update destroy], controller: 'template_folders'
resources :templates, only: %i[new create edit show destroy] do
@@ -66,6 +70,7 @@ Rails.application.routes.draw do
resource :folder, only: %i[edit update], controller: 'templates_folders'
resources :submissions_export, only: %i[index new]
end
resources :preview_document_page, only: %i[show], path: '/preview/:attachment_uuid'
resources :start_form, only: %i[show update], path: 'd', param: 'slug' do
get :completed
@@ -90,6 +95,7 @@ Rails.application.routes.draw do
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 :sso, only: %i[index], controller: 'sso_settings'
end
resources :notifications, only: %i[index create], controller: 'notifications_settings'
resource :esign, only: %i[show create new update destroy], controller: 'esign_settings'
@@ -20,7 +20,7 @@ class AddFolderIdToTemplates < ActiveRecord::Migration[7.0]
def up
add_reference :templates, :folder, foreign_key: { to_table: :template_folders }, index: true, null: true
MigrationAccount.all.pluck(:id).each do |account_id|
MigrationAccount.pluck(:id).each do |account_id|
author_id = MigrationUser.where(account_id:).minimum(:id)
next if author_id.blank?
@@ -0,0 +1,15 @@
# frozen_string_literal: true
class CreateEncryptedUserConfigs < ActiveRecord::Migration[7.0]
def change
create_table :encrypted_user_configs do |t|
t.references :user, null: false, foreign_key: true, index: true
t.string :key, null: false
t.text :value, null: false
t.index %i[user_id key], unique: true
t.timestamps
end
end
end
@@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddApplicationIdToSubmitters < ActiveRecord::Migration[7.0]
def change
add_column :submitters, :application_key, :string
end
end
+13 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2023_10_07_052818) do
ActiveRecord::Schema[7.0].define(version: 2023_10_17_213639) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -91,6 +91,16 @@ ActiveRecord::Schema[7.0].define(version: 2023_10_07_052818) do
t.index ["account_id"], name: "index_encrypted_configs_on_account_id"
end
create_table "encrypted_user_configs", force: :cascade do |t|
t.bigint "user_id", null: false
t.string "key", null: false
t.text "value", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id", "key"], name: "index_encrypted_user_configs_on_user_id_and_key", unique: true
t.index ["user_id"], name: "index_encrypted_user_configs_on_user_id"
end
create_table "submission_events", force: :cascade do |t|
t.bigint "submission_id", null: false
t.bigint "submitter_id"
@@ -133,6 +143,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_10_07_052818) do
t.datetime "updated_at", null: false
t.string "name"
t.string "phone"
t.string "application_key"
t.index ["email"], name: "index_submitters_on_email"
t.index ["slug"], name: "index_submitters_on_slug", unique: true
t.index ["submission_id"], name: "index_submitters_on_submission_id"
@@ -216,6 +227,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_10_07_052818) do
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "document_generation_events", "submitters"
add_foreign_key "encrypted_configs", "accounts"
add_foreign_key "encrypted_user_configs", "users"
add_foreign_key "submission_events", "submissions"
add_foreign_key "submission_events", "submitters"
add_foreign_key "submissions", "templates"
+2 -2
View File
@@ -11,8 +11,8 @@ services:
volumes:
- .:/data
environment:
FORCE_SSL: 'true'
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/docuseal
- FORCE_SSL=${HOST}
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/docuseal
postgres:
image: postgres:15
+1
View File
@@ -10,6 +10,7 @@ class Ability
can :manage, Submitter, template: { account_id: user.account_id }
can :manage, User, account_id: user.account_id
can :manage, EncryptedConfig, account_id: user.account_id
can :manage, EncryptedUserConfig, user_id: user.id
can :manage, AccountConfig, account_id: user.account_id
can :manage, UserConfig, user_id: user.id
can :manage, Account, id: user.account_id
+4 -5
View File
@@ -47,7 +47,7 @@ module Accounts
end
def load_webhook_configs(account)
account = Account.order(:id).first unless Docuseal.multitenant?
EncryptedConfig.find_by(key: EncryptedConfig::WEBHOOK_URL_KEY) unless Docuseal.multitenant?
account.encrypted_configs.find_by(key: EncryptedConfig::WEBHOOK_URL_KEY)
end
@@ -57,8 +57,7 @@ module Accounts
if Docuseal.multitenant?
EncryptedConfig.find_by(account:, key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || Docuseal::CERTS
else
EncryptedConfig.find_by(account: Account.order(:id).first,
key: EncryptedConfig::ESIGN_CERTS_KEY).value
EncryptedConfig.find_by(key: EncryptedConfig::ESIGN_CERTS_KEY).value
end
if (default_cert = cert_data['custom']&.find { |e| e['status'] == 'default' })
@@ -68,11 +67,11 @@ module Accounts
end
end
def can_send_emails?(account)
def can_send_emails?(_account)
return true if Docuseal.multitenant?
return true if ENV['SMTP_ADDRESS'].present?
EncryptedConfig.exists?(account_id: account.id, key: EncryptedConfig::EMAIL_SMTP_KEY)
EncryptedConfig.exists?(key: EncryptedConfig::EMAIL_SMTP_KEY)
end
def normalize_timezone(timezone)
+2 -1
View File
@@ -3,6 +3,7 @@
module Docuseal
PRODUCT_URL = 'https://www.docuseal.co'
NEWSLETTER_URL = "#{PRODUCT_URL}/newsletters".freeze
ENQUIRIES_URL = "#{PRODUCT_URL}/enquiries".freeze
PRODUCT_NAME = 'DocuSeal'
DEFAULT_APP_URL = 'http://localhost:3000'
GITHUB_URL = 'https://github.com/docusealco/docuseal'
@@ -30,7 +31,7 @@ module Docuseal
DEFAULT_URL_OPTIONS = {
host: HOST,
protocol: ENV['FORCE_SSL'] == 'true' ? 'https' : 'http'
protocol: ENV['FORCE_SSL'].present? ? 'https' : 'http'
}.freeze
module_function
@@ -92,6 +92,7 @@ module Submissions
email:,
phone: attrs[:phone].to_s.gsub(/[^0-9+]/, ''),
name: attrs[:name],
application_key: attrs[:application_key],
completed_at: attrs[:completed] ? Time.current : nil,
sent_at: mark_as_sent && email.present? && is_order_sent ? Time.current : nil,
values: attrs[:values] || {},
+1 -1
View File
@@ -4,7 +4,7 @@ module Submissions
module EnsureResultGenerated
WAIT_FOR_RETRY = 2.seconds
CHECK_EVENT_INTERVAL = 1.second
CHECK_COMPLETE_TIMEOUT = 20.seconds
CHECK_COMPLETE_TIMEOUT = 90.seconds
WaitForCompleteTimeout = Class.new(StandardError)
+58 -46
View File
@@ -4,8 +4,8 @@ module Submissions
module GenerateAuditTrail
FONT_SIZE = 9
TEXT_COLOR = '525252'
FONT_PATH = '/LiberationSans-Regular.ttf'
FONT_BOLD_PATH = '/LiberationSans-Bold.ttf'
FONT_PATH = '/fonts/LiberationSans-Regular.ttf'
FONT_BOLD_PATH = '/fonts/LiberationSans-Bold.ttf'
FONT_NAME = if File.exist?(FONT_PATH)
FONT_PATH
else
@@ -105,7 +105,8 @@ module Submissions
document.metadata['sha256'] || document.checksum,
"\n",
{ text: 'Generated at: ', font: [FONT_BOLD_NAME, { variant: :bold }] },
I18n.l(document.created_at, format: :long, locale: account.locale)
"#{I18n.l(document.created_at.in_time_zone(account.timezone), format: :long, locale: account.locale)} " \
"#{timezone_abbr(account.timezone, document.created_at)}"
], line_spacing: 1.8
)
]
@@ -166,57 +167,59 @@ module Submissions
composer.table(info_rows, cell_style: { padding: [0, 0, 0, 0], border: { width: 0 } })
composer.column(columns: 1, gaps: 0, style: { padding: [0, 200, 0, 0] }) do |column|
submission.template_fields.filter_map do |field|
next if field['submitter_uuid'] != submitter.uuid
submission.template_fields.filter_map do |field|
next if field['submitter_uuid'] != submitter.uuid
submitter_field_counters[field['type']] += 1
submitter_field_counters[field['type']] += 1
value = submitter.values[field['uuid']]
value = submitter.values[field['uuid']]
next if Array.wrap(value).compact_blank.blank?
next if Array.wrap(value).compact_blank.blank?
[
column.formatted_text_box(
[
{
text: field['name'].to_s.upcase.presence ||
"#{field['type']} Field #{submitter_field_counters[field['type']]}\n".upcase,
font_size: 6
}
].compact_blank, line_spacing: 1.8, padding: [0, 0, 5, 0]
),
if field['type'].in?(%w[image signature initials])
attachment = submitter.attachments.find { |a| a.uuid == value }
image = Vips::Image.new_from_buffer(attachment.download, '').autorot
[
composer.formatted_text_box(
[
{
text: field['name'].to_s.upcase.presence ||
"#{field['type']} Field #{submitter_field_counters[field['type']]}\n".upcase,
font_size: 6
}
].compact_blank, line_spacing: 1.8, padding: [0, 0, 5, 0]
),
if field['type'].in?(%w[image signature initials])
attachment = submitter.attachments.find { |a| a.uuid == value }
image = Vips::Image.new_from_buffer(attachment.download, '').autorot
scale = [300.0 / image.width, 300.0 / image.height].min
scale = [600.0 / image.width, 600.0 / image.height].min
io = StringIO.new(image.resize([scale, 1].min).write_to_buffer('.png'))
resized_image = image.resize([scale, 1].min)
io = StringIO.new(resized_image.write_to_buffer('.png'))
column.image(io, padding: [0, field['type'] == 'initials' ? 200 : 100, 10, 0])
column.formatted_text_box([{ text: '' }])
elsif field['type'] == 'file'
column.formatted_text_box(
Array.wrap(value).map do |uuid|
attachment = submitter.attachments.find { |a| a.uuid == uuid }
link =
Rails.application.routes.url_helpers.rails_blob_url(attachment, **Docuseal.default_url_options)
width = field['type'] == 'initials' ? 100 : 200
height = resized_image.height * (width.to_f / resized_image.width)
{ link:, text: "#{attachment.filename}\n", style: :link }
end,
padding: [0, 0, 10, 0]
)
elsif field['type'] == 'checkbox'
column.formatted_text_box([{ text: value.to_s.titleize }], padding: [0, 0, 10, 0])
else
value = I18n.l(Date.parse(value), format: :long, locale: account.locale) if field['type'] == 'date'
value = value.join(', ') if value.is_a?(Array)
composer.image(io, width:, height:, margin: [0, 0, 10, 0])
composer.formatted_text_box([{ text: '' }])
elsif field['type'] == 'file'
composer.formatted_text_box(
Array.wrap(value).map do |uuid|
attachment = submitter.attachments.find { |a| a.uuid == uuid }
link =
Rails.application.routes.url_helpers.rails_blob_url(attachment, **Docuseal.default_url_options)
column.formatted_text_box([{ text: value.to_s.presence || 'n/a' }], padding: [0, 0, 10, 0])
end
]
end
{ link:, text: "#{attachment.filename}\n", style: :link }
end,
padding: [0, 0, 10, 0]
)
elsif field['type'] == 'checkbox'
composer.formatted_text_box([{ text: value.to_s.titleize }], padding: [0, 0, 10, 0])
else
value = I18n.l(Date.parse(value), format: :long, locale: account.locale) if field['type'] == 'date'
value = value.join(', ') if value.is_a?(Array)
composer.formatted_text_box([{ text: value.to_s.presence || 'n/a' }], padding: [0, 0, 10, 0])
end
]
end
composer.draw_box(divider)
@@ -227,7 +230,8 @@ module Submissions
events_data = submission.submission_events.sort_by(&:event_timestamp).map do |event|
submitter = submission.submitters.find { |e| e.id == event.submitter_id }
[
I18n.l(event.event_timestamp, format: :long, locale: account.locale),
"#{I18n.l(event.event_timestamp.in_time_zone(account.timezone), format: :long, locale: account.locale)} " \
"#{timezone_abbr(account.timezone, event.event_timestamp)}",
composer.document.layout.formatted_text_box(
[
{ text: SubmissionEvents::EVENT_NAMES[event.event_type.to_sym],
@@ -263,6 +267,14 @@ module Submissions
)
end
def timezone_abbr(timezone, time = Time.current)
tz_info = TZInfo::Timezone.get(
ActiveSupport::TimeZone::MAPPING[timezone] || timezone || 'UTC'
)
tz_info.abbreviation(time)
end
def add_logo(column)
column.image(PdfIcons.logo_io, width: 40, height: 40, position: :float)
@@ -3,7 +3,7 @@
module Submissions
module GenerateResultAttachments
FONT_SIZE = 11
FONT_PATH = '/LiberationSans-Regular.ttf'
FONT_PATH = '/fonts/LiberationSans-Regular.ttf'
FONT_NAME = if File.exist?(FONT_PATH)
FONT_PATH
else
+1 -1
View File
@@ -18,7 +18,7 @@ module Submitters
serialize_params = {
include: {},
only: %i[id slug uuid name email phone completed_at
only: %i[id slug uuid name email phone completed_at application_key
opened_at sent_at created_at updated_at]
}
+2 -2
View File
@@ -24,8 +24,8 @@ module Submitters
end
def build_values_array(submitter)
fields_index = (submitter.submission.template_fields ||
submitter.submission.template.fields).index_by { |e| e['uuid'] }
fields_index = (submitter.submission.template.fields +
submitter.submission.template_fields.to_a).index_by { |e| e['uuid'] }
attachments_index = submitter.attachments.index_by(&:uuid)
submitter_field_counters = Hash.new { 0 }
+9 -7
View File
@@ -13,13 +13,15 @@ module Templates
record: template
)
document.preview_images_attachments.each do |preview_image|
ActiveStorage::Attachment.create!(
uuid: preview_image.uuid,
blob_id: preview_image.blob_id,
name: 'preview_images',
record: new_document
)
ApplicationRecord.no_touching do
document.preview_images_attachments.each do |preview_image|
ActiveStorage::Attachment.create!(
uuid: preview_image.uuid,
blob_id: preview_image.blob_id,
name: 'preview_images',
record: new_document
)
end
end
end
end
+7 -2
View File
@@ -3,6 +3,7 @@
module Templates
module CreateAttachments
PDF_CONTENT_TYPE = 'application/pdf'
ANNOTATIONS_SIZE_LIMIT = 6.megabytes
InvalidFileType = Class.new(StandardError)
module_function
@@ -18,7 +19,10 @@ module Templates
document = template.documents.create!(blob:)
if blob.content_type == PDF_CONTENT_TYPE && blob.metadata['pdf'].nil?
blob.metadata['pdf'] = { 'annotations' => Templates::BuildAnnotations.call(document_data) }
annotations =
document_data.size > ANNOTATIONS_SIZE_LIMIT ? [] : Templates::BuildAnnotations.call(document_data)
blob.metadata['pdf'] = { 'annotations' => annotations }
blob.metadata['sha256'] = Base64.urlsafe_encode64(Digest::SHA256.digest(document_data))
end
@@ -37,9 +41,10 @@ module Templates
data = file.read
if file.content_type == PDF_CONTENT_TYPE
annotations = data.size > ANNOTATIONS_SIZE_LIMIT ? [] : Templates::BuildAnnotations.call(data)
metadata = { 'identified' => true, 'analyzed' => true,
'sha256' => Base64.urlsafe_encode64(Digest::SHA256.digest(data)),
'pdf' => { 'annotations' => Templates::BuildAnnotations.call(data) } }
'pdf' => { 'annotations' => annotations } }
end
ActiveStorage::Blob.create_and_upload!(
+43 -2
View File
@@ -9,6 +9,7 @@ module Templates
PDF_CONTENT_TYPE = 'application/pdf'
Q = 35
MAX_WIDTH = 1400
MAX_NUMBER_OF_PAGES_PROCESSED = 15
module_function
@@ -42,14 +43,52 @@ module Templates
def generate_pdf_preview_images(attachment, data)
ActiveStorage::Attachment.where(name: ATTACHMENT_NAME, record: attachment).destroy_all
number_of_pages = HexaPDF::Document.new(io: StringIO.new(data)).pages.size - 1
number_of_pages = HexaPDF::Document.new(io: StringIO.new(data)).pages.size
(0..number_of_pages).each do |page_number|
(attachment.metadata['pdf'] ||= {})[:number_of_pages] = number_of_pages
attachment.save!
(0..[number_of_pages - 1, MAX_NUMBER_OF_PAGES_PROCESSED].min).each do |page_number|
page = Vips::Image.new_from_buffer(data, '', dpi: DPI, page: page_number)
page = page.resize(MAX_WIDTH / page.width.to_f)
io = StringIO.new(page.write_to_buffer(FORMAT, Q: Q, interlace: true))
ApplicationRecord.no_touching do
ActiveStorage::Attachment.create!(
blob: ActiveStorage::Blob.create_and_upload!(
io:, filename: "#{page_number}#{FORMAT}",
metadata: { analyzed: true, identified: true, width: page.width, height: page.height }
),
name: ATTACHMENT_NAME,
record: attachment
)
end
end
end
def generate_pdf_preview_from_file(attachment, file_path, page_number)
io = StringIO.new
command = [
'pdftocairo', '-jpeg', '-jpegopt', "progressive=y,quality=#{Q},optimize=y",
'-scale-to-x', MAX_WIDTH, '-scale-to-y', '-1',
'-r', DPI, '-f', page_number + 1, '-l', page_number + 1,
'-singlefile', Shellwords.escape(file_path), '-'
].join(' ')
Open3.popen3(command) do |_, stdout, _, _|
io.write(stdout.read)
io.rewind
end
page = Vips::Image.new_from_buffer(io.read, '')
io.rewind
ApplicationRecord.no_touching do
ActiveStorage::Attachment.create!(
blob: ActiveStorage::Blob.create_and_upload!(
io:, filename: "#{page_number}#{FORMAT}",
@@ -59,6 +98,8 @@ module Templates
record: attachment
)
end
io
end
end
end
+1 -1
View File
@@ -2,7 +2,7 @@
require 'rails_helper'
RSpec.describe 'App Setup', js: true do
RSpec.describe 'App Setup' do
let(:form_data) do
{
first_name: 'John',