Compare commits

...
51 Commits
Author SHA1 Message Date
Alex Turchyn 528a1216f8 Merge from docusealco/wip 2026-05-11 13:52:58 +03:00
Pete Matsyburka f2479bd259 adjust open revisions 2026-05-11 11:11:16 +03:00
Pete Matsyburka 135f0826d2 fix style 2026-05-09 21:17:18 +03:00
Pete Matsyburka 5710f0177b update gh stars 2026-05-09 19:25:42 +03:00
Pete Matsyburka 1a3a0528ba fix i18n 2026-05-09 10:34:20 +03:00
Pete Matsyburka 59793ff374 adjust builder 2026-05-08 19:35:46 +03:00
Pete Matsyburka 3c4ed42419 fix i18n 2026-05-08 18:08:35 +03:00
Pete Matsyburka 6a17b61550 update gem 2026-05-08 16:25:11 +03:00
Pete Matsyburka 76659497b7 typos 2026-05-08 16:12:09 +03:00
Pete Matsyburka ee50c957cb update gem 2026-05-08 15:43:34 +03:00
Pete Matsyburka a9dd200919 add pdfium rotate 2026-05-08 15:43:34 +03:00
Pete Matsyburka 1fa953104a update gh stars 2026-05-08 15:43:34 +03:00
Pete Matsyburka 50eb5b070e fix typo 2026-05-08 15:43:34 +03:00
Pete Matsyburka 10fd624bec add revisions 2026-05-08 15:43:34 +03:00
Alex Turchyn 04ec2f8260 allow permanent delete submissions in archived templates 2026-05-08 15:43:34 +03:00
Pete Matsyburka d828d79574 improve log 2026-05-08 15:43:34 +03:00
Pete Matsyburka 01dd3fefe5 retry webhooks in test mode 2026-05-08 15:43:34 +03:00
Pete Matsyburka 45ae954c0c add hmac webhook secret 2026-05-08 15:43:34 +03:00
Pete Matsyburka 1304849b55 update gh stars 2026-05-08 15:43:34 +03:00
Pete Matsyburka 0875faa079 fix date preview 2026-05-08 15:43:34 +03:00
Pete Matsyburka 3aa15d6ea6 update gem 2026-05-08 15:43:34 +03:00
Pete Matsyburka 6557329e97 gh 13k 2026-05-08 15:43:34 +03:00
Pete Matsyburka be27ce4161 Merge pull request #666 from aqilaziz/docs-fix-readme-typos
docs: fix README typos
2026-05-08 14:02:33 +03:00
aqilaziz a3391a970e docs: fix README typos 2026-05-08 05:23:28 +07:00
Alex Turchyn 744d45d2c5 Merge from docusealco/wip 2026-05-04 13:55:19 +03:00
Pete Matsyburka 310b16f54a use mcp source 2026-05-04 09:42:51 +03:00
Pete Matsyburka 6d3ad4a869 update gh stars 2026-05-03 18:37:56 +03:00
Alex Turchyn 1ce2448820 update create_template and send_documents annotations 2026-05-03 17:43:22 +03:00
Pete Matsyburka 07b4ada5be optimize edit json 2026-05-03 16:32:37 +03:00
Pete Matsyburka f70bf530ba hide qr link 2026-05-03 08:19:58 +03:00
Pete Matsyburka a26aa0f411 remove log 2026-05-03 07:27:24 +03:00
Pete Matsyburka 584577c21f fix ed.jp email typo 2026-05-01 13:50:17 +03:00
Pete Matsyburka 1503375eed respond to HEAD file proxy 2026-04-30 17:55:13 +03:00
Pete Matsyburka 2dec099f20 fix signature canvas minimize 2026-04-30 14:51:24 +03:00
Pete Matsyburka bf6eed4ea2 fix canvas error 2026-04-30 14:42:22 +03:00
Pete Matsyburka e0dc33cfb4 fix email domain search 2026-04-30 11:27:17 +03:00
Pete Matsyburka 9c9dc27537 fix new area order 2026-04-30 11:13:35 +03:00
Pete Matsyburka 7305637b8c sort appears on 2026-04-30 10:32:51 +03:00
Pete Matsyburka a6e22adf2e fix preview 2026-04-29 18:00:47 +03:00
Pete Matsyburka 809c6b1aa8 fix modal open 2026-04-29 10:27:02 +03:00
Pete Matsyburka 1536ded2a6 redis error log 2026-04-28 19:30:51 +03:00
Pete Matsyburka 76d54971ce fix test mode clone 2026-04-28 11:58:17 +03:00
Pete Matsyburka 6dec56198c add require email 2fa to submitter update 2026-04-28 11:58:17 +03:00
Pete Matsyburka d96d252df3 use post for test mode 2026-04-28 11:58:17 +03:00
Pete Matsyburka 3fca76a716 change state param 2026-04-28 11:58:17 +03:00
Pete Matsyburka a184fa11f1 move auth links 2026-04-28 11:58:17 +03:00
Pete Matsyburka e9c0b6a703 rate limit 2026-04-28 11:58:17 +03:00
Pete Matsyburka ec7df08496 adjust send submission email 2026-04-28 11:58:17 +03:00
Pete Matsyburka 6627d7eac5 fix erblint 2026-04-27 15:34:57 +03:00
Pete Matsyburka 1f8945d710 fix expired filter 2026-04-27 15:31:29 +03:00
Pete Matsyburka 81a6a48d70 validate color 2026-04-26 15:13:19 +03:00
88 changed files with 1489 additions and 567 deletions

No files matched your search

+7 -7
View File
@@ -257,7 +257,7 @@ GEM
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
hashdiff (1.2.1)
hexapdf (1.6.0)
hexapdf (1.7.0)
cmdparse (~> 3.0, >= 3.0.3)
geom2d (~> 0.4, >= 0.4.1)
openssl (>= 2.2.1)
@@ -318,7 +318,7 @@ GEM
multi_json (1.19.1)
net-http (0.9.1)
uri (>= 0.11.1)
net-imap (0.6.3)
net-imap (0.6.4)
date
net-protocol
net-pop (0.1.2)
@@ -328,15 +328,15 @@ GEM
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
nokogiri (1.19.2-aarch64-linux-gnu)
nokogiri (1.19.3-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.2-aarch64-linux-musl)
nokogiri (1.19.3-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.19.2-arm64-darwin)
nokogiri (1.19.3-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.2-x86_64-linux-gnu)
nokogiri (1.19.3-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.2-x86_64-linux-musl)
nokogiri (1.19.3-x86_64-linux-musl)
racc (~> 1.4)
numo-narray-alt (0.10.3)
oj (3.16.16)
+2 -2
View File
@@ -50,7 +50,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
- Company logo and white-label
- User roles
- Automated reminders
- Invitation and identify verification via SMS
- Invitation and identity verification via SMS
- Conditional fields and formulas
- Bulk send with CSV, XLSX spreadsheet import
- SSO / SAML
@@ -74,7 +74,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
docker run --name docuseal -p 3000:3000 -v.:/data docuseal/docuseal
```
By default DocuSeal docker container uses an SQLite database to store data and configurations. Alternatively, it is possible use PostgreSQL or MySQL databases by specifying the `DATABASE_URL` env variable.
By default DocuSeal docker container uses an SQLite database to store data and configurations. Alternatively, it is possible to use PostgreSQL or MySQL databases by specifying the `DATABASE_URL` env variable.
#### Docker Compose
@@ -33,9 +33,15 @@ module Api
else
http_cache_forever public: true do
response.headers['Accept-Ranges'] = 'bytes'
response.headers['Content-Length'] = blob.byte_size.to_s
send_blob_stream blob, disposition: params[:disposition]
if request.head?
response.headers['Content-Type'] = blob.content_type_for_serving
head :ok
else
send_blob_stream blob, disposition: params[:disposition]
end
response.headers['Content-Length'] = blob.byte_size.to_s
end
end
end
@@ -57,8 +63,6 @@ module Api
return if !require_ttl && !require_auth
end
Rollbar.error('Blob unauthorized') if defined?(Rollbar)
raise CanCan::AccessDenied
end
end
@@ -8,10 +8,10 @@ module Api
COOKIE_STORE_LIMIT = 10
def create
submitter = Submitter.find_by!(slug: params[:submitter_slug])
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
unless can_upload?(submitter)
Rollbar.error("Can't upload: #{submitter.id}") if defined?(Rollbar)
unless can_upload?(@submitter)
Rollbar.error("Can't upload: #{@submitter.id}") if defined?(Rollbar)
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
end
@@ -20,23 +20,23 @@ module Api
image = Vips::Image.new_from_file(params[:file].path)
if ImageUtils.blank?(image)
Rollbar.error("Empty signature: #{submitter.id}") if defined?(Rollbar)
Rollbar.error("Empty signature: #{@submitter.id}") if defined?(Rollbar)
return render json: { error: "#{params[:type]} is empty" }, status: :unprocessable_content
end
if ImageUtils.error?(image)
Rollbar.error("Error signature: #{submitter.id}") if defined?(Rollbar)
Rollbar.error("Error signature: #{@submitter.id}") if defined?(Rollbar)
return render json: { error: "#{params[:type]} error, try to sign on another device" },
status: :unprocessable_content
end
end
attachment = Submitters.create_attachment!(submitter, params)
attachment = Submitters.create_attachment!(@submitter, params)
if params[:remember_signature] == 'true' && submitter.email.present?
cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(submitter, attachment)
if params[:remember_signature] == 'true' && @submitter.email.present?
cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment)
end
render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
@@ -203,10 +203,15 @@ module Api
submitter.preferences['send_sms'] = submitter_preferences['send_sms'] if submitter_preferences.key?('send_sms')
submitter.preferences['reply_to'] = submitter_preferences['reply_to'] if submitter_preferences.key?('reply_to')
if submitter_preferences.key?('require_phone_2fa')
submitter.preferences['require_phone_2fa'] = submitter_preferences['require_phone_2fa']
end
if submitter_preferences.key?('require_email_2fa')
submitter.preferences['require_email_2fa'] = submitter_preferences['require_email_2fa']
end
if submitter_preferences.key?('go_to_last')
submitter.preferences['go_to_last'] = submitter_preferences['go_to_last']
end
@@ -41,7 +41,7 @@ class PreviewDocumentPageController < ActionController::API
end
def find_or_create_document_tempfile_path(attachment)
file_path = "#{Dir.tmpdir}/#{attachment.uuid}"
file_path = "#{Dir.tmpdir}/attachment-#{Digest::SHA1.hexdigest("#{attachment.id}-#{attachment.uuid}")}"
File.open(file_path, File::RDWR | File::CREAT, 0o644) do |f|
f.flock(File::LOCK_EX)
@@ -1,6 +1,14 @@
# frozen_string_literal: true
class RevealAccessTokenController < ApplicationController
rate_limit to: 4, within: 1.minute, only: %i[create], by: -> { current_user.id }, with: lambda {
Rollbar.error('Rate limit api key') if defined?(Rollbar)
render turbo_stream: turbo_stream.replace(:modal, template: 'reveal_access_token/show',
locals: { error_message: I18n.t(:too_many_attempts) }),
status: :unprocessable_content
}
def show
authorize!(:manage, current_user.access_token)
end
@@ -14,7 +14,7 @@ class SendSubmissionEmailController < ApplicationController
template = Template.find_by!(slug: params[:template_slug])
@submitter =
Submitter.completed.where(submission: template.submissions).find_by!(email: params[:email].to_s.downcase)
Submitter.completed.where(submission: template.submissions).find_by(email: params[:email].to_s.downcase)
elsif params[:submission_slug]
submission = Submission.find_by(slug: params[:submission_slug])
@@ -27,9 +27,11 @@ class SendSubmissionEmailController < ApplicationController
@submitter = Submitter.completed.find_by!(slug: params[:submitter_slug])
end
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
if @submitter
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter)
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter)
end
respond_to do |f|
f.html { render :success }
@@ -5,12 +5,12 @@ class SubmitFormInviteController < ApplicationController
skip_authorization_check
def create
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return head :unprocessable_content unless can_invite?(submitter)
return head :unprocessable_content unless can_invite?(@submitter)
invite_submitters = filter_invite_submitters(submitter, 'invite_by_uuid')
optional_invite_submitters = filter_invite_submitters(submitter, 'optional_invite_by_uuid')
invite_submitters = filter_invite_submitters(@submitter, 'invite_by_uuid')
optional_invite_submitters = filter_invite_submitters(@submitter, 'optional_invite_by_uuid')
ApplicationRecord.transaction do
(invite_submitters + optional_invite_submitters).each do |item|
@@ -21,18 +21,18 @@ class SubmitFormInviteController < ApplicationController
email = Submissions.normalize_email(attrs[:email])
submitter.submission.submitters.create!(uuid: attrs[:uuid], email:, account_id: submitter.account_id)
@submitter.submission.submitters.create!(uuid: attrs[:uuid], email:, account_id: @submitter.account_id)
SubmissionEvents.create_with_tracking_data(submitter, 'invite_party', request, { uuid: submitter.uuid })
SubmissionEvents.create_with_tracking_data(@submitter, 'invite_party', request, { uuid: @submitter.uuid })
end
submitter.submission.update!(submitters_order: :preserved)
@submitter.submission.update!(submitters_order: :preserved)
end
submitter.submission.submitters.reload
@submitter.submission.submitters.reload
if invite_submitters.all? { |s| submitter.submission.submitters.any? { |e| e.uuid == s['uuid'] } }
Submitters::SubmitValues.call(submitter, ActionController::Parameters.new(completed: 'true'), request)
if invite_submitters.all? { |s| @submitter.submission.submitters.any? { |e| e.uuid == s['uuid'] } }
Submitters::SubmitValues.call(@submitter, ActionController::Parameters.new(completed: 'true'), request)
head :ok
else
@@ -5,19 +5,19 @@ class SubmitFormMetadataController < ApplicationController
skip_authorization_check
def index
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return head :not_found if submitter.declined_at? ||
submitter.completed_at? ||
submitter.submission.archived_at? ||
submitter.submission.expired? ||
submitter.submission.template&.archived_at? ||
submitter.account.archived_at? ||
!Submitters::AuthorizedForForm.call(submitter, current_user, request)
return head :not_found if @submitter.declined_at? ||
@submitter.completed_at? ||
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
@submitter.submission.template&.archived_at? ||
@submitter.account.archived_at? ||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
submission = submitter.submission
submission = @submitter.submission
values = submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) }
schema = Submissions.filtered_conditions_schema(submission, values:, include_submitter_uuid: submitter.uuid)
schema = Submissions.filtered_conditions_schema(submission, values:, include_submitter_uuid: @submitter.uuid)
documents = schema.filter_map do |item|
submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] }
@@ -5,21 +5,21 @@ class SubmitFormValuesController < ApplicationController
skip_authorization_check
def index
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return render json: {} if submitter.completed_at? ||
submitter.declined_at? ||
submitter.submission.template&.archived_at? ||
submitter.submission.archived_at? ||
submitter.submission.expired? ||
!Submitters::AuthorizedForForm.call(submitter, current_user, request)
return render json: {} if @submitter.completed_at? ||
@submitter.declined_at? ||
@submitter.submission.template&.archived_at? ||
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
value = submitter.values[params['field_uuid']]
attachment = submitter.attachments.where(created_at: params[:after]..).find_by(uuid: value) if value.present?
value = @submitter.values[params['field_uuid']]
attachment = @submitter.attachments.where(created_at: params[:after]..).find_by(uuid: value) if value.present?
render json: {
value:,
attachment: attachment&.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
}, head: :ok
}
end
end
+9 -2
View File
@@ -1,6 +1,10 @@
# frozen_string_literal: true
class TemplatesController < ApplicationController
TEMPLATE_FIELDS = %i[id author_id folder_id external_id name slug
schema fields submitters variables_schema preferences
shared_link source archived_at created_at updated_at].freeze
load_and_authorize_resource :template
def show
@@ -33,10 +37,11 @@ class TemplatesController < ApplicationController
).call
@template_data =
@template.as_json.merge(
@template.as_json(only: TEMPLATE_FIELDS).merge(
documents: @template.schema_documents.as_json(
only: %i[id uuid],
methods: %i[metadata signed_key],
include: { preview_images: { methods: %i[url metadata filename] } }
include: { preview_images: { only: %i[id], methods: %i[url metadata filename] } }
)
).to_json
@@ -72,6 +77,8 @@ class TemplatesController < ApplicationController
WebhookUrls.enqueue_events(@template, 'template.updated')
TemplateVersions.find_or_create_for(@template, author: current_user) if params[:revision]
head :ok
end
@@ -0,0 +1,17 @@
# frozen_string_literal: true
class TemplatesVersionsController < ApplicationController
load_and_authorize_resource :template
def index
versions = @template.template_versions.order(id: :desc).preload(:author)
render json: versions.as_json(TemplateVersions::SERIALIZE_PARAMS)
end
def show
version = @template.template_versions.find(params[:id])
render json: TemplateVersions.serialize(version)
end
end
@@ -3,7 +3,7 @@
class TestingAccountsController < ApplicationController
skip_authorization_check only: :destroy
def show
def create
authorize!(:manage, current_account)
authorize!(:manage, current_user)
@@ -0,0 +1,7 @@
# frozen_string_literal: true
class WebhookHmacController < ApplicationController
load_and_authorize_resource :webhook_url, parent: false
def show; end
end
+2
View File
@@ -170,6 +170,8 @@ safeRegisterElement('template-builder', class extends HTMLElement {
withLogo: this.dataset.withLogo !== 'false',
withFieldsDetection: this.dataset.withFieldsDetection === 'true',
withDetectExistingFields: this.dataset.withDetectExistingFields === 'true',
withRevisions: true,
withRevisionsMenu: this.dataset.withRevisionsMenu === 'true',
editable: this.dataset.editable !== 'false',
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
withCustomFields: true,
+2 -1
View File
@@ -11,6 +11,7 @@ window.customElements.define('draw-signature', class extends HTMLElement {
this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
if (!this.canvas) return
if (!this.canvas.parentNode?.clientWidth) return
const { width, height } = this.canvas
@@ -89,7 +90,7 @@ window.customElements.define('draw-signature', class extends HTMLElement {
}
redrawCanvas (oldWidth, oldHeight) {
if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) {
if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0 && this.canvas.width > 0 && this.canvas.height > 0) {
const sx = this.canvas.width / oldWidth
const sy = this.canvas.height / oldHeight
+2 -1
View File
@@ -14,6 +14,7 @@ export default targetable(class extends HTMLElement {
this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
if (!this.canvas) return
if (!this.canvas.parentNode?.clientWidth) return
const { width, height } = this.canvas
@@ -80,7 +81,7 @@ export default targetable(class extends HTMLElement {
}
redrawCanvas (oldWidth, oldHeight) {
if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) {
if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0 && this.canvas.width > 0 && this.canvas.height > 0) {
const sx = this.canvas.width / oldWidth
const sy = this.canvas.height / oldHeight
+17 -2
View File
@@ -42,10 +42,25 @@ export default {
const areas = {}
this.field.areas?.forEach((area) => {
areas[area.attachment_uuid + area.page] ||= area
areas[area.attachment_uuid] ||= []
areas[area.attachment_uuid].push(area)
})
return Object.values(areas).slice(0, 6)
const sortedAreas = Object.values(areas).reduce((acc, group) => {
const seen = {}
const sortedGroup = [...group].sort((a, b) => a.page - b.page)
sortedGroup.forEach((area) => {
if (!seen[area.page]) {
seen[area.page] = true
acc.push(area)
}
})
return acc
}, [])
return sortedAreas.slice(0, 6)
}
}
}
+38 -38
View File
@@ -136,7 +136,7 @@ const es = {
verification_code_is_invalid: 'El código de verificación no es válido',
sign_on_the_touchscreen: 'Firmar en pantalla táctil',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Escanea el código QR con la aplicación de la cámara para abrir el formulario en el móvil y dibujar tu firma',
by_clicking_you_agree_to_the: 'Al hacer clic en "{button}", usted acepta el',
by_clicking_you_agree_to_the: 'Al hacer clic en "{button}", usted acepta la',
electronic_signature_disclosure: 'Divulgación de Firma Electrónica',
esignature_disclosure: 'Divulgación de eFirma',
already_paid: 'Ya pagado',
@@ -245,7 +245,7 @@ const it = {
verification_code_is_invalid: 'Il codice di verifica non è valido',
sign_on_the_touchscreen: 'Firma su schermo tattile',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: "Scansiona il codice QR con l'app della fotocamera per aprire il modulo sul cellulare e disegnare la tua firma",
by_clicking_you_agree_to_the: 'Cliccando su "{button}", accetti il',
by_clicking_you_agree_to_the: 'Cliccando su "{button}", accetti la',
electronic_signature_disclosure: 'Divulgazione della Firma Elettronica',
esignature_disclosure: 'Divulgazione della eFirma',
minimize: 'Minimizza',
@@ -258,7 +258,7 @@ const it = {
date: 'Data',
number: 'Numero',
image: 'Immagine',
pay: 'Pagamento',
pay: 'Paga',
file: 'File',
select: 'Seleziona',
checkbox: 'Checkbox',
@@ -574,7 +574,7 @@ const pl = {
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Zeskanuj kod QR za pomocą aplikacji aparatu, aby otworzyć formularz na telefonie i narysować swój podpis',
by_clicking_you_agree_to_the: 'Klikając na "{button}", zgadzasz się na',
electronic_signature_disclosure: 'Ujawnienie Elektronicznej Sygnatury',
esignature_disclosure: 'Ujawnienie ePodpisu',
esignature_disclosure: 'informację o e-podpisie',
minimize: 'Zminimalizuj',
text: 'Tekst',
already_paid: 'Już zapłacono',
@@ -642,7 +642,7 @@ const pl = {
toggle_multiline_text: 'Przełącz Tekst Wielolinijkowy',
email_has_been_sent: 'E-mail został wysłany',
processing: 'Przetwarzanie',
pay_with_stripe: 'Płatność za pomocą Stripe',
pay_with_stripe: 'Zapłać za pomocą Stripe',
reupload: 'Ponowne przesłanie',
upload: 'Przesyłanie',
files: 'Pliki',
@@ -678,12 +678,12 @@ const uk = {
authored_by: 'Автор',
select_a_reason: 'Виберіть причину',
value_is_invalid: 'Значення є неправильним',
verification_code_is_invalid: 'Код підтвердження є неправильним',
verification_code_is_invalid: 'Невірний код підтвердження',
sign_on_the_touchscreen: 'Підписати на сенсорному екрані',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Скануйте QR-код за допомогою програми камери, щоб відкрити форму на мобільному пристрої та намалювати свій підпис',
by_clicking_you_agree_to_the: 'Натиснувши на "{button}", ви погоджуєтеся з',
electronic_signature_disclosure: 'Розголошення Електронного Підпису',
esignature_disclosure: 'Розголошення еПідпису',
electronic_signature_disclosure: 'інформацією про е-підпис',
esignature_disclosure: 'інформацією про е-підпис',
minimize: 'Зменшити',
text: 'Текст',
already_paid: 'Вже оплачено',
@@ -751,7 +751,7 @@ const uk = {
toggle_multiline_text: 'Перемкнути Багаторядковий Текст',
email_has_been_sent: 'Електронний лист був відправлений',
processing: 'Обробка',
pay_with_strip: 'Сплатити за допомогою Stripe',
pay_with_stripe: 'Сплатити за допомогою Stripe',
reupload: 'Перезавантажити',
upload: 'Завантажити',
files: 'Файли',
@@ -774,7 +774,7 @@ const cs = {
verify_id: 'Ověřit ID',
identity_verification: 'Ověření identity',
complete: 'Dokončit',
fill_all_required_fields_to_complete: 'Please complete all mandatory fields',
fill_all_required_fields_to_complete: 'Vyplňte povinná pole pro dokončení',
sign_and_complete: 'Podepsat a dokončit',
invite: 'Pozvat',
email: 'E-mail',
@@ -782,8 +782,8 @@ const cs = {
reviewed: 'Zkontrolováno',
other: 'Jiné',
authored_by_me: 'Autorem jsem já',
approved_by: 'Schváleno kým',
reviewed_by: 'Zkontrolováno kým',
approved_by: 'Schválil(a)',
reviewed_by: 'Zkontroloval(a)',
authored_by: 'Autorem',
select_a_reason: 'Vyberte důvod',
value_is_invalid: 'Hodnota je neplatná',
@@ -792,7 +792,7 @@ const cs = {
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Naskenujte QR kód pomocí aplikace fotoaparátu, abyste otevřeli formulář na mobilním zařízení a nakreslili svůj podpis',
by_clicking_you_agree_to_the: 'Kliknutím na "{button}" souhlasíte s',
electronic_signature_disclosure: 'Zveřejněním Elektronického Podpisu',
esignature_disclosure: 'Zveřejnění ePodpisu',
esignature_disclosure: 'informací o e-podpisu',
already_paid: 'Už zaplaceno',
minimize: 'Minimalizovat',
text: 'Text',
@@ -860,7 +860,7 @@ const cs = {
toggle_multiline_text: 'Přepnout Víceřádkový Text',
email_has_been_sent: 'E-mail byl odeslán',
processing: 'Zpracování',
pay_with_stripe: 'Zaplacení přes Stripe',
pay_with_stripe: 'Zaplatit přes Stripe',
reupload: 'Znovu nahrát',
upload: 'Nahrát',
files: 'Soubory',
@@ -890,18 +890,18 @@ const pt = {
approved: 'Aprovado',
reviewed: 'Revisado',
other: 'Outro',
authored_by_me: 'Autorizado por mim',
authored_by_me: 'Escrito por mim',
approved_by: 'Aprovado por',
reviewed_by: 'Revisado por',
authored_by: 'Autorizado por',
authored_by: 'Escrito por',
select_a_reason: 'Selecione um motivo',
value_is_invalid: 'Valor é inválido',
verification_code_is_invalid: 'Código de verificação é inválido',
sign_on_the_touchscreen: 'Assinar na tela sensível',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Escaneie o código QR com o aplicativo da câmera para abrir o formulário no celular e desenhar sua assinatura',
by_clicking_you_agree_to_the: 'Ao clicar em "{button}", você concorda com o',
by_clicking_you_agree_to_the: 'Ao clicar em "{button}", você concorda com a',
electronic_signature_disclosure: 'Divulgação de Assinatura Eletrônica',
esignature_disclosure: 'Divulgação da eAssinatura',
esignature_disclosure: 'Informação sobre eAssinatura',
already_paid: 'Já pago',
minimize: 'Minimizar',
text: 'Texto',
@@ -970,7 +970,7 @@ const pt = {
email_has_been_sent: 'Email enviado',
processing: 'Processamento',
pay_with_stripe: 'Pagar com Stripe',
reupload: 'Reenviar',
reupload: 'Recarregar',
upload: 'Carregar',
files: 'Arquivos',
signature_is_too_small_or_simple_please_redraw: 'A assinatura é muito pequena ou simples. Por favor, redesenhe.',
@@ -1010,8 +1010,8 @@ const he = {
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'סרוק את קוד ה-QR באמצעות אפליקציית המצלמה כדי לפתוח את הטופס במובייל ולצייר את החתימה שלך',
by_clicking_you_agree_to_the: 'על ידי לחיצה על "{button}", אתה מסכים ל',
electronic_signature_disclosure: 'חשיפת חתימה אלקטרונית',
esignature_disclosure: 'חשיפת ה-eחתימה',
minimize: 'לקטן',
esignature_disclosure: 'חשיפת חתימה אלקטרונית',
minimize: 'מזער',
text: 'טקסט',
already_paid: 'כבר שולם',
signature: 'חתימה',
@@ -1022,9 +1022,9 @@ const he = {
number: 'מספר',
image: 'תמונה',
file: 'קובץ',
pay: 'תשלום',
pay: 'שלם',
select: 'בחר',
checkbox: 'תיק בחירה',
checkbox: 'סימון',
multiple: 'רב ערכים',
radio: 'רדיו',
cells: 'תאים',
@@ -1051,9 +1051,9 @@ const he = {
download: 'הורדה',
clear: 'נקה',
redraw: 'צייר מחדש',
draw_initials: 'צייר ציוני ראשי תיבות',
draw_initials: 'צייר ראשי תיבות',
type_signature_here: 'הקלד חתימה כאן',
type_initial_here: 'הקלד ציוני ראשי תיבות כאן',
type_initial_here: 'הקלד ראשי תיבות כאן',
form_has_been_completed: 'הטופס הושלם',
document_has_been_signed: 'המסמך נחתם!',
documents_have_been_signed: 'המסמכים נחתמו!',
@@ -1063,7 +1063,7 @@ const he = {
open_source_documents_software: 'תוכנה פתוחה למסמכים',
verified_phone_number: 'אימות מספר טלפון',
use_international_format: 'השתמש בפורמט בינלאומי: +1xxx',
six_digits_code: 'קוד משתמש שש ספרות',
six_digits_code: 'קוד שש ספרות',
change_phone_number: 'שינוי מספר טלפון',
sending: 'שולח',
resend_code: 'שלח מחדש קוד',
@@ -1072,7 +1072,7 @@ const he = {
set_today: 'קבע היום',
toggle_multiline_text: 'שנה בין טקסט במספר שורות לטקסט בשורה אחת',
draw_signature: 'צייר חתימה',
type_initial: 'הקלד ציוני ראשי תיבות',
type_initial: 'הקלד ראשי תיבות',
draw: 'צייר',
type: 'הקלד',
type_text: 'הקלד טקסט',
@@ -1098,7 +1098,7 @@ const nl = {
please_upload_an_image_file: 'Upload alstublieft een afbeeldingsbestand',
must_be_characters_length: 'Moet {number} tekens lang zijn',
complete_all_required_fields_to_proceed_with_identity_verification: 'Vul alle verplichte velden in om door te gaan met de identiteitsverificatie.',
verify_id: 'Verifiëren ID',
verify_id: 'ID verifiëren',
identity_verification: 'Identiteitsverificatie',
complete: 'Voltooien',
fill_all_required_fields_to_complete: 'Vul alle verplichte velden in om te voltooien',
@@ -1146,7 +1146,7 @@ const nl = {
continue: 'Doorgaan',
sign_now: 'Nu ondertekenen',
type_here_: 'Typ hier...',
optional: 'Optioneel',
optional: 'optioneel',
option: 'Optie',
appears_on: 'Verschijnt op',
page: 'Pagina',
@@ -1170,11 +1170,11 @@ const nl = {
powered_by: 'Aangedreven door',
please_check_the_box_to_continue: 'Vink het vakje aan om door te gaan.',
open_source_documents_software: 'Open source documenten software',
verified_phone_number: 'Geverifieerd telefoonnummer',
verified_phone_number: 'Telefoonnummer verifiëren',
use_international_format: 'Gebruik internationaal formaat: +1xxx',
six_digits_code: '6-cijferige code',
change_phone_number: 'Wijzig telefoonnummer',
sending: 'Voltooien...',
sending: 'Verzenden...',
resend_code: 'Code opnieuw verzenden',
verification_code_has_been_resent: 'Verificatiecode is opnieuw verzonden via SMS',
please_fill_all_required_fields: 'Vul alle verplichte velden in',
@@ -1232,7 +1232,7 @@ const ar = {
esignature_disclosure: 'كشف التوقيع الإلكتروني',
text: 'نص',
signature: 'توقيع',
initials: 'الاختصارات',
initials: 'حروف أولى',
date: 'تاريخ',
number: 'رقم',
digitally_signed_by: 'تم التوقيع رقميًا بواسطة',
@@ -1269,9 +1269,9 @@ const ar = {
download: 'تحميل',
clear: 'مسح',
redraw: 'إعادة الرسم',
draw_initials: 'ارسم الاختصارات',
draw_initials: 'ارسم حروف أولى',
type_signature_here: 'اكتب التوقيع هنا',
type_initial_here: 'اكتب الاختصارات هنا',
type_initial_here: 'اكتب حروف أولى هنا',
form_has_been_completed: 'تم إكمال النموذج!',
document_has_been_signed: 'تم توقيع الوثيقة!',
documents_have_been_signed: 'تم توقيع الوثائق!',
@@ -1290,13 +1290,13 @@ const ar = {
set_today: 'تعيين اليوم',
toggle_multiline_text: 'تبديل النصوص متعددة الأسطر',
draw_signature: 'ارسم التوقيع',
type_initial: 'اكتب الاختصارات',
type_initial: 'اكتب حروف أولى',
draw: 'ارسم',
type: 'اكتب',
type_text: 'اكتب نصًا',
email_has_been_sent: 'تم إرسال البريد الإلكتروني',
processing: 'جارٍ المعالجة',
pay_with_strip: 'الدفع بواسطة Stripe',
pay_with_stripe: 'الدفع بواسطة Stripe',
reupload: 'إعادة التحميل',
upload: 'تحميل',
files: 'الملفات',
@@ -1343,10 +1343,10 @@ const ko = {
signature: '서명',
initials: '이니셜',
date: '날짜',
digitally_signed_by: '디지털 서명이 완료되었습니다',
digitally_signed_by: '디지털 서명자:',
reason: '이유',
number: '숫자',
pay: '급여',
pay: '결제',
image: '이미지',
take_photo: '사진 찍기',
file: '파일',
@@ -544,6 +544,7 @@ export default {
this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
if (!this.$refs.canvas) return
if (!this.$refs.canvas.parentNode?.clientWidth) return
const { width, height } = this.$refs.canvas
@@ -586,7 +587,7 @@ export default {
redrawCanvas (oldWidth, oldHeight) {
const canvas = this.$refs.canvas
if (this.pad && !this.isTextSignature && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) {
if (this.pad && !this.isTextSignature && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0 && canvas.width > 0 && canvas.height > 0) {
const sx = canvas.width / oldWidth
const sy = canvas.height / oldHeight
+2 -1
View File
@@ -50,6 +50,7 @@
@remove="$emit('remove')"
@scroll-to="$emit('scroll-to', $event)"
@add-custom-field="$emit('add-custom-field')"
@click-title="$emit('click-title')"
/>
<div
ref="touchValueTarget"
@@ -317,7 +318,7 @@ export default {
default: false
}
},
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to', 'add-custom-field'],
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to', 'add-custom-field', 'click-title'],
data () {
return {
isContenteditable: false,
@@ -3,7 +3,7 @@
v-if="field?.type && (isSelected || isNameFocus) && !isInMultiSelection"
class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap flex z-10 field-area-controls"
style="top: -25px; height: 25px"
@mousedown.stop
@mousedown.stop="$emit('click-title')"
@pointerdown.stop
>
<FieldSubmitter
@@ -279,7 +279,7 @@ export default {
default: false
}
},
emits: ['remove', 'scroll-to', 'add-custom-field', 'change'],
emits: ['remove', 'scroll-to', 'add-custom-field', 'change', 'click-title'],
data () {
return {
isShowFormulaModal: false,
+181 -49
View File
@@ -51,6 +51,30 @@
</div>
</div>
</div>
<div
v-if="beforeRevisionSnapshot"
class="top-1.5 sticky h-0 z-20 max-w-2xl mx-auto"
>
<div class="alert border-base-content/30 py-2 px-2.5">
<IconInfoCircle class="stroke-info shrink-0 w-6 h-6" />
<span>{{ t('viewing_revision_from').replace('{date}', formatRevisionTime(beforeRevisionSnapshot.revision.created_at)) }}</span>
<div>
<button
class="btn btn-sm"
@click.prevent="cancelRevision"
>
{{ t('cancel') }}
</button>
<button
v-if="editable"
class="btn btn-sm btn-neutral text-white"
@click.prevent="applyRevision"
>
{{ t('apply') }}
</button>
</div>
</div>
</div>
<div
v-if="$slots.buttons || withTitle"
id="title_container"
@@ -213,6 +237,18 @@
<span class="whitespace-nowrap">{{ t('preferences') }}</span>
</a>
</li>
<li v-if="withRevisionsMenu">
<button
class="flex space-x-2"
@click.prevent="openRevisionsModal"
@mouseenter="preloadRevisions"
>
<span class="w-6 h-6 flex-shrink-0 flex items-center justify-center">
<IconHistory class="w-5 h-5" />
</span>
<span class="whitespace-nowrap">{{ t('revisions') }}</span>
</button>
</li>
<li v-if="withDownload">
<button
class="flex space-x-2"
@@ -600,7 +636,16 @@
<div
id="docuseal_modal_container"
class="modal-container"
/>
>
<RevisionsModal
v-if="isRevisionsModalOpen"
:template="template"
:revisions="revisions"
:locale="locale"
@close="isRevisionsModalOpen = false"
@apply="onRevisionApply"
/>
</div>
</div>
</template>
@@ -618,7 +663,8 @@ import DocumentPreview from './preview'
import DocumentControls from './controls'
import MobileFields from './mobile_fields'
import FieldSubmitter from './field_submitter'
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload } from '@tabler/icons-vue'
import RevisionsModal from './revisions_modal'
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload, IconHistory } from '@tabler/icons-vue'
import { v4 } from 'uuid'
import { ref, computed, toRaw, defineAsyncComponent } from 'vue'
import * as i18n from './i18n'
@@ -658,7 +704,9 @@ export default {
IconDownload,
IconAdjustments,
IconEye,
IconDeviceFloppy
IconHistory,
IconDeviceFloppy,
RevisionsModal
},
provide () {
return {
@@ -980,6 +1028,16 @@ export default {
type: Boolean,
required: false,
default: false
},
withRevisions: {
type: Boolean,
required: false,
default: false
},
withRevisionsMenu: {
type: Boolean,
required: false,
default: false
}
},
data () {
@@ -1002,7 +1060,10 @@ export default {
drawOption: null,
dragField: null,
isDragFile: false,
isMathLoaded: false
isMathLoaded: false,
isRevisionsModalOpen: false,
revisions: [],
beforeRevisionSnapshot: null
}
},
computed: {
@@ -1012,6 +1073,13 @@ export default {
fieldsDragFieldRef: () => ref(),
customDragFieldRef: () => ref(),
selectedAreasRef: () => ref([]),
attachmentUuidsIndex () {
return this.template.schema.reduce((acc, e, index) => {
acc[e.attachment_uuid] = index
return acc
}, {})
},
language () {
return this.locale.split('-')[0].toLowerCase()
},
@@ -1663,33 +1731,26 @@ export default {
this.save()
}
},
compareAreas (a, b) {
const aAttIdx = this.attachmentUuidsIndex[a.attachment_uuid]
const bAttIdx = this.attachmentUuidsIndex[b.attachment_uuid]
if (aAttIdx !== bAttIdx) return aAttIdx - bAttIdx
if (a.page !== b.page) return a.page - b.page
const aY = a.y + a.h
const bY = b.y + b.h
if (Math.abs(aY - bY) < 0.01) return a.x - b.x
if (a.h < b.h ? a.y >= b.y && aY <= bY : b.y >= a.y && bY <= aY) return a.x - b.x
return aY - bY
},
findFieldInsertIndex (field) {
if (!field.areas?.length) return -1
const area = field.areas[0]
const attachmentUuidsIndex = this.template.schema.reduce((acc, e, index) => {
acc[e.attachment_uuid] = index
return acc
}, {})
const compareAreas = (a, b) => {
const aAttIdx = attachmentUuidsIndex[a.attachment_uuid]
const bAttIdx = attachmentUuidsIndex[b.attachment_uuid]
if (aAttIdx !== bAttIdx) return aAttIdx - bAttIdx
if (a.page !== b.page) return a.page - b.page
const aY = a.y + a.h
const bY = b.y + b.h
if (Math.abs(aY - bY) < 0.01) return a.x - b.x
if (a.h < b.h ? a.y >= b.y && aY <= bY : b.y >= a.y && bY <= aY) return a.x - b.x
return aY - bY
}
let closestBeforeIndex = -1
let closestBeforeArea = null
let closestAfterIndex = -1
@@ -1698,15 +1759,15 @@ export default {
this.template.fields.forEach((f, index) => {
if (f.submitter_uuid === field.submitter_uuid) {
(f.areas || []).forEach((a) => {
const cmp = compareAreas(a, area)
const cmp = this.compareAreas(a, area)
if (cmp < 0) {
if (!closestBeforeArea || (compareAreas(a, closestBeforeArea) > 0 && closestBeforeIndex < index)) {
if (!closestBeforeArea || (this.compareAreas(a, closestBeforeArea) > 0 && closestBeforeIndex < index)) {
closestBeforeIndex = index
closestBeforeArea = a
}
} else {
if (!closestAfterArea || (compareAreas(a, closestAfterArea) < 0 && closestAfterIndex > index)) {
if (!closestAfterArea || (this.compareAreas(a, closestAfterArea) < 0 && closestAfterIndex > index)) {
closestAfterIndex = index
closestAfterArea = a
}
@@ -1729,6 +1790,17 @@ export default {
this.template.fields.push(field)
}
},
insertArea (field, area) {
field.areas ||= []
const insertIndex = field.areas.findIndex((a) => this.compareAreas(a, area) > 0)
if (insertIndex === -1) {
field.areas.push(area)
} else {
field.areas.splice(insertIndex, 0, area)
}
},
insertDetectedField (field) {
if (!this.withDetectExistingFields || !field.name) {
this.insertField(field)
@@ -1744,7 +1816,7 @@ export default {
if (existingField) {
existingField.areas = existingField.areas || []
existingField.areas.push(...(field.areas || []))
field.areas.forEach((area) => this.insertArea(existingField, area))
} else {
const customField = this.detectCustomFieldsIndex[indexKey] || this.detectCustomFieldsIndex[nameKey]
@@ -1756,6 +1828,73 @@ export default {
closeDropdown () {
document.activeElement.blur()
},
preloadRevisions () {
this.loadRevisionsPromise ||= this.baseFetch(`/templates/${this.template.id}/versions`)
},
openRevisionsModal () {
this.closeDropdown()
this.loadRevisionsPromise ||= this.baseFetch(`/templates/${this.template.id}/versions`)
this.loadRevisionsPromise.then(async (resp) => {
this.revisions = await resp.json()
this.isRevisionsModalOpen = true
}).finally(() => {
this.loadRevisionsPromise = null
})
},
onRevisionApply (revision) {
this.beforeRevisionSnapshot = {
template: JSON.parse(JSON.stringify(this.template)),
dynamicDocuments: JSON.parse(JSON.stringify(this.dynamicDocuments)),
revision
}
const { dynamic_documents: nextDynamicDocs = [], ...nextTemplate } = revision.data
Object.assign(this.template, nextTemplate)
this.dynamicDocuments.splice(0, this.dynamicDocuments.length, ...nextDynamicDocs)
this.$nextTick(() => this.reloadDynamicDocumentContent())
this.isRevisionsModalOpen = false
},
cancelRevision () {
Object.assign(this.template, this.beforeRevisionSnapshot.template)
this.dynamicDocuments.splice(0, this.dynamicDocuments.length, ...this.beforeRevisionSnapshot.dynamicDocuments)
this.beforeRevisionSnapshot = null
this.$nextTick(() => this.reloadDynamicDocumentContent())
},
applyRevision () {
this.beforeRevisionSnapshot = null
const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic)
dynamicDocumentRefs.forEach((ref) => ref.update())
this.rebuildVariablesSchema({ disable: false })
return Promise.all([this.save({ force: true }), ...dynamicDocumentRefs.map((ref) => ref.saveBody())])
},
reloadDynamicDocumentContent () {
this.documentRefs.forEach((ref) => {
if (ref.isDynamic) ref.reloadContent()
})
},
formatRevisionTime (string) {
return new Date(string).toLocaleString(this.locale || undefined, {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
})
},
t (key) {
return this.i18n[key] || i18n[this.language]?.[key] || i18n.en[key] || key
},
@@ -2249,7 +2388,7 @@ export default {
fieldUuidIndex[field.uuid] = newField
newField.areas.push(newArea)
this.insertArea(newField, newArea)
newAreas.push(newArea)
if (['radio', 'multiple'].includes(field.type) && field.options?.length) {
@@ -2362,17 +2501,7 @@ export default {
area.y -= area.h / 2
}
this.drawField.areas ||= []
const insertBeforeAreaIndex = this.drawField.areas.findIndex((a) => {
return a.attachment_uuid === area.attachment_uuid && a.page > area.page
})
if (insertBeforeAreaIndex !== -1) {
this.drawField.areas.splice(insertBeforeAreaIndex, 0, area)
} else {
this.drawField.areas.push(area)
}
this.insertArea(this.drawField, area)
if (this.template.fields.indexOf(this.drawField) === -1) {
this.insertField(this.drawField)
@@ -2513,9 +2642,7 @@ export default {
delete field.height
}
field.areas ||= []
field.areas.push(fieldArea)
this.insertArea(field, fieldArea)
if (this.selectedAreasRef.value.length < 2) {
this.selectedAreasRef.value = [fieldArea]
@@ -2585,7 +2712,7 @@ export default {
}
}
field.areas.push(fieldArea)
this.insertArea(field, fieldArea)
})
} else {
const fieldArea = {
@@ -3014,7 +3141,7 @@ export default {
const dynamicDocumentSaves = dynamicDocumentRefs.map((ref) => ref.saveBody())
Promise.all([this.save(), ...dynamicDocumentSaves]).then(() => {
Promise.all([this.save({ force: true, revision: this.withRevisions }), ...dynamicDocumentSaves]).then(() => {
window.Turbo.visit(`/templates/${this.template.id}`)
}).finally(() => {
this.isSaving = false
@@ -3245,9 +3372,13 @@ export default {
}
})
},
save ({ force } = { force: false }) {
save ({ force = false, revision = false } = {}) {
this.pendingFieldAttachmentUuids = []
if (this.beforeRevisionSnapshot) {
this.beforeRevisionSnapshot = null
}
if (this.onChange) {
this.onChange(this.template)
}
@@ -3273,7 +3404,8 @@ export default {
submitters: this.template.submitters,
fields: this.template.fields,
variables_schema: this.template.variables_schema
}
},
...(revision ? { revision: true } : {})
}),
headers: { 'Content-Type': 'application/json' }
}).then(() => {
@@ -6,7 +6,7 @@
class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')"
/>
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="modal-box pt-4 pb-6 px-6 mt-20 w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span class="modal-title">
{{ t('condition') }} - {{ (defaultField ? (defaultField.title || item.title || item.name) : item.name) || buildDefaultName(item) }}
@@ -183,6 +183,9 @@ export default {
this.sectionRefs.push(ref)
}
},
reloadContent () {
this.sectionRefs.forEach((ref) => ref.reloadContent())
},
onBeforeUnload (event) {
if (this.saveTimer) {
event.preventDefault()
@@ -281,6 +281,9 @@ export default {
}
},
methods: {
reloadContent () {
this.editor.commands.setContent(this.section.innerHTML, { emitUpdate: false })
},
findAreaNodePos (areaUuid) {
const el = this.editor.view.dom.querySelector(`[data-area-uuid="${areaUuid}"]`)
@@ -786,17 +786,27 @@ export default {
},
copyToAllPages (field) {
const areaString = JSON.stringify(field.areas[0])
const newAreas = []
const existingAreasIndex = field.areas.reduce((acc, area) => {
acc[`${area.attachment_uuid}-${area.page}`] = area
return acc
}, {})
this.template.schema.forEach((item) => {
const attachment = this.template.documents.find((d) => d.uuid === item.attachment_uuid)
this.template.documents.forEach((attachment) => {
const numberOfPages = attachment.metadata?.pdf?.number_of_pages || attachment.preview_images.length
for (let page = 0; page <= numberOfPages - 1; page++) {
if (!field.areas.find((area) => area.attachment_uuid === attachment.uuid && area.page === page)) {
field.areas.push({ ...JSON.parse(areaString), attachment_uuid: attachment.uuid, page })
}
const existing = existingAreasIndex[`${attachment.uuid}-${page}`]
newAreas.push(existing || { ...JSON.parse(areaString), attachment_uuid: attachment.uuid, page })
}
})
field.areas = newAreas
this.$emit('scroll-to', this.field.areas[this.field.areas.length - 1])
this.$emit('save')
@@ -147,7 +147,7 @@
>
<a
href="#"
class="flex px-2 group justify-between items-center"
class="flex px-2 text-sm group justify-between items-center"
:class="{ 'active': submitter === selectedSubmitter, 'py-0.5': submitters.length > 8 }"
@click.prevent="selectSubmitter(submitter)"
>
@@ -198,7 +198,7 @@
>
<a
href="#"
class="flex px-2"
class="flex px-2 text-sm"
@click.prevent="addSubmitter"
>
<IconUserPlus
+1 -1
View File
@@ -383,7 +383,7 @@
class="rounded py-2 px-4 w-full border border-dashed border-base-300"
>
<div class="text-center text-sm">
{{ t('start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document') }}
{{ t('start_a_quick_tour_to_learn_how_to_create_and_send_your_first_document') }}
</div>
<div class="flex justify-center">
<label
+64 -36
View File
@@ -200,7 +200,7 @@ const en = {
learn_more: 'Learn more',
and: 'and',
or: 'or',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Start a quick tour to learn how to create and send your first document',
start_a_quick_tour_to_learn_how_to_create_and_send_your_first_document: 'Start a quick tour to learn how to create and send your first document',
start_tour: 'Start Tour',
or_add_from: 'Or add from',
sync: 'Sync',
@@ -215,7 +215,11 @@ const en = {
align_bottom: 'Align Bottom',
fields_selected: '{count} Fields Selected',
field_added: '{count} Field Added',
fields_added: '{count} Fields Added'
fields_added: '{count} Fields Added',
revisions: 'Revisions',
apply: 'Apply',
no_revisions_yet: 'No revisions yet',
viewing_revision_from: 'Viewing revision from {date}'
}
const es = {
@@ -239,7 +243,7 @@ const es = {
min: 'Mín',
max: 'Máx',
date_signed: 'Fecha actual',
fuente: 'Fuente',
font: 'Fuente',
party: 'Parte',
method: 'Método',
reorder_fields: 'Reordenar campos',
@@ -298,7 +302,7 @@ const es = {
set_signing_date: 'Establecer fecha de firma',
are_you_sure_: '¿Estás seguro?',
sign_yourself: 'Firma tú mismo',
checked: 'Seleccionado',
checked: 'Marcado',
current_date: 'Fecha actual',
send: 'Enviar',
remove: 'Eliminar',
@@ -336,16 +340,16 @@ const es = {
eighth_party: 'Octava Parte',
ninth_party: 'Novena Parte',
tenth_party: 'Décima Parte',
eleventh_party: 'Undécimo Partido',
twelfth_party: 'Duodécimo Partido',
thirteenth_party: 'Decimotercer Partido',
fourteenth_party: 'Catorceavo Partido',
fifteenth_party: 'Quinceavo Partido',
sixteenth_party: 'Dieciséisavo Partido',
seventeenth_party: 'Diecisieteavo Partido',
eighteenth_party: 'Dieciochoavo Partido',
nineteenth_party: 'Decimonovena Fiesta',
twentieth_party: 'Vigésima Fiesta',
eleventh_party: 'Undécima Parte',
twelfth_party: 'Duodécima Parte',
thirteenth_party: 'Decimotercera Parte',
fourteenth_party: 'Decimocuarta Parte',
fifteenth_party: 'Decimoquinta Parte',
sixteenth_party: 'Decimosexta Parte',
seventeenth_party: 'Decimoséptima Parte',
eighteenth_party: 'Decimoctava Parte',
nineteenth_party: 'Decimonovena Parte',
twentieth_party: 'Vigésima Parte',
draw: 'Dibujar',
add: 'Agregar',
text: 'Texto',
@@ -420,7 +424,7 @@ const es = {
learn_more: 'Aprende más',
and: 'y',
or: 'o',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Inicia una guía rápida para aprender a crear y enviar tu primer documento.',
start_a_quick_tour_to_learn_how_to_create_and_send_your_first_document: 'Inicia una guía rápida para aprender a crear y enviar tu primer documento.',
start_tour: 'Iniciar guía',
or_add_from: 'O agregar desde',
sync: 'Sincronizar',
@@ -435,7 +439,11 @@ const es = {
align_bottom: 'Alinear abajo',
fields_selected: '{count} Campos Seleccionados',
field_added: '{count} Campo Añadido',
fields_added: '{count} Campos Añadidos'
fields_added: '{count} Campos Añadidos',
revisions: 'Revisiones',
apply: 'Aplicar',
no_revisions_yet: 'Aún no hay revisiones',
viewing_revision_from: 'Viendo revisión del {date}'
}
const it = {
@@ -458,15 +466,15 @@ const it = {
length: 'Lunghezza',
min: 'Min',
max: 'Max',
date_signed: 'Data attuale',
date_signed: 'Data corrente',
font: 'Carattere',
party: 'Parte',
method: 'Metodo',
reorder_fields: 'Riordina i campi',
verify_id: 'Verifica ID',
obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Ottieni una firma elettronica qualificata (QeS) con il fornitore di fiducia. Clicca per saperne di più.',
ricorrente: 'Ricorrente',
una_volta: 'Una volta',
recurrent: 'Ricorrente',
one_off: 'Una tantum',
editable: 'Modificabile',
search_field: 'Campo di ricerca',
field_not_found: 'Campo non trovato',
@@ -525,7 +533,7 @@ const it = {
drawn: 'Disegnato',
drawn_or_typed: 'Disegnato o Digitato',
drawn_or_upload: 'Disegnato o Caricato',
upload: 'Caricare',
upload: 'Carica',
formula: 'Formula',
typed: 'Digitato',
draw_field_on_the_document: 'Disegnare un campo sul documento',
@@ -640,7 +648,7 @@ const it = {
learn_more: 'Scopri di più',
and: 'e',
or: 'o',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Inizia un tour rapido per imparare a creare e inviare il tuo primo documento.',
start_a_quick_tour_to_learn_how_to_create_and_send_your_first_document: 'Inizia un tour rapido per imparare a creare e inviare il tuo primo documento.',
start_tour: 'Inizia il tour',
or_add_from: 'O aggiungi da',
sync: 'Sincronizza',
@@ -655,7 +663,11 @@ const it = {
align_bottom: 'Allinea in basso',
fields_selected: '{count} Campi Selezionati',
field_added: '{count} Campo Aggiunto',
fields_added: '{count} Campi Aggiunti'
fields_added: '{count} Campi Aggiunti',
revisions: 'Revisioni',
apply: 'Applica',
no_revisions_yet: 'Nessuna revisione ancora',
viewing_revision_from: 'Visualizzazione revisione del {date}'
}
const pt = {
@@ -679,13 +691,13 @@ const pt = {
min: 'Mín',
max: 'Máx',
date_signed: 'Data atual',
fonte: 'Fonte',
font: 'Fonte',
party: 'Parte',
method: 'Método',
reorder_fields: 'Reorganizar campos',
verify_id: 'Verificar ID',
obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Obtenha a assinatura eletrônica qualificada (QeS) com o provedor confiável. Clique para saber mais.',
recurrent: 'Recurrente',
recurrent: 'Recorrente',
one_off: 'Único',
editable: 'Editável',
search_field: 'Campo de busca',
@@ -752,7 +764,7 @@ const pt = {
processing_: 'Processando...',
add_pdf_documents_or_images: 'Adicionar documentos PDF ou imagens',
add_documents_or_images: 'Adicionar documentos ou imagens',
add_a_new_document: 'Ajouter un nouveau document',
add_a_new_document: 'Adicionar um novo documento',
replace_existing_document: 'Substituir documento existente',
clone_and_replace_documents: 'Clonar e substituir documentos',
required: 'Obrigatório',
@@ -860,7 +872,7 @@ const pt = {
learn_more: 'Saiba mais',
and: 'e',
or: 'ou',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Comece um tour rápido para aprender a criar e enviar seu primeiro documento.',
start_a_quick_tour_to_learn_how_to_create_and_send_your_first_document: 'Comece um tour rápido para aprender a criar e enviar seu primeiro documento.',
start_tour: 'Iniciar tour',
or_add_from: 'Ou adicionar de',
sync: 'Sincronizar',
@@ -875,7 +887,11 @@ const pt = {
align_bottom: 'Alinhar à parte inferior',
fields_selected: '{count} Campos Selecionados',
field_added: '{count} Campo Adicionado',
fields_added: '{count} Campos Adicionados'
fields_added: '{count} Campos Adicionados',
revisions: 'Revisões',
apply: 'Aplicar',
no_revisions_yet: 'Nenhuma revisão ainda',
viewing_revision_from: 'Visualizando revisão de {date}'
}
const fr = {
@@ -900,7 +916,7 @@ const fr = {
max: 'Max',
font: 'Police',
party: 'Partie',
date_signed: 'Date de signature',
date_signed: 'Date du jour',
method: 'Méthode',
reorder_fields: 'Réorganiser les champs',
verify_id: "Vérifier l'ID",
@@ -1080,7 +1096,7 @@ const fr = {
learn_more: 'En savoir plus',
and: 'et',
or: 'ou',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Commencez une visite rapide pour apprendre à créer et à envoyer votre premier document',
start_a_quick_tour_to_learn_how_to_create_and_send_your_first_document: 'Commencez une visite rapide pour apprendre à créer et à envoyer votre premier document',
start_tour: 'Démarrer',
or_add_from: 'Ou ajouter depuis',
sync: 'Synchroniser',
@@ -1095,7 +1111,11 @@ const fr = {
align_bottom: 'Aligner en bas',
fields_selected: '{count} Champs Sélectionnés',
field_added: '{count} Champ Ajouté',
fields_added: '{count} Champs Ajoutés'
fields_added: '{count} Champs Ajoutés',
revisions: 'Révisions',
apply: 'Appliquer',
no_revisions_yet: 'Aucune révision pour le moment',
viewing_revision_from: 'Affichage de la révision du {date}'
}
const de = {
@@ -1120,7 +1140,7 @@ const de = {
max: 'Max.',
font: 'Schriftart',
party: 'Partei',
date_signed: 'Unterzeichnungsdatum',
date_signed: 'Aktuelles Datum',
method: 'Methode',
reorder_fields: 'Felder neu anordnen',
verify_id: 'ID überprüfen',
@@ -1300,7 +1320,7 @@ const de = {
learn_more: 'Mehr erfahren',
and: 'und',
or: 'oder',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Starten Sie eine Kurztour, um zu lernen, wie Sie Ihr erstes Dokument erstellen und versenden.',
start_a_quick_tour_to_learn_how_to_create_and_send_your_first_document: 'Starten Sie eine Kurztour, um zu lernen, wie Sie Ihr erstes Dokument erstellen und versenden.',
start_tour: 'Tour starten',
or_add_from: 'Oder hinzufügen aus',
sync: 'Synchronisieren',
@@ -1315,7 +1335,11 @@ const de = {
align_bottom: 'Unten ausrichten',
fields_selected: '{count} Felder Ausgewählt',
field_added: '{count} Feld Hinzugefügt',
fields_added: '{count} Felder Hinzugefügt'
fields_added: '{count} Felder Hinzugefügt',
revisions: 'Revisionen',
apply: 'Anwenden',
no_revisions_yet: 'Noch keine Revisionen',
viewing_revision_from: 'Ansicht der Revision vom {date}'
}
const nl = {
@@ -1340,7 +1364,7 @@ const nl = {
max: 'Max',
font: 'Lettertype',
party: 'Partij',
date_signed: 'Datum ondertekend',
date_signed: 'Huidige datum',
method: 'Methode',
reorder_fields: 'Velden herschikken',
verify_id: 'ID verifiëren',
@@ -1520,7 +1544,7 @@ const nl = {
learn_more: 'Meer informatie',
and: 'en',
or: 'of',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Start een korte rondleiding om te leren hoe u uw eerste document maakt en verzendt',
start_a_quick_tour_to_learn_how_to_create_and_send_your_first_document: 'Start een korte rondleiding om te leren hoe u uw eerste document maakt en verzendt',
start_tour: 'Rondleiding starten',
or_add_from: 'Of toevoegen van',
sync: 'Synchroniseren',
@@ -1535,7 +1559,11 @@ const nl = {
align_bottom: 'Onder uitlijnen',
fields_selected: '{count} Velden Geselecteerd',
field_added: '{count} Veld Toegevoegd',
fields_added: '{count} Velden Toegevoegd'
fields_added: '{count} Velden Toegevoegd',
revisions: 'Revisies',
apply: 'Toepassen',
no_revisions_yet: 'Nog geen revisies',
viewing_revision_from: 'Revisie van {date} bekijken'
}
export { en, es, it, pt, fr, de, nl }
+1
View File
@@ -57,6 +57,7 @@
@scroll-to="$emit('scroll-to', $event)"
@add-custom-field="$emit('add-custom-field', $event)"
@contextmenu="openAreaContextMenu($event, item.area, item.field)"
@click-title="closeContextMenu"
/>
<FieldArea
v-for="(area, index) in newAreas"
@@ -143,8 +143,8 @@
>
<input
type="hidden"
name="state"
:value="oauthState"
name="oauth_data"
:value="oauthData"
autocomplete="off"
>
<input
@@ -334,7 +334,7 @@ export default {
authenticityToken () {
return document.querySelector('meta[name="csrf-token"]')?.content
},
oauthState () {
oauthData () {
const params = new URLSearchParams('')
params.set('redir', document.location.href)
@@ -0,0 +1,104 @@
<template>
<div class="modal modal-open items-start !animate-none overflow-y-auto">
<div
class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')"
/>
<div class="modal-box pt-4 pb-6 mt-20 w-full">
<div class="flex justify-between items-center border-b pb-2 mb-3 font-medium">
<span>{{ t('revisions') }}</span>
<a
href="#"
class="text-xl"
@click.prevent="$emit('close')"
>&times;</a>
</div>
<ul class="space-y-1.5">
<li
v-for="revision in revisions"
:key="revision.id"
>
<button
type="button"
class="w-full text-left rounded-lg p-3 border border-dashed border-base-200 transition-colors disabled:cursor-default hover:bg-base-200 hover:border-base-200"
:disabled="loadingId !== null"
@click="viewRevision(revision.id)"
>
<div class="flex justify-between items-center gap-2">
<div class="flex flex-col">
<span>{{ formatDate(revision.created_at) }}</span>
<span class="-ml-0.5 flex items-center space-x-1 text-xs text-base-content/60 mt-0.5">
<IconUser class="w-3.5 h-3.5 flex-shrink-0" />
<span class="truncate">{{ revision.author.full_name || revision.author.email }}</span>
</span>
</div>
<span class="btn btn-sm btn-neutral text-white pointer-events-none flex-shrink-0">
<IconInnerShadowTop
v-if="loadingId === revision.id"
class="w-4 h-4 animate-spin"
/>
<span v-else>{{ t('view') }}</span>
</span>
</div>
</button>
</li>
<li
v-if="!revisions.length"
class="py-4 text-center text-base-content/60"
>
{{ t('no_revisions_yet') }}
</li>
</ul>
</div>
</div>
</template>
<script>
import { IconUser, IconInnerShadowTop } from '@tabler/icons-vue'
export default {
name: 'RevisionsModal',
components: { IconUser, IconInnerShadowTop },
inject: ['t', 'baseFetch'],
props: {
template: {
type: Object,
required: true
},
revisions: {
type: Array,
required: true
},
locale: {
type: String,
required: true
}
},
emits: ['close', 'apply'],
data () {
return {
loadingId: null
}
},
methods: {
viewRevision (id) {
if (this.loadingId !== null) return
this.loadingId = id
this.baseFetch(`/templates/${this.template.id}/versions/${id}`)
.then((r) => r.json())
.then((revision) => { this.$emit('apply', revision) })
.finally(() => { this.loadingId = null })
},
formatDate (string) {
return new Date(string).toLocaleString(this.locale || undefined, {
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
})
}
}
}
</script>
+1 -1
View File
@@ -228,7 +228,7 @@ export default {
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/drive.file'
].join(' '),
state: new URLSearchParams({
oauth_data: new URLSearchParams({
redir: `/templates/${this.templateId}/edit?google_drive_open=1`
}).toString()
}
@@ -30,13 +30,12 @@ class SendFormCompletedWebhookRequestJob
attempt:,
data: Submitters::SerializeForWebhook.call(submitter))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormCompletedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendFormCompletedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
@@ -28,13 +28,12 @@ class SendFormDeclinedWebhookRequestJob
attempt:,
data: Submitters::SerializeForWebhook.call(submitter))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormDeclinedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendFormDeclinedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
@@ -28,13 +28,12 @@ class SendFormStartedWebhookRequestJob
attempt:,
data: Submitters::SerializeForWebhook.call(submitter))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormStartedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendFormStartedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
@@ -28,13 +28,12 @@ class SendFormViewedWebhookRequestJob
attempt:,
data: Submitters::SerializeForWebhook.call(submitter))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormViewedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendFormViewedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
@@ -26,13 +26,12 @@ class SendSubmissionArchivedWebhookRequestJob
attempt:,
data: submission.as_json(only: %i[id archived_at]))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan))
SendSubmissionArchivedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendSubmissionArchivedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
@@ -26,13 +26,12 @@ class SendSubmissionCompletedWebhookRequestJob
attempt:,
data: Submissions::SerializeForApi.call(submission))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan))
SendSubmissionCompletedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendSubmissionCompletedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
@@ -26,13 +26,12 @@ class SendSubmissionCreatedWebhookRequestJob
attempt:,
data: Submissions::SerializeForApi.call(submission))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan))
SendSubmissionCreatedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendSubmissionCreatedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
@@ -26,13 +26,12 @@ class SendSubmissionExpiredWebhookRequestJob
attempt:,
data: Submissions::SerializeForApi.call(submission))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan))
SendSubmissionExpiredWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendSubmissionExpiredWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
@@ -26,13 +26,12 @@ class SendTemplateArchivedWebhookRequestJob
attempt:,
data: template.as_json(only: %i[id archived_at]))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan))
SendTemplateArchivedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendTemplateArchivedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
@@ -26,13 +26,12 @@ class SendTemplateCreatedWebhookRequestJob
attempt:,
data: Templates::SerializeForApi.call(template))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan))
SendTemplateCreatedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendTemplateCreatedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
@@ -26,13 +26,12 @@ class SendTemplateUpdatedWebhookRequestJob
attempt:,
data: Templates::SerializeForApi.call(template))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan))
SendTemplateUpdatedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendTemplateUpdatedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
+6 -1
View File
@@ -101,13 +101,18 @@ class Submission < ApplicationRecord
where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
.and(Submitter.arel_table[:declined_at].not_eq(nil))).select(1).arel.exists)
}
scope :expired, -> { pending.where(expire_at: ..Time.current) }
scope :expired, lambda {
where(expire_at: ..Time.current)
.where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
.and(Submitter.arel_table[:completed_at].eq(nil))).select(1).arel.exists)
}
enum :source, {
invite: 'invite',
bulk: 'bulk',
api: 'api',
embed: 'embed',
mcp: 'mcp',
link: 'link'
}, scope: false, prefix: true
+1
View File
@@ -70,6 +70,7 @@ class Template < ApplicationRecord
has_many :submissions, dependent: :destroy
has_many :template_sharings, dependent: :destroy
has_many :template_accesses, dependent: :destroy
has_many :template_versions, dependent: :destroy
has_many :dynamic_documents, dependent: :destroy
has_many :dynamic_document_versions, through: :dynamic_documents, source: :versions
+44
View File
@@ -0,0 +1,44 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: template_versions
#
# id :bigint not null, primary key
# data :text not null
# sha1 :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# author_id :bigint not null
# template_id :bigint not null
#
# Indexes
#
# index_template_versions_on_account_id (account_id)
# index_template_versions_on_author_id (author_id)
# index_template_versions_on_template_id_and_sha1 (template_id,sha1) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (author_id => users.id)
# fk_rails_... (template_id => templates.id)
#
class TemplateVersion < ApplicationRecord
belongs_to :template
belongs_to :account
belongs_to :author, class_name: 'User'
attribute :data, :string, default: -> { {} }
serialize :data, coder: JSON
before_validation :set_account, on: :create
private
def set_account
self.account ||= template.account
end
end
+15 -9
View File
@@ -4,14 +4,15 @@
#
# Table name: webhook_urls
#
# id :bigint not null, primary key
# events :text not null
# secret :text not null
# sha1 :string not null
# url :text not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# id :bigint not null, primary key
# events :text not null
# hmac_secret :text not null
# secret :text not null
# sha1 :string not null
# url :text not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
@@ -47,10 +48,15 @@ class WebhookUrl < ApplicationRecord
serialize :secret, coder: JSON
before_validation :set_sha1
before_validation :set_hmac_secret
encrypts :url, :secret
encrypts :url, :secret, :hmac_secret
def set_sha1
self.sha1 = Digest::SHA1.hexdigest(url)
end
def set_hmac_secret
self.hmac_secret ||= WebhookUrls::Signatures.generate_secret
end
end
Whitespace-only changes.
+1 -18
View File
@@ -18,24 +18,7 @@
<%= f.button button_title(title: t(:sign_in), disabled_with: t(:signing_in)), class: 'base-button' %>
</div>
<% end %>
<% if devise_mapping.omniauthable? %>
<div class="space-y-4">
<% if User.omniauth_providers.include?(:google_oauth2) %>
<%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %>
<%= f.button button_title(title: t('sign_in_with_google'), icon: svg_icon('brand_google', class: 'w-6 h-6')), class: 'white-button w-full mt-4' %>
<% end %>
<% end %>
<% if User.omniauth_providers.include?(:microsoft_office365) %>
<%= form_for '', url: omniauth_authorize_path(resource_name, :microsoft_office365), data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state_microsoft" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query, id: 'state_microsoft' %>
<%= f.button button_title(title: t('sign_in_with_microsoft'), icon: svg_icon('brand_microsoft', class: 'w-6 h-6')), class: 'white-button w-full' %>
<% end %>
<% end %>
</div>
<% end %>
<%= render 'omniauthable' %>
<%= render 'extra_links' %>
<%= render 'devise/shared/links' %>
</div>
+3 -2
View File
@@ -20,9 +20,10 @@
</head>
<body>
<% if params[:modal].present? %>
<% url_params = Rails.application.routes.recognize_path(params[:modal], method: :get) %>
<% modal_uri = Addressable::URI.parse(params[:modal]) %>
<% url_params = Rails.application.routes.recognize_path(modal_uri.path, method: :get) %>
<% if url_params[:action] == 'new' %>
<open-modal src="<%= url_for(url_params) %>"></open-modal>
<open-modal src="<%= url_for(**url_params, params: modal_uri.query_values) %>"></open-modal>
<% end %>
<% end %>
<turbo-frame id="modal"></turbo-frame>
+1 -1
View File
@@ -1,6 +1,6 @@
<a target="_blank" href="<%= Docuseal::GITHUB_URL %>" rel="noopener noreferrer nofollow" class="relative flex items-center rounded-full px-2 py-0.5 text-xs leading-4 mt-1 text-base-content border border-base-300 tooltip tooltip-bottom" data-tip="Give a star on GitHub">
<span class="flex items-center justify-between space-x-0.5 font-medium">
<%= svg_icon('start', class: 'h-3 w-3') %>
<span>11k</span>
<span>16k</span>
</span>
</a>
+1 -1
View File
@@ -59,7 +59,7 @@
</li>
<% end %>
<% if (can?(:manage, EncryptedConfig) && current_user == true_user) || (current_user != true_user && current_account.testing?) %>
<%= form_for '', url: testing_account_path, method: current_account.testing? ? :delete : :get, html: { class: 'w-full py-1' } do |f| %>
<%= form_for '', url: testing_account_path, method: current_account.testing? ? :delete : :post, html: { class: 'w-full py-1' } do |f| %>
<label class="flex items-center pl-6 pr-4 py-2 border-y border-base-300 -ml-2 -mr-2" for="testing_toggle">
<submit-form data-on="change" class="flex">
<%= f.check_box :testing_toggle, class: 'toggle', checked: current_account.testing?, style: 'height: 0.885rem; width: 1.35rem; --handleoffset: 0.395rem; margin-left: -2px; margin-right: 8px' %>
+1 -1
View File
@@ -97,7 +97,7 @@
<% end %>
<%= render 'shared/settings_nav_extra2' %>
<% if (can?(:manage, EncryptedConfig) && current_user == true_user) || (current_user != true_user && current_account.testing?) %>
<%= form_for '', url: testing_account_path, method: current_account.testing? ? :delete : :get, html: { class: 'w-full' } do |f| %>
<%= form_for '', url: testing_account_path, method: current_account.testing? ? :delete : :post, html: { class: 'w-full' } do |f| %>
<li>
<label class="flex items-center text-base hover:bg-base-300 w-full justify-between" for="testing_toggle">
<span class="mr-2 w-full">
+1 -1
View File
@@ -1,5 +1,5 @@
<% if can?(:manage, EncryptedConfig) || (current_user != true_user && current_account.testing?) %>
<%= form_for '', url: testing_account_path, method: current_account.testing? ? :delete : :get, html: { class: 'flex' } do |f| %>
<%= form_for '', url: testing_account_path, method: current_account.testing? ? :delete : :post, html: { class: 'flex' } do |f| %>
<label class="flex items-center justify-between" for="testing_toggle">
<span class="mr-2 text-lg">
<%= t('test_mode') %>
+8 -8
View File
@@ -5,9 +5,9 @@
<% font = field.dig('preferences', 'font') %>
<% font_type = field.dig('preferences', 'font_type') %>
<% font_size_px = (field.dig('preferences', 'font_size').presence || Submissions::GenerateResultAttachments::FONT_SIZE).to_i * local_assigns.fetch(:font_scale) { 1000.0 / PdfUtils::US_LETTER_W } %>
<field-value dir="auto" aria-hidden="true" class="flex absolute <%= 'font-courier' if font == 'Courier' %> <%= 'font-times' if font == 'Times' %> <%= 'font-bold' if font_type == 'bold' || font_type == 'bold_italic' %> <%= 'italic' if font_type == 'italic' || font_type == 'bold_italic' %> <%= align == 'right' ? 'text-right' : (align == 'center' ? 'text-center' : '') %>" style="<%= "color: #{color}; " if color.present? %><%= "background: #{bg_color}; " if bg_color.present? %>width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%; font-size: <%= fs = "clamp(1pt, #{font_size_px / 10}vw, #{font_size_px}px)" %>; line-height: calc(<%= fs %> * 1.3); font-size: <%= fs = "#{font_size_px / 10}cqmin" %>; line-height: calc(<%= fs %> * 1.3)">
<field-value dir="auto" aria-hidden="true" class="flex absolute <%= 'font-courier' if font == 'Courier' %> <%= 'font-times' if font == 'Times' %> <%= 'font-bold' if font_type == 'bold' || font_type == 'bold_italic' %> <%= 'italic' if font_type == 'italic' || font_type == 'bold_italic' %> <%= align == 'right' ? 'text-right' : (align == 'center' ? 'text-center' : '') %>" style="<%= "color: #{color}; " if color.present? && color.match?(Templates::COLOR_REGEXP) %><%= "background: #{bg_color}; " if bg_color.present? && bg_color.match?(Templates::COLOR_REGEXP) %>width: <%= area['w'].to_f * 100 %>%; height: <%= area['h'].to_f * 100 %>%; left: <%= area['x'].to_f * 100 %>%; top: <%= area['y'].to_f * 100 %>%; font-size: <%= fs = "clamp(1pt, #{font_size_px / 10}vw, #{font_size_px}px)" %>; line-height: calc(<%= fs %> * 1.3); font-size: <%= fs = "#{font_size_px / 10}cqmin" %>; line-height: calc(<%= fs %> * 1.3)">
<% if field['type'] == 'signature' %>
<% is_narrow = area['h'].positive? && ((area['w'] * local_assigns[:page_width]).to_f / (area['h'] * local_assigns[:page_height])) > 4.5 %>
<% is_narrow = area['h'].to_f.positive? && ((area['w'].to_f * local_assigns[:page_width]) / (area['h'].to_f * local_assigns[:page_height])) > 4.5 %>
<div class="flex justify-between w-full h-full gap-1 <%= is_narrow && (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) ? 'flex-row' : 'flex-col' %>">
<div class="flex overflow-hidden <%= is_narrow && (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) ? 'w-1/2' : 'flex-grow' %>" style="min-height: 50%">
<img class="object-contain mx-auto" src="<%= attachments_index[value].url %>" alt="<%= field['name'].presence || field['title'].presence || field['type'] %>">
@@ -62,9 +62,9 @@
<% end %>
<% end %>
<% elsif field['type'] == 'cells' && area['cell_w'].to_f > 0.0 %>
<% cell_width = area['cell_w'] / area['w'] * 100 %>
<% cell_width = area['cell_w'].to_f / area['w'] * 100 %>
<div class="w-full flex <%= valign == 'top' ? 'items-start' : (valign == 'bottom' ? 'items-end' : 'items-center') %> <%= 'justify-end' if align == 'right' %>">
<% (0..(area['w'] / area['cell_w']).ceil).each do |index| %>
<% (0..(area['w'].to_f / area['cell_w']).ceil).each do |index| %>
<% if value[index] %>
<div class="text-center flex-none" style="width: <%= cell_width %>%;"><%= value[index] %></div>
<% end %>
@@ -83,14 +83,14 @@
</div>
<% elsif field['type'] == 'strikethrough' %>
<div class="w-full h-full flex items-center justify-center">
<% if (((1000.0 / local_assigns[:page_width]) * local_assigns[:page_height]) * area['h']) < 40 %>
<% if (((1000.0 / local_assigns[:page_width]) * local_assigns[:page_height]) * area['h'].to_f) < 40 %>
<svg width="100%" height="100%">
<line x1="0" y1="50%" x2="100%" y2="50%" stroke="<%= field.dig('preferences', 'color').presence || 'red' %>" style="stroke-width: clamp(0px, 0.5vw, 6px); stroke-width: 0.6cqmin"></line>
<line x1="0" y1="50%" x2="100%" y2="50%" stroke="<%= color.present? && color.match?(Templates::COLOR_REGEXP) ? color : 'red' %>" style="stroke-width: clamp(0px, 0.5vw, 6px); stroke-width: 0.6cqmin"></line>
</svg>
<% else %>
<svg xmlns="http://www.w3.org/2000/svg" style="overflow: visible; width: calc(100% - 6px); height: calc(100% - 6px); width: calc(100% - 0.6cqmin); height: calc(100% - 0.6cqmin)">
<line x1="0" y1="0" x2="100%" y2="100%" stroke="<%= field.dig('preferences', 'color').presence || 'red' %>" style="stroke-width: clamp(0px, 0.5vw, 6px); stroke-width: 0.6cqmin"></line>
<line x1="100%" y1="0" x2="0" y2="100%" stroke="<%= field.dig('preferences', 'color').presence || 'red' %>" style="stroke-width: clamp(0px, 0.5vw, 6px); stroke-width: 0.6cqmin"></line>
<line x1="0" y1="0" x2="100%" y2="100%" stroke="<%= color.present? && color.match?(Templates::COLOR_REGEXP) ? color : 'red' %>" style="stroke-width: clamp(0px, 0.5vw, 6px); stroke-width: 0.6cqmin"></line>
<line x1="100%" y1="0" x2="0" y2="100%" stroke="<%= color.present? && color.match?(Templates::COLOR_REGEXP) ? color : 'red' %>" style="stroke-width: clamp(0px, 0.5vw, 6px); stroke-width: 0.6cqmin"></line>
</svg>
<% end %>
</div>
+1 -1
View File
@@ -131,7 +131,7 @@
<% submitters_order_index ||= (@submission.template_submitters || @submission.template.submitters).each_with_index.to_h { |s, i| [s['uuid'], i] } %>
<% submitter_index = submitters_order_index[submitter.uuid] %>
<% bg_class = bg_classes[submitter_index % bg_classes.size] %>
<div class="absolute overflow-visible" style="width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%;">
<div class="absolute overflow-visible" style="width: <%= area['w'].to_f * 100 %>%; height: <%= area['h'].to_f * 100 %>%; left: <%= area['x'].to_f * 100 %>%; top: <%= area['y'].to_f * 100 %>%;">
<div class="flex h-full w-full bg-opacity-80 justify-center items-center <%= bg_class %>">
<%= svg_icon(SubmissionsController::FIELD_ICONS.fetch(field['type'], 'text_size'), class: 'max-h-10 w-full h-full stroke-2 opacity-50') %>
</div>
+1 -1
View File
@@ -6,4 +6,4 @@
<%= button_to nil, user_configs_path, method: :post, params: { user_config: { key: UserConfig::SHOW_APP_TOUR, value: true } }, class: 'hidden', id: 'start_tour_button' %>
<% end %>
<% end %>
<template-builder class="grid" data-template="<%= @template_data %>" data-custom-fields="<%= (current_account.account_configs.find_or_initialize_by(key: AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY).value || []).to_json %>" data-with-sign-yourself-button="<%= !@template.archived_at? %>" data-with-fields-detection="true" data-with-send-button="<%= !@template.archived_at? && can?(:create, @template.submissions.new(account: current_account)) %>" data-locale="<%= I18n.locale %>" data-show-tour-start-form="<%= @show_tour_start_form %>"></template-builder>
<template-builder class="grid" data-template="<%= @template_data %>" data-custom-fields="<%= (current_account.account_configs.find_or_initialize_by(key: AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY).value || []).to_json %>" data-with-sign-yourself-button="<%= !@template.archived_at? %>" data-with-fields-detection="true" data-with-send-button="<%= !@template.archived_at? && can?(:create, @template.submissions.new(account: current_account)) %>" data-with-revisions-menu="<%= @template.template_versions.exists? %>" data-locale="<%= I18n.locale %>" data-show-tour-start-form="<%= @show_tour_start_form %>"></template-builder>
+1 -1
View File
@@ -75,7 +75,7 @@
<% end %>
<% if @submissions.present? %>
<div class="space-y-4">
<%= render partial: 'submission', collection: @submissions, locals: { template: @template } %>
<%= render partial: 'submission', collection: @submissions, locals: { template: @template, archived: @template.archived_at? } %>
</div>
<% else %>
<div class="card bg-base-200">
+1 -1
View File
@@ -68,7 +68,7 @@
<%= t('welcome_to_docuseal') %>
</div>
<div class="my-2 text-center text-xs text-base-content/70">
<%= t('start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document') %>
<%= t('start_a_quick_tour_to_learn_how_to_create_and_send_your_first_document') %>
</div>
<div class="flex gap-2 mt-3 w-full">
<remove-on-event data-on="submit" data-selector-id="app_tour_manager" class="block w-full">
+5 -3
View File
@@ -25,9 +25,11 @@
<div class="flex gap-2 mt-3">
<div class="relative flex-grow">
<input id="embedding_url" type="text" value="<%= start_form_url(slug: @template.slug, host: form_link_host) %>" class="base-input w-full pr-10" autocomplete="off" readonly>
<a href="<%= template_share_link_qr_path(@template) %>" target="_blank" rel="noopener" class="absolute top-1/2 -translate-y-1/2 right-2 flex items-center justify-center tooltip text-base-content/70 hover:text-base-content bg-white rounded px-1 py-0.5" data-tip="<%= t('qr_code') %>" aria-label="<%= t('qr_code') %>">
<%= svg_icon('qrcode', class: 'w-6 h-6') %>
</a>
<% if @template.variables_schema.blank? && !@template.preferences&.dig('require_email_2fa') && !@template.preferences&.dig('require_phone_2fa') %>
<a href="<%= template_share_link_qr_path(@template) %>" target="_blank" rel="noopener" class="absolute top-1/2 -translate-y-1/2 right-2 flex items-center justify-center tooltip text-base-content/70 hover:text-base-content bg-white rounded px-1 py-0.5" data-tip="<%= t('qr_code') %>" aria-label="<%= t('qr_code') %>">
<%= svg_icon('qrcode', class: 'w-6 h-6') %>
</a>
<% end %>
</div>
<check-on-click data-element-id="template_shared_link">
<%= render 'shared/clipboard_copy', icon: 'copy', text: start_form_url(slug: @template.slug, host: form_link_host), class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
@@ -3,13 +3,14 @@
<% webhook_attempts = webhook_event.webhook_attempts.sort_by { |e| -e.id } %>
<% if webhook_event.status == 'error' %>
<% last_attempt = webhook_attempts.select { |e| e.attempt < SendWebhookRequest::MANUAL_ATTEMPT }.max_by(&:attempt) %>
<% if webhook_event.webhook_attempts.none?(&:success?) && last_attempt.attempt <= 10 %>
<% next_attempt_at = last_attempt.created_at + (2**last_attempt.attempt).minutes %>
<% if webhook_event.webhook_attempts.none?(&:success?) && last_attempt.attempt <= 10 && next_attempt_at > 30.seconds.ago %>
<li class="ml-7">
<span class="btn btn-outline btn-xs btn-circle pointer-events-none absolute justify-center border-base-content-/60 text-base-content/60 bg-base-100" style="left: -12px;">
<%= svg_icon('clock', class: 'w-4 h-4 shrink-0') %>
</span>
<p class="leading-none text-base-content/90 pt-1">
<%= t('next_attempt_in_time_in_words', time_in_words: distance_of_time_in_words(Time.current, last_attempt.created_at + (2**last_attempt.attempt).minutes)) %>
<%= t('next_attempt_in_time_in_words', time_in_words: distance_of_time_in_words(Time.current, next_attempt_at)) %>
</p>
</li>
<% end %>
+20
View File
@@ -0,0 +1,20 @@
<%= render 'shared/turbo_modal', title: t('webhook_security') do %>
<div class="text-center mb-4">
<div class="inline-flex justify-center">
<%= link_to t('secret'), webhook_secret_path(@webhook_url), class: 'block bg-base-200 md:min-w-[112px] text-sm font-semibold whitespace-nowrap py-1.5 px-4 rounded-l-3xl', data: { turbo_frame: 'modal' } %>
<%= link_to t('hmac'), webhook_hmac_path(@webhook_url), class: 'block bg-base-300 md:min-w-[112px] text-sm font-semibold whitespace-nowrap py-1.5 px-4 rounded-r-3xl', data: { turbo_frame: 'modal' } %>
</div>
</div>
<div class="form-control">
<label class="label" for="hmac_secret"><%= t('hmac_signing_secret') %></label>
<% token = @webhook_url.hmac_secret %>
<% obscured = "#{token[0, 10]}#{'*' * [token.length - 10, 0].max}" %>
<div class="flex gap-2">
<masked-input class="block w-full" data-token="<%= token %>">
<input id="hmac_secret" type="text" value="<%= obscured %>" class="base-input font-mono 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>
<p class="text-sm mt-2 opacity-70"><%= t('hmac_signature_header_hint_html', header: '<code>X-Docuseal-Signature</code>'.html_safe) %></p>
</div>
<% end %>
+7 -1
View File
@@ -1,4 +1,10 @@
<%= render 'shared/turbo_modal', title: t('webhook_secret') do %>
<%= render 'shared/turbo_modal', title: t('webhook_security') do %>
<div class="text-center mb-4">
<div class="inline-flex justify-center">
<%= link_to t('secret'), webhook_secret_path(@webhook_url), class: 'block bg-base-300 md:min-w-[112px] text-sm font-semibold whitespace-nowrap py-1.5 px-4 rounded-l-3xl', data: { turbo_frame: 'modal' } %>
<%= link_to t('hmac'), webhook_hmac_path(@webhook_url), class: 'block bg-base-200 md:min-w-[112px] text-sm font-semibold whitespace-nowrap py-1.5 px-4 rounded-r-3xl', data: { turbo_frame: 'modal' } %>
</div>
</div>
<%= form_for @webhook_url, url: webhook_secret_path, method: :patch, html: { class: 'space-y-4' }, data: { turbo_frame: :_top } do |f| %>
<div class="space-y-2">
<%= f.fields_for :secret, Struct.new(:key, :value).new(*@webhook_url.secret.to_a.first) do |ff| %>
+1 -1
View File
@@ -1,7 +1,7 @@
<div class="flex flex-wrap space-y-4 md:flex-nowrap md:space-y-0 md:space-x-10">
<%= render 'shared/settings_nav' %>
<div class="flex-grow min-w-0">
<div class="flex flex-col gap-2 md:flex-row md:justify-between md:items-center mb-4">
<div class="flex flex-col gap-2 md:flex-row md:justify-between md:items-end mb-4">
<h1 class="text-4xl font-bold">Webhooks</h1>
<div class="flex flex-col gap-2 md:flex-row md:justify-between md:items-center">
<%= render 'shared/test_mode_toggle' %>
+2 -2
View File
@@ -1,7 +1,7 @@
<div class="flex flex-wrap space-y-4 md:flex-nowrap md:space-y-0 md:space-x-10">
<%= render 'shared/settings_nav' %>
<div class="flex-grow">
<div class="flex flex-col gap-2 md:flex-row md:justify-between md:items-center mb-4">
<div class="flex flex-col gap-2 md:flex-row md:justify-between md:items-end mb-4">
<h1 class="text-4xl font-bold">Webhook</h1>
<div class="flex flex-col gap-2 md:flex-row md:justify-between md:items-center">
<% if params[:action] == 'index' && (current_user == true_user || current_account.testing?) %>
@@ -23,7 +23,7 @@
<div class="flex items-center space-x-2 md:absolute md:right-0">
<%= link_to webhook_secret_path(@webhook_url), class: 'btn btn-outline btn-sm bg-white', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('lock', class: 'w-4 h-4') %>
<span><%= @webhook_url.secret.present? ? t('edit_secret') : t('add_secret') %></span>
<span><%= t('security') %></span>
<% end %>
<div class="tooltip tooltip-left md:tooltip-top" data-tip="<%= t('delete_webhook') %>">
<%= button_to settings_webhook_path(@webhook_url), class: 'btn btn-warning btn-sm', method: :delete, data: { turbo_confirm: t('are_you_sure_') } do %>
+248 -200
View File
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -77,12 +77,13 @@ Rails.application.routes.draw do
resources :console_redirect, only: %i[index]
resources :upgrade, only: %i[index], controller: 'console_redirect'
resources :manage, only: %i[index], controller: 'console_redirect'
resource :testing_account, only: %i[show destroy]
resource :testing_account, only: %i[create destroy]
resources :testing_api_settings, only: %i[index]
resources :submitters_autocomplete, only: %i[index]
resources :submitters_resubmit, only: %i[update]
resources :template_folders_autocomplete, only: %i[index]
resources :webhook_secret, only: %i[show update]
resources :webhook_hmac, only: %i[show]
resources :webhook_preferences, only: %i[update]
resource :templates_upload, only: %i[create]
authenticated do
@@ -107,6 +108,7 @@ Rails.application.routes.draw do
resource :form, only: %i[show], controller: 'templates_form_preview'
resource :code_modal, only: %i[show], controller: 'templates_code_modal'
resource :preferences, only: %i[show create destroy], controller: 'templates_preferences'
resources :versions, only: %i[index show], controller: 'templates_versions'
resource :share_link, only: %i[show create], controller: 'templates_share_link'
resource :share_link_qr, only: %i[show], controller: 'templates_share_link_qr'
resources :recipients, only: %i[create], controller: 'templates_recipients'
@@ -0,0 +1,23 @@
# frozen_string_literal: true
class AddHmacToWebhookUrls < ActiveRecord::Migration[8.1]
class MigrationWebhookUrl < ApplicationRecord
self.table_name = 'webhook_urls'
encrypts :hmac_secret
end
def up
add_column :webhook_urls, :hmac_secret, :text
MigrationWebhookUrl.find_each do |webhook_url|
webhook_url.update_columns(hmac_secret: WebhookUrls::Signatures.generate_secret)
end
change_column_null :webhook_urls, :hmac_secret, false
end
def down
remove_column :webhook_urls, :hmac_secret
end
end
@@ -0,0 +1,16 @@
# frozen_string_literal: true
class CreateTemplateVersions < ActiveRecord::Migration[8.1]
def change
create_table :template_versions do |t|
t.references :template, null: false, foreign_key: true, index: false
t.references :account, null: false, foreign_key: true, index: true
t.references :author, null: false, foreign_key: { to_table: :users }, index: true
t.text :data, null: false
t.string :sha1, null: false
t.timestamps
end
add_index :template_versions, %i[template_id sha1], unique: true
end
end
+18 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_04_16_100000) do
ActiveRecord::Schema[8.1].define(version: 2026_05_06_121640) do
# These are extensions that must be enabled in order to support this database
enable_extension "btree_gin"
enable_extension "pg_catalog.plpgsql"
@@ -444,6 +444,19 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_16_100000) do
t.index ["template_id"], name: "index_template_sharings_on_template_id"
end
create_table "template_versions", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "author_id", null: false
t.datetime "created_at", null: false
t.text "data", null: false
t.string "sha1", null: false
t.bigint "template_id", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_template_versions_on_account_id"
t.index ["author_id"], name: "index_template_versions_on_author_id"
t.index ["template_id", "sha1"], name: "index_template_versions_on_template_id_and_sha1", unique: true
end
create_table "templates", force: :cascade do |t|
t.bigint "account_id", null: false
t.datetime "archived_at"
@@ -545,6 +558,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_16_100000) do
t.bigint "account_id", null: false
t.datetime "created_at", null: false
t.text "events", null: false
t.text "hmac_secret", null: false
t.text "secret", null: false
t.string "sha1", null: false
t.datetime "updated_at", null: false
@@ -586,6 +600,9 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_16_100000) do
add_foreign_key "template_folders", "template_folders", column: "parent_folder_id"
add_foreign_key "template_folders", "users", column: "author_id"
add_foreign_key "template_sharings", "templates"
add_foreign_key "template_versions", "accounts"
add_foreign_key "template_versions", "templates"
add_foreign_key "template_versions", "users", column: "author_id"
add_foreign_key "templates", "accounts"
add_foreign_key "templates", "template_folders", column: "folder_id"
add_foreign_key "templates", "users", column: "author_id"
+2 -1
View File
@@ -27,7 +27,7 @@ module Mcp
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false
openWorldHint: true
}
}.freeze
@@ -43,6 +43,7 @@ module Mcp
account:,
author: current_user,
folder: account.default_template_folder,
source: :mcp,
name: arguments['name'].to_s.presence || 'New Template',
fields: [],
schema: []
+2 -2
View File
@@ -61,7 +61,7 @@ module Mcp
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
destructiveHint: true,
idempotentHint: false,
openWorldHint: true
}
@@ -96,7 +96,7 @@ module Mcp
submissions = Submissions.create_from_submitters(
template:,
user: current_user,
source: :api,
source: :mcp,
submitters_order: 'random',
submissions_attrs: { submitters: submitters },
params: { 'send_email' => true, 'submitters' => submitters }
+203
View File
@@ -192,6 +192,71 @@ class Pdfium
attach_function :FPDFDOC_ExitFormFillEnvironment, [:FPDF_FORMHANDLE], :void
attach_function :FPDF_FFLDraw, %i[FPDF_FORMHANDLE FPDF_BITMAP FPDF_PAGE int int int int int int], :void
attach_function :FPDFPage_Flatten, %i[FPDF_PAGE int], :int
FLAT_NORMALDISPLAY = 0
FLAT_PRINT = 1
FLATTEN_FAIL = 0
FLATTEN_SUCCESS = 1
FLATTEN_NOTHINGTODO = 2
# rubocop:disable Naming/ClassAndModuleCamelCase
class FS_MATRIX < FFI::Struct
layout :a, :float,
:b, :float,
:c, :float,
:d, :float,
:e, :float,
:f, :float
end
# rubocop:enable Naming/ClassAndModuleCamelCase
attach_function :FPDFPage_GetRotation, [:FPDF_PAGE], :int
attach_function :FPDFPage_SetRotation, %i[FPDF_PAGE int], :void
attach_function :FPDFPage_TransFormWithClip, %i[FPDF_PAGE pointer pointer], :int
attach_function :FPDFPage_TransformAnnots, %i[FPDF_PAGE double double double double double double], :void
attach_function :FPDFPage_GenerateContent, [:FPDF_PAGE], :int
attach_function :FPDFPage_GetMediaBox, %i[FPDF_PAGE pointer pointer pointer pointer], :int
attach_function :FPDFPage_SetMediaBox, %i[FPDF_PAGE float float float float], :void
attach_function :FPDFPage_GetCropBox, %i[FPDF_PAGE pointer pointer pointer pointer], :int
attach_function :FPDFPage_SetCropBox, %i[FPDF_PAGE float float float float], :void
attach_function :FPDFPage_GetBleedBox, %i[FPDF_PAGE pointer pointer pointer pointer], :int
attach_function :FPDFPage_SetBleedBox, %i[FPDF_PAGE float float float float], :void
attach_function :FPDFPage_GetTrimBox, %i[FPDF_PAGE pointer pointer pointer pointer], :int
attach_function :FPDFPage_SetTrimBox, %i[FPDF_PAGE float float float float], :void
attach_function :FPDFPage_GetArtBox, %i[FPDF_PAGE pointer pointer pointer pointer], :int
attach_function :FPDFPage_SetArtBox, %i[FPDF_PAGE float float float float], :void
PAGE_BOX_ACCESSORS = [
%i[FPDFPage_GetMediaBox FPDFPage_SetMediaBox],
%i[FPDFPage_GetCropBox FPDFPage_SetCropBox],
%i[FPDFPage_GetBleedBox FPDFPage_SetBleedBox],
%i[FPDFPage_GetTrimBox FPDFPage_SetTrimBox],
%i[FPDFPage_GetArtBox FPDFPage_SetArtBox]
].freeze
# rubocop:disable Naming/ClassAndModuleCamelCase
class FPDF_FILEWRITE < FFI::Struct
layout :version, :int,
:WriteBlock, :pointer
end
# rubocop:enable Naming/ClassAndModuleCamelCase
attach_function :FPDF_SaveAsCopy, %i[FPDF_DOCUMENT pointer ulong], :int
FPDF_INCREMENTAL = 1
FPDF_NO_INCREMENTAL = 2
FPDF_REMOVE_SECURITY = 3
attach_function :FPDF_CreateNewDocument, [], :FPDF_DOCUMENT
begin
attach_function :FPDF_ImportPages, %i[FPDF_DOCUMENT FPDF_DOCUMENT string int], :int
rescue FFI::NotFoundError
define_singleton_method(:FPDF_ImportPages) { |*| raise PdfiumError, 'FPDF_ImportPages is not available' } # rubocop:disable Naming/MethodName
end
FPDF_ERR_SUCCESS = 0
FPDF_ERR_UNKNOWN = 1
FPDF_ERR_FILE = 2
@@ -257,6 +322,38 @@ class Pdfium
@page_count ||= Pdfium.FPDF_GetPageCount(@document_ptr)
end
def import_pages(src_doc)
ensure_not_closed!
result = Pdfium.FPDF_ImportPages(@document_ptr, src_doc.document_ptr, nil, page_count)
raise PdfiumError, 'Failed to import pages' if result.zero?
@page_count = nil
result
end
def self.create
doc_ptr = Pdfium.FPDF_CreateNewDocument()
if doc_ptr.null?
Pdfium.check_last_error('Failed to create new document')
raise PdfiumError, 'Failed to create new document'
end
doc = new(doc_ptr)
return doc unless block_given?
begin
yield doc
ensure
doc.close
end
end
def self.open_file(file_path, password = nil)
doc_ptr = Pdfium.FPDF_LoadDocument(file_path, password)
@@ -318,6 +415,30 @@ class Pdfium
@pages[page_index] ||= Page.new(self, page_index)
end
def save(io, flags: Pdfium::FPDF_NO_INCREMENTAL)
ensure_not_closed!
file_write_mem = FFI::MemoryPointer.new(FPDF_FILEWRITE.size)
file_write_struct = FPDF_FILEWRITE.new(file_write_mem)
file_write_struct[:version] = 1
file_write_struct[:WriteBlock] = FFI::Function.new(:int, %i[pointer pointer ulong]) do |_, data, size|
io.write(data.read_bytes(size))
1
end
result = Pdfium.FPDF_SaveAsCopy(@document_ptr, file_write_mem, flags)
if result.zero?
Pdfium.check_last_error('Failed to save document')
raise PdfiumError, 'Failed to save document'
end
io
end
def close
return if closed?
@@ -680,6 +801,88 @@ class Pdfium
@line_nodes = @line_nodes.sort { |a, b| a.endy == b.endy ? a.x <=> b.x : a.endy <=> b.endy }
end
def rotate
ensure_not_closed!
rotation = Pdfium.FPDFPage_GetRotation(page_ptr)
return false if rotation.zero?
l_ptr = FFI::MemoryPointer.new(:float)
b_ptr = FFI::MemoryPointer.new(:float)
r_ptr = FFI::MemoryPointer.new(:float)
t_ptr = FFI::MemoryPointer.new(:float)
has_crop = !Pdfium.FPDFPage_GetCropBox(page_ptr, l_ptr, b_ptr, r_ptr, t_ptr).zero?
Pdfium.FPDFPage_GetMediaBox(page_ptr, l_ptr, b_ptr, r_ptr, t_ptr) unless has_crop
pl = l_ptr.read_float
pb = b_ptr.read_float
pr = r_ptr.read_float
pt = t_ptr.read_float
a, b, c, d, e, f =
case rotation
when 1 then [0, -1, 1, 0, -pb, pr]
when 2 then [-1, 0, 0, -1, pr, pt]
when 3 then [0, 1, -1, 0, pt, -pl]
end
Pdfium::PAGE_BOX_ACCESSORS.each do |getter, setter|
next if Pdfium.public_send(getter, page_ptr, l_ptr, b_ptr, r_ptr, t_ptr).zero?
bl = l_ptr.read_float
bb = b_ptr.read_float
br = r_ptr.read_float
bt = t_ptr.read_float
c1x, c1y, c2x, c2y =
case rotation
when 1 then [br, bb, bl, bt]
when 2 then [br, bt, bl, bb]
when 3 then [bl, bt, br, bb]
end
new_llx = (a * c1x) + (c * c1y) + e
new_lly = (b * c1x) + (d * c1y) + f
new_urx = (a * c2x) + (c * c2y) + e
new_ury = (b * c2x) + (d * c2y) + f
Pdfium.public_send(setter, page_ptr, new_llx, new_lly, new_urx, new_ury)
end
Pdfium.FPDFPage_TransformAnnots(page_ptr, a, b, c, d, e, f)
matrix_ptr = FFI::MemoryPointer.new(FS_MATRIX.size)
matrix_struct = FS_MATRIX.new(matrix_ptr)
matrix_struct[:a] = a
matrix_struct[:b] = b
matrix_struct[:c] = c
matrix_struct[:d] = d
matrix_struct[:e] = e
matrix_struct[:f] = f
Pdfium.FPDFPage_TransFormWithClip(page_ptr, matrix_ptr, FFI::Pointer::NULL)
Pdfium.FPDFPage_SetRotation(page_ptr, 0)
Pdfium.FPDFPage_GenerateContent(page_ptr)
true
end
def flatten(flag = Pdfium::FLAT_NORMALDISPLAY)
ensure_not_closed!
result = Pdfium.FPDFPage_Flatten(page_ptr, flag)
if result == Pdfium::FLATTEN_FAIL
Pdfium.check_last_error("Failed to flatten page #{page_index}")
raise PdfiumError, "Failed to flatten page #{page_index}"
end
result
end
def close
return if closed?
+1 -1
View File
@@ -54,7 +54,7 @@ Puma::Plugin.create do
Dir.chdir(ENV.fetch('WORKDIR', nil)) unless ENV['WORKDIR'].to_s.empty?
exec('redis-server', '--requirepass', Digest::SHA1.hexdigest("redis#{ENV.fetch('SECRET_KEY_BASE', '')}"),
out: '/dev/null')
'--loglevel', 'warning')
end
end
+2 -1
View File
@@ -56,7 +56,8 @@ module SearchEntries
end
[sql, number, number.length > 1 ? number.delete_prefix('0') : number, keyword]
elsif keyword.match?(/[^\p{L}\d&@.-]/) || keyword.match?(/\A['"].*['"]\z/) || keyword.match?(/[.-]{2,}/)
elsif keyword.start_with?('@') || keyword.match?(/[^\p{L}\d&@.-]/) ||
keyword.match?(/\A['"].*['"]\z/) || keyword.match?(/[.-]{2,}/)
['tsvector @@ plainto_tsquery(?)', TextUtils.transliterate(keyword.downcase)]
else
keyword = TextUtils.transliterate(keyword.downcase).squish
+9 -5
View File
@@ -15,11 +15,7 @@ module SendWebhookRequest
# rubocop:disable Metrics/AbcSize
def call(webhook_url, event_uuid:, event_type:, record:, data:, attempt: 0)
uri = begin
URI(webhook_url.url)
rescue URI::Error
Addressable::URI.parse(webhook_url.url).normalize
end
uri = parse_uri(webhook_url.url)
if Docuseal.multitenant?
raise HttpsError, 'Only HTTPS is allowed.' if (uri.scheme != 'https' || [443, nil].exclude?(uri.port)) &&
@@ -43,6 +39,8 @@ module SendWebhookRequest
data: data
}.to_json
req.headers['X-Docuseal-Signature'] = WebhookUrls::Signatures.sign(webhook_url.hmac_secret, body: req.body)
req.options.read_timeout = 15
req.options.open_timeout = 8
end
@@ -55,6 +53,12 @@ module SendWebhookRequest
end
# rubocop:enable Metrics/AbcSize
def parse_uri(url)
URI(url)
rescue URI::Error
Addressable::URI.parse(url).normalize
end
def create_webhook_event(webhook_url, event_uuid:, event_type:, record:)
return if event_uuid.blank?
+1 -1
View File
@@ -162,7 +162,7 @@ module Submissions
return email.downcase.sub(/@gmail?\z/i, '@gmail.com') if email.match?(/@gmail?\z/i)
return email.downcase if email.include?(',') ||
email.match?(/\.(?:gob|om|mm|cm|et|mo|nz|za|ie)\z/) ||
email.match?(/\.(?:gob|om|mm|cm|et|mo|nz|za|ie|ed\.jp)\z/i) ||
email.exclude?('.')
fixed_email = EmailTypo.call(email.delete_prefix('<'))
@@ -875,6 +875,50 @@ module Submissions
pdf
end
def maybe_rotate_pdfium(io)
pdf = HexaPDF::Document.new(io:)
return pdf if pdf.pages.size > MAX_PAGE_ROTATE
root_rotate = pdf.pages.root[:Rotate].to_i
rotated_indexes = pdf.pages.each_with_index.filter_map do |page, idx|
page_rotate = page[:Rotate]
effective = page_rotate.nil? ? root_rotate : page_rotate.to_i
idx if effective != 0
end
return pdf if rotated_indexes.blank?
has_widgets = pdf.acro_form && pdf.acro_form[:Fields].present?
io.rewind
out_io = StringIO.new
Pdfium::Document.open_bytes(io.string) do |doc|
rotated_indexes.each do |idx|
page = doc.get_page(idx)
page.flatten if has_widgets
page.rotate
end
doc.save(out_io)
end
pdf = HexaPDF::Document.new(io: out_io.tap(&:rewind))
pdf.pages.root[:Rotate] = 0
pdf
rescue StandardError => e
Rollbar.error(e) if defined?(Rollbar)
io.rewind
HexaPDF::Document.new(io:)
end
def on_missing_glyph(character, font_wrapper)
Rails.logger.info("Missing glyph: #{character}") if character.present? && defined?(Rollbar)
+76
View File
@@ -0,0 +1,76 @@
# frozen_string_literal: true
module TemplateVersions
SERIALIZE_PARAMS = {
only: %i[id created_at],
include: { author: { only: %i[email], methods: %i[full_name] } }
}.freeze
DATA_FIELDS = %i[name schema submitters variables_schema fields].freeze
module_function
def find_or_create_for(template, author:)
data = build_data(template)
sha1 = Digest::SHA1.hexdigest(data.to_json)
version = template.template_versions.find_by(sha1:)
version ||= template.template_versions.create!(data:, sha1:, author:)
version
rescue ActiveRecord::RecordNotUnique
retry
end
def serialize(version)
data = version.data.dup
data['documents'] = serialize_documents(version.template, data['schema'].to_a)
data['dynamic_documents'] = serialize_dynamic_documents(version.template, data['dynamic_documents'].to_a)
version.as_json(SERIALIZE_PARAMS).merge('data' => data)
end
def build_data(template)
dynamic_uuids = template.schema.select { |e| e['dynamic'] }.pluck('attachment_uuid')
dynamic_documents =
if dynamic_uuids.present?
template.dynamic_documents.where(uuid: dynamic_uuids).as_json(only: %i[uuid body])
else
[]
end
template.as_json(only: DATA_FIELDS).merge('dynamic_documents' => dynamic_documents)
end
def serialize_documents(template, schema)
return [] if schema.blank?
template.documents_attachments
.where(uuid: schema.pluck('attachment_uuid'))
.preload(:blob, preview_images_attachments: :blob)
.as_json(
only: %i[id uuid],
methods: %i[metadata signed_key],
include: { preview_images: { only: %i[id], methods: %i[url metadata filename] } }
)
end
def serialize_dynamic_documents(template, dynamic_docs)
return [] if dynamic_docs.blank?
dynamic_docs_index = template.dynamic_documents
.where(uuid: dynamic_docs.pluck('uuid'))
.preload(attachments_attachments: :blob)
.index_by(&:uuid)
dynamic_docs.map do |attrs|
document = dynamic_docs_index[attrs['uuid']]
attachments_data = document.attachments_attachments.as_json(only: %i[uuid], methods: %i[url metadata filename])
attrs.merge('head' => document.head, 'attachments' => attachments_data)
end
end
end
+2
View File
@@ -1,6 +1,8 @@
# frozen_string_literal: true
module Templates
COLOR_REGEXP = /\A(#(?:[0-9a-f]{3}|[0-9a-f]{6})|[a-z]+)\z/i
EXPIRATION_DURATIONS = {
one_day: 1.day,
two_days: 2.days,
+4 -2
View File
@@ -6,7 +6,7 @@ module Templates
# rubocop:disable Metrics
def call(original_template, author:, external_id: nil, name: nil, folder_name: nil)
template = original_template.account.templates.new
template = author.account.templates.new
template.external_id = external_id
template.shared_link = original_template.shared_link
@@ -16,8 +16,10 @@ module Templates
if folder_name.present?
template.folder = TemplateFolders.find_or_create_by_name(author, folder_name)
else
elsif author.account_id == original_template.account_id
template.folder_id = original_template.folder_id
else
template.folder = author.account.default_template_folder
end
template.submitters, template.fields, template.schema, template.preferences =
+3 -3
View File
@@ -92,9 +92,9 @@ module TimeUtils
end
def format_date_preview(format, locale, timezone)
return '' if format.blank?
format = format.upcase if format && !format_with_time?(format)
format = format.presence || (locale.to_s.ends_with?('US') ? DEFAULT_DATE_FORMAT_US : DEFAULT_DATE_FORMAT)
format = format.upcase unless format_with_time?(format)
preview_pattern = format.gsub(TOKEN_REGEX) { |token| TIME_FORMATS.key?(token) ? '--' : ALL_FORMATS[token] }
I18n.l(Time.current.in_time_zone(timezone.presence || Time.zone.name), format: preview_pattern, locale:)
@@ -122,7 +122,7 @@ module TimeUtils
def format_date_string(string, format, locale, timezone: nil)
format = format.upcase if format && !format_with_time?(format)
format ||= locale.to_s.ends_with?('US') ? DEFAULT_DATE_FORMAT_US : DEFAULT_DATE_FORMAT
format = format.presence || (locale.to_s.ends_with?('US') ? DEFAULT_DATE_FORMAT_US : DEFAULT_DATE_FORMAT)
date =
if format_with_time?(format)
+40
View File
@@ -0,0 +1,40 @@
# frozen_string_literal: true
module WebhookUrls
module Signatures
SECRET_PREFIX = 'whsec_'
SECRET_BYTES = 24
TOLERANCE = 5 * 60
InvalidSignatureError = Class.new(StandardError)
TimestampError = Class.new(StandardError)
module_function
def generate_secret
SECRET_PREFIX + Base64.strict_encode64(SecureRandom.bytes(SECRET_BYTES))
end
def sign(secret, body:, timestamp: Time.current.to_i)
"#{timestamp}.#{OpenSSL::HMAC.hexdigest('sha256', secret, "#{timestamp}.#{body}")}"
end
def verify(secret, body:, header:, tolerance: TOLERANCE)
ts, sig = header.to_s.split('.', 2)
ts = Integer(ts, exception: false)
raise InvalidSignatureError unless ts && sig
now = Time.current.to_i
raise TimestampError, 'Too old' if ts < now - tolerance
raise TimestampError, 'In future' if ts > now + tolerance
expected = OpenSSL::HMAC.hexdigest('sha256', secret, "#{ts}.#{body}")
raise InvalidSignatureError unless ActiveSupport::SecurityUtils.secure_compare(expected, sig)
true
end
end
end
@@ -57,6 +57,23 @@ RSpec.describe SendSubmissionCreatedWebhookRequestJob do
).once
end
it 'signs the request with the HMAC secret' do
captured_body = nil
captured_signature = nil
stub_request(:post, webhook_url.url).with do |req|
captured_body = req.body
captured_signature = req.headers['X-Docuseal-Signature']
end.to_return(status: 200)
described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id,
'event_uuid' => SecureRandom.uuid)
expect(captured_signature).to be_present
expect(WebhookUrls::Signatures.verify(webhook_url.hmac_secret,
body: captured_body,
header: captured_signature)).to be(true)
end
it "doesn't send a webhook request if the event is not in the webhook's events" do
webhook_url.update!(events: ['submission.completed'])
+21 -5
View File
@@ -49,7 +49,7 @@ RSpec.describe 'Webhook Settings' do
expect(page).to have_field('webhook_url[url]', type: 'url', with: webhook_url.url)
expect(page).to have_button('Save')
expect(page).to have_button('Delete')
expect(page).to have_link('Add Secret')
expect(page).to have_link('Security')
WebhookUrl::EVENTS.each do |event|
expect(page).to have_field(event, type: 'checkbox', checked: webhook_url.events.include?(event))
@@ -123,7 +123,7 @@ RSpec.describe 'Webhook Settings' do
expect(webhook_url.secret).to eq({})
click_link 'Add Secret'
click_link 'Security'
within '#modal' do
fill_in 'Key', with: 'X-Signature'
@@ -136,7 +136,7 @@ RSpec.describe 'Webhook Settings' do
expect(webhook_url.secret).to eq({ 'X-Signature' => 'secret-value' })
end
expect(page).to have_link('Edit Secret')
expect(page).to have_link('Security')
expect(page).to have_content('Webhook Secret has been saved.')
end
@@ -145,7 +145,7 @@ RSpec.describe 'Webhook Settings' do
visit settings_webhooks_path
click_link 'Edit Secret'
click_link 'Security'
within '#modal' do
fill_in 'Key', with: ''
@@ -158,10 +158,26 @@ RSpec.describe 'Webhook Settings' do
expect(webhook_url.secret).to eq({})
end
expect(page).to have_link('Add Secret')
expect(page).to have_link('Security')
expect(page).to have_content('Webhook Secret has been saved.')
end
it 'shows the HMAC signing secret on the HMAC tab' do
webhook_url = create(:webhook_url, account:)
visit settings_webhooks_path
click_link 'Security'
within '#modal' do
click_link 'HMAC'
expect(page).to have_field('hmac_secret')
end
expect(webhook_url.reload.hmac_secret).to start_with('whsec_')
end
context 'when testing the webhook' do
let!(:webhook_url) { create(:webhook_url, account:) }
let!(:template) { create(:template, account:, author: user) }