Compare commits

...
45 Commits
Author SHA1 Message Date
Alex Turchyn d7a895b98b Merge from docusealco/wip 2025-07-07 15:36:18 +03:00
Pete Matsyburka b90b7b7845 add select field font 2025-07-07 10:57:21 +03:00
Pete Matsyburka 3d05d75f2d fix search 2025-07-07 07:36:27 +03:00
Pete Matsyburka e701881377 store error body 2025-07-05 16:36:55 +03:00
Pete Matsyburka 4615e3e48f refresh webhook status 2025-07-05 15:39:42 +03:00
Alex Turchyn 988a5361a6 add webhook events 2025-07-04 22:28:01 +03:00
Pete Matsyburka 1b60b42428 text size 2025-07-04 13:33:52 +03:00
Pete Matsyburka 7b74487214 add length validation 2025-07-04 13:03:22 +03:00
Pete Matsyburka 91d98ed267 rotate pages 2025-07-04 09:47:52 +03:00
Pete Matsyburka 24fbb7de06 require phone 2fa 2025-07-03 10:20:30 +03:00
Pete Matsyburka 91df5b2407 optimize image blank 2025-07-02 17:44:18 +03:00
Pete Matsyburka eef2d9d599 use union all 2025-07-01 09:16:50 +03:00
Pete Matsyburka 8ad70b7e74 adjust folder query 2025-07-01 09:02:40 +03:00
Pete Matsyburka 8b775103c7 fix spec 2025-07-01 08:35:25 +03:00
Pete Matsyburka 8c2475aac1 add gh action timouts 2025-07-01 08:05:42 +03:00
Pete Matsyburka d3236ae4a1 adjust sort order 2025-06-30 23:57:49 +03:00
Pete Matsyburka 8fd9af3a6a fix ci 2025-06-30 13:11:21 +03:00
Alex Turchyn 4f94858901 Merge from docusealco/wip 2025-06-30 12:56:05 +03:00
Pete Matsyburka d0ac4ad222 fix sqlite remove template 2025-06-30 11:44:55 +03:00
Pete Matsyburka 10e4476f4a fix signature prefill 2025-06-30 11:41:07 +03:00
Pete Matsyburka 74411d883a adjust link default parties 2025-06-29 12:31:41 +03:00
Alex Turchyn 18ee2d5316 add shared link form fields preferences 2025-06-28 23:06:25 +03:00
Pete Matsyburka b4f80422eb update doc 2025-06-28 13:30:05 +03:00
Pete Matsyburka 7fe9ab7601 update params 2025-06-28 10:37:10 +03:00
Pete Matsyburka 13ad614e6b fix rubocop 2025-06-28 10:17:50 +03:00
Alex Turchyn 7b1b4bcf2c show shared link enable button on disabled link page 2025-06-28 09:58:22 +03:00
Alex Turchyn a6715d4034 add meta description translations 2025-06-27 23:25:52 +03:00
Pete Matsyburka 254995c6c6 fix missing font variant 2025-06-27 14:45:29 +03:00
Pete Matsyburka 0e33e23d1f fix prefill condition 2025-06-27 12:49:25 +03:00
Pete Matsyburka 57503eefd2 adjust search index 2025-06-27 12:08:57 +03:00
Pete Matsyburka cd477c880c render image as file 2025-06-26 21:50:12 +03:00
Pete Matsyburka bf2f5b24dc update docs 2025-06-26 17:19:53 +03:00
Pete Matsyburka 7eba4865bc fix signature remember 2025-06-26 11:28:38 +03:00
Pete Matsyburka 64cd24a24d validate blank images 2025-06-26 11:04:25 +03:00
Pete Matsyburka de5acd0949 fix one-off 2025-06-26 10:05:16 +03:00
Pete Matsyburka 6ebbc3edac pdfium read text 2025-06-26 09:51:09 +03:00
Pete Matsyburka 2020f6c136 make shared templates searchable 2025-06-26 07:47:29 +03:00
Pete Matsyburka ee4acd5a82 validate signature image 2025-06-25 22:31:19 +03:00
Pete Matsyburka 2e487ac936 fix timezone selector 2025-06-25 21:36:03 +03:00
Pete Matsyburka dee5fc7c07 fix preview 2025-06-25 16:34:25 +03:00
Pete Matsyburka e130584fec fix timezone selector 2025-06-25 09:38:52 +03:00
Pete Matsyburka ade5c4832f fix bulk table mappings 2025-06-24 12:49:52 +03:00
Pete Matsyburka 33b14d5278 fix sqlite query 2025-06-23 20:34:30 +03:00
Pete Matsyburka 48e73c2c81 fix png quality 2025-06-23 14:53:35 +03:00
Pete Matsyburka 894d98e92b update widget version 2025-06-23 13:33:44 +03:00
130 changed files with 16443 additions and 471 deletions

No files matched your search

+5
View File
@@ -6,6 +6,7 @@ jobs:
rubocop:
name: Rubocop
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Install Ruby
@@ -30,6 +31,7 @@ jobs:
erblint:
name: Erblint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Install Ruby
@@ -54,6 +56,7 @@ jobs:
eslint:
name: ESLint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Install Node.js
@@ -80,6 +83,7 @@ jobs:
brakeman:
name: Brakeman
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Install Ruby
@@ -107,6 +111,7 @@ jobs:
rspec:
name: RSpec
runs-on: ubuntu-latest
timeout-minutes: 10
services:
postgres:
+1
View File
@@ -8,6 +8,7 @@ on:
jobs:
build:
runs-on: ubuntu-24.04-arm
timeout-minutes: 20
steps:
- name: Checkout code
+1 -1
View File
@@ -66,7 +66,7 @@ RSpec/ExampleLength:
Max: 50
RSpec/MultipleMemoizedHelpers:
Max: 9
Max: 15
Metrics/BlockNesting:
Max: 5
+1
View File
@@ -34,6 +34,7 @@ gem 'rails'
gem 'rails_autolink'
gem 'rails-i18n'
gem 'rotp'
gem 'rouge', require: false
gem 'rqrcode'
gem 'ruby-vips'
gem 'rubyXL'
+2
View File
@@ -453,6 +453,7 @@ GEM
retriable (3.1.2)
rexml (3.4.0)
rotp (6.3.0)
rouge (4.5.2)
rqrcode (2.2.0)
chunky_png (~> 1.0)
rqrcode_core (~> 1.0)
@@ -632,6 +633,7 @@ DEPENDENCIES
rails-i18n
rails_autolink
rotp
rouge
rqrcode
rspec-rails
rubocop
@@ -10,6 +10,12 @@ module Api
def create
submitter = Submitter.find_by!(slug: params[:submitter_slug])
if params[:type].in?(%w[initials signature]) && ImageUtils.blank?(Vips::Image.new_from_file(params[:file].path))
Rollbar.error("Empty signature: #{submitter.id}") if defined?(Rollbar)
return render json: { error: "#{params[:type]} is empty" }, status: :unprocessable_entity
end
attachment = Submitters.create_attachment!(submitter, params)
if params[:remember_signature] == 'true' && submitter.email.present?
+6 -13
View File
@@ -63,12 +63,7 @@ module Api
submissions = create_submissions(@template, params)
WebhookUrls.for_account_id(@template.account_id, 'submission.created').each do |webhook_url|
submissions.each do |submission|
SendSubmissionCreatedWebhookRequestJob.perform_async('submission_id' => submission.id,
'webhook_url_id' => webhook_url.id)
end
end
WebhookUrls.enqueue_events(submissions, 'submission.created')
Submissions.send_signature_requests(submissions)
@@ -96,10 +91,7 @@ module Api
else
@submission.update!(archived_at: Time.current)
WebhookUrls.for_account_id(@submission.account_id, 'submission.archived').each do |webhook_url|
SendSubmissionArchivedWebhookRequestJob.perform_async('submission_id' => @submission.id,
'webhook_url_id' => webhook_url.id)
end
WebhookUrls.enqueue_events(@submission, 'submission.archived')
end
render json: @submission.as_json(only: %i[id archived_at])
@@ -112,9 +104,9 @@ module Api
submissions = submissions.where(slug: params[:slug]) if params[:slug].present?
if params[:template_folder].present?
folder = TemplateFolder.accessible_by(current_ability).find_by(name: params[:template_folder])
folder_ids = TemplateFolder.accessible_by(current_ability).where(name: params[:template_folder]).pluck(:id)
submissions = folder ? submissions.joins(:template).where(template: { folder_id: folder.id }) : submissions.none
submissions = submissions.joins(:template).where(template: { folder_id: folder_ids })
end
if params.key?(:archived)
@@ -187,11 +179,12 @@ module Api
def submissions_params
permitted_attrs = [
:send_email, :send_sms, :bcc_completed, :completed_redirect_url, :reply_to, :go_to_last,
:expire_at, :name,
:require_phone_2fa, :expire_at, :name,
{
message: %i[subject body],
submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role,
:completed, :phone, :application_key, :external_id, :reply_to, :go_to_last,
:require_phone_2fa,
{ metadata: {}, values: {}, roles: [], readonly_fields: [], message: %i[subject body],
fields: [:name, :uuid, :default_value, :value, :title, :description,
:readonly, :required, :validation_pattern, :invalid_message,
@@ -13,10 +13,7 @@ module Api
SubmissionEvents.create_with_tracking_data(@submitter, 'view_form', request)
WebhookUrls.for_account_id(@submitter.account_id, 'form.viewed').each do |webhook_url|
SendFormViewedWebhookRequestJob.perform_async('submitter_id' => @submitter.id,
'webhook_url_id' => webhook_url.id)
end
WebhookUrls.enqueue_events(@submitter, 'form.viewed')
render json: {}
end
+5 -1
View File
@@ -82,7 +82,7 @@ module Api
submitter_params.permit(
:send_email, :send_sms, :reply_to, :completed_redirect_url, :uuid, :name, :email, :role,
:completed, :phone, :application_key, :external_id, :go_to_last,
:completed, :phone, :application_key, :external_id, :go_to_last, :require_phone_2fa,
{ metadata: {}, values: {}, readonly_fields: [], message: %i[subject body],
fields: [[:name, :uuid, :default_value, :value, :required,
:readonly, :validation_pattern, :invalid_message,
@@ -193,6 +193,10 @@ 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?('go_to_last')
submitter.preferences['go_to_last'] = submitter_preferences['go_to_last']
end
@@ -28,10 +28,7 @@ module Api
cloned_template.save!
WebhookUrls.for_account_id(cloned_template.account_id, 'template.created').each do |webhook_url|
SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => cloned_template.id,
'webhook_url_id' => webhook_url.id)
end
WebhookUrls.enqueue_events(cloned_template, 'template.created')
SearchEntries.enqueue_reindex(cloned_template)
+3 -6
View File
@@ -67,10 +67,7 @@ module Api
SearchEntries.enqueue_reindex(@template)
WebhookUrls.for_account_id(@template.account_id, 'template.updated').each do |webhook_url|
SendTemplateUpdatedWebhookRequestJob.perform_async('template_id' => @template.id,
'webhook_url_id' => webhook_url.id)
end
WebhookUrls.enqueue_events(@template, 'template.updated')
render json: @template.as_json(only: %i[id updated_at])
end
@@ -95,9 +92,9 @@ module Api
templates = templates.where(slug: params[:slug]) if params[:slug].present?
if params[:folder].present?
folder = TemplateFolder.accessible_by(current_ability).find_by(name: params[:folder])
folder_ids = TemplateFolder.accessible_by(current_ability).where(name: params[:folder]).pluck(:id)
templates = folder ? templates.where(folder:) : templates.none
templates = templates.where(folder_id: folder_ids)
end
templates
+40 -15
View File
@@ -33,12 +33,12 @@ class StartFormController < ApplicationController
@submitter = find_or_initialize_submitter(@template, submitter_params)
if @submitter.completed_at?
redirect_to start_form_completed_path(@template.slug, email: submitter_params[:email])
redirect_to start_form_completed_path(@template.slug, submitter_params.compact_blank)
else
if filter_undefined_submitters(@template).size > 1 && @submitter.new_record?
@error_message = multiple_submitters_error_message
return render :show
return render :show, status: :unprocessable_entity
end
if (is_new_record = @submitter.new_record?)
@@ -49,9 +49,9 @@ class StartFormController < ApplicationController
@submitter.assign_attributes(ip: request.remote_ip, ua: request.user_agent)
end
if @submitter.save
if @submitter.errors.blank? && @submitter.save
if is_new_record
enqueue_submission_create_webhooks(@submitter)
WebhookUrls.enqueue_events(@submitter.submission, 'submission.created')
SearchEntries.enqueue_reindex(@submitter)
@@ -63,7 +63,7 @@ class StartFormController < ApplicationController
redirect_to submit_form_path(@submitter.slug)
else
render :show
render :show, status: :unprocessable_entity
end
end
end
@@ -71,9 +71,20 @@ class StartFormController < ApplicationController
def completed
return redirect_to start_form_path(@template.slug) if !@template.shared_link? || @template.archived_at?
submitter_params = params.permit(:name, :email, :phone).tap do |attrs|
attrs[:email] = Submissions.normalize_email(attrs[:email])
end
required_fields = @template.preferences.fetch('link_form_fields', ['email'])
required_params = required_fields.index_with { |key| submitter_params[key] }
raise ActionController::RoutingError, I18n.t('not_found') if required_params.any? { |_, v| v.blank? } ||
required_params.except('name').compact_blank.blank?
@submitter = Submitter.where(submission: @template.submissions)
.where.not(completed_at: nil)
.find_by!(email: params[:email])
.find_by!(required_params)
end
private
@@ -96,15 +107,17 @@ class StartFormController < ApplicationController
redirect_to start_form_path(@template.slug)
end
def enqueue_submission_create_webhooks(submitter)
WebhookUrls.for_account_id(submitter.account_id, 'submission.created').each do |webhook_url|
SendSubmissionCreatedWebhookRequestJob.perform_async('submission_id' => submitter.submission_id,
'webhook_url_id' => webhook_url.id)
end
end
def find_or_initialize_submitter(template, submitter_params)
Submitter
required_fields = template.preferences.fetch('link_form_fields', ['email'])
required_params = required_fields.index_with { |key| submitter_params[key] }
find_params = required_params.except('name')
submitter = Submitter.new if find_params.compact_blank.blank?
submitter ||=
Submitter
.where(submission: template.submissions.where(expire_at: Time.current..)
.or(template.submissions.where(expire_at: nil)).where(archived_at: nil))
.order(id: :desc)
@@ -112,7 +125,17 @@ class StartFormController < ApplicationController
.where(external_id: nil)
.where(ip: [nil, request.remote_ip])
.then { |rel| params[:resubmit].present? || params[:selfsign].present? ? rel.where(completed_at: nil) : rel }
.find_or_initialize_by(email: submitter_params[:email], **submitter_params.compact_blank)
.find_or_initialize_by(find_params)
submitter.name = required_params['name'] if submitter.new_record?
unless @resubmit_submitter
required_params.each do |key, value|
submitter.errors.add(key.to_sym, :blank) if value.blank?
end
end
submitter
end
def assign_submission_attributes(submitter, template)
@@ -125,6 +148,8 @@ class StartFormController < ApplicationController
metadata: @resubmit_submitter&.metadata.presence || {}
)
submitter.assign_attributes(@resubmit_submitter.slice(:name, :email, :phone)) if @resubmit_submitter
if submitter.values.present?
@resubmit_submitter.attachments.each do |attachment|
submitter.attachments << attachment.dup if submitter.values.value?(attachment.uuid)
+2 -14
View File
@@ -54,7 +54,7 @@ class SubmissionsController < ApplicationController
params: params.merge('send_completed_email' => true))
end
enqueue_submission_created_webhooks(@template, submissions)
WebhookUrls.enqueue_events(submissions, 'submission.created')
Submissions.send_signature_requests(submissions)
@@ -77,10 +77,7 @@ class SubmissionsController < ApplicationController
else
@submission.update!(archived_at: Time.current)
WebhookUrls.for_account_id(@submission.account_id, 'submission.archived').each do |webhook_url|
SendSubmissionArchivedWebhookRequestJob.perform_async('submission_id' => @submission.id,
'webhook_url_id' => webhook_url.id)
end
WebhookUrls.enqueue_events(@submission, 'submission.archived')
I18n.t('submission_has_been_archived')
end
@@ -97,15 +94,6 @@ class SubmissionsController < ApplicationController
template.save!
end
def enqueue_submission_created_webhooks(template, submissions)
WebhookUrls.for_account_id(template.account_id, 'submission.created').each do |webhook_url|
submissions.each do |submission|
SendSubmissionCreatedWebhookRequestJob.perform_async('submission_id' => submission.id,
'webhook_url_id' => webhook_url.id)
end
end
end
def submissions_params
params.permit(submission: { submitters: [%i[uuid email phone name]] })
end
@@ -28,7 +28,7 @@ class SubmissionsPreviewController < ApplicationController
raise ActionController::RoutingError, I18n.t('not_found')
end
if !submission_valid_ttl?(@submission) && !signature_valid
if use_signature?(@submission) && !signature_valid
Rollbar.info("TTL: #{@submission.id}") if defined?(Rollbar)
return redirect_to submissions_preview_completed_path(@submission.slug)
@@ -48,9 +48,15 @@ class SubmissionsPreviewController < ApplicationController
private
def submission_valid_ttl?(submission)
return true if current_user && current_user.account.submissions.exists?(id: submission.id)
def use_signature?(submission)
return false if current_user && can?(:read, submission)
return true if submission.submitters.any? { |e| e.preferences['require_phone_2fa'] }
return true if submission.template&.preferences&.dig('require_phone_2fa')
!submission_valid_ttl?(submission)
end
def submission_valid_ttl?(submission)
last_submitter = submission.submitters.select(&:completed_at?).max_by(&:completed_at)
last_submitter && last_submitter.completed_at > TTL.ago
@@ -25,10 +25,7 @@ class SubmitFormDeclineController < ApplicationController
SubmitterMailer.declined_email(submitter, user).deliver_later!
end
WebhookUrls.for_account_id(submitter.account_id, 'form.declined').each do |webhook_url|
SendFormDeclinedWebhookRequestJob.perform_async('submitter_id' => submitter.id,
'webhook_url_id' => webhook_url.id)
end
WebhookUrls.enqueue_events(submitter, 'form.declined')
redirect_to submit_form_path(submitter.slug)
end
@@ -12,7 +12,7 @@ class SubmitFormDrawSignatureController < ApplicationController
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
if @submitter.submission.template.archived_at? || @submitter.submission.archived_at?
if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at?
return redirect_to submit_form_path(@submitter.slug)
end
@@ -45,7 +45,7 @@ class SubmitFormInviteController < ApplicationController
!submitter.completed_at? &&
!submitter.submission.archived_at? &&
!submitter.submission.expired? &&
!submitter.submission.template.archived_at?
!submitter.submission.template&.archived_at?
end
def filter_invite_submitters(submitter, key = 'invite_by_uuid')
@@ -8,7 +8,7 @@ class SubmitFormValuesController < ApplicationController
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return render json: {} if submitter.completed_at? || submitter.declined_at?
return render json: {} if submitter.submission.template.archived_at? ||
return render json: {} if submitter.submission.template&.archived_at? ||
submitter.submission.archived_at? ||
submitter.submission.expired?
+13 -1
View File
@@ -3,11 +3,13 @@
class TemplateFoldersController < ApplicationController
load_and_authorize_resource :template_folder
helper_method :selected_order
def show
@templates = @template_folder.templates.active.accessible_by(current_ability)
.preload(:author, :template_accesses)
@templates = Templates.search(current_user, @templates, params[:q])
@templates = Templates::Order.call(@templates, current_user, cookies.permanent[:dashboard_templates_order])
@templates = Templates::Order.call(@templates, current_user, selected_order)
@pagy, @templates = pagy_auto(@templates, limit: 12)
end
@@ -25,6 +27,16 @@ class TemplateFoldersController < ApplicationController
private
def selected_order
@selected_order ||=
if cookies.permanent[:dashboard_templates_order].blank? ||
(cookies.permanent[:dashboard_templates_order] == 'used_at' && can?(:manage, :countless))
'created_at'
else
cookies.permanent[:dashboard_templates_order]
end
end
def template_folder_params
params.require(:template_folder).permit(:name)
end
+2 -16
View File
@@ -74,7 +74,7 @@ class TemplatesController < ApplicationController
SearchEntries.enqueue_reindex(@template)
enqueue_template_created_webhooks(@template)
WebhookUrls.enqueue_events(@template, 'template.created')
maybe_redirect_to_template(@template)
else
@@ -91,7 +91,7 @@ class TemplatesController < ApplicationController
SearchEntries.enqueue_reindex(@template) if is_name_changed
enqueue_template_updated_webhooks(@template)
WebhookUrls.enqueue_events(@template, 'template.updated')
head :ok
end
@@ -142,20 +142,6 @@ class TemplatesController < ApplicationController
end
end
def enqueue_template_created_webhooks(template)
WebhookUrls.for_account_id(template.account_id, 'template.created').each do |webhook_url|
SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => template.id,
'webhook_url_id' => webhook_url.id)
end
end
def enqueue_template_updated_webhooks(template)
WebhookUrls.for_account_id(template.account_id, 'template.updated').each do |webhook_url|
SendTemplateUpdatedWebhookRequestJob.perform_async('template_id' => template.id,
'webhook_url_id' => webhook_url.id)
end
end
def load_base_template
return if params[:base_template_id].blank?
@@ -8,12 +8,13 @@ class TemplatesDashboardController < ApplicationController
TEMPLATES_PER_PAGE = 12
FOLDERS_PER_PAGE = 18
helper_method :selected_order
def index
@template_folders = @template_folders.where(id: @templates.active.select(:folder_id))
@template_folders = TemplateFolders.search(@template_folders, params[:q])
@template_folders = sort_template_folders(@template_folders, current_user,
cookies.permanent[:dashboard_templates_order])
@template_folders = sort_template_folders(@template_folders, current_user, selected_order)
@pagy, @template_folders = pagy(
@template_folders,
@@ -26,7 +27,7 @@ class TemplatesDashboardController < ApplicationController
else
@template_folders = @template_folders.reject { |e| e.name == TemplateFolder::DEFAULT_NAME }
@templates = filter_templates(@templates).preload(:author, :template_accesses)
@templates = Templates::Order.call(@templates, current_user, cookies.permanent[:dashboard_templates_order])
@templates = Templates::Order.call(@templates, current_user, selected_order)
limit =
if @template_folders.size < 4
@@ -55,10 +56,8 @@ class TemplatesDashboardController < ApplicationController
rel = Template.where(
Template.arel_table[:id].in(
Arel::Nodes::Union.new(
rel.where(folder_id: current_account.default_template_folder.id).select(:id).arel,
shared_template_ids.arel
)
rel.where(folder_id: current_account.default_template_folder.id).select(:id).arel
.union(:all, shared_template_ids.arel)
)
)
else
@@ -103,6 +102,16 @@ class TemplatesDashboardController < ApplicationController
end
end
def selected_order
@selected_order ||=
if cookies.permanent[:dashboard_templates_order].blank? ||
(cookies.permanent[:dashboard_templates_order] == 'used_at' && can?(:manage, :countless))
'created_at'
else
cookies.permanent[:dashboard_templates_order]
end
end
def load_related_submissions
@related_submissions = Submission.accessible_by(current_ability)
.left_joins(:template)
@@ -31,7 +31,7 @@ class TemplatesPreferencesController < ApplicationController
completed_notification_email_subject completed_notification_email_body
completed_notification_email_enabled completed_notification_email_attach_audit] +
[completed_message: %i[title body],
submitters: [%i[uuid request_email_subject request_email_body]]]
submitters: [%i[uuid request_email_subject request_email_body]], link_form_fields: []]
).tap do |attrs|
attrs[:preferences].delete(:submitters) if params[:request_email_per_submitter] != '1'
@@ -10,7 +10,11 @@ class TemplatesShareLinkController < ApplicationController
@template.update!(template_params)
head :ok
if params[:redir].present?
redirect_to params[:redir]
else
head :ok
end
end
private
@@ -23,7 +23,7 @@ class TemplatesUploadsController < ApplicationController
@template.update!(schema:)
enqueue_template_created_webhooks(@template)
WebhookUrls.enqueue_events(@template, 'template.created')
SearchEntries.enqueue_reindex(@template)
@@ -68,11 +68,4 @@ class TemplatesUploadsController < ApplicationController
{ files: [file] }
end
def enqueue_template_created_webhooks(template)
WebhookUrls.for_account_id(template.account_id, 'template.created').each do |webhook_url|
SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => template.id,
'webhook_url_id' => webhook_url.id)
end
end
end
@@ -0,0 +1,66 @@
# frozen_string_literal: true
class WebhookEventsController < ApplicationController
load_and_authorize_resource :webhook_url, parent: false, id_param: :webhook_id
before_action :load_webhook_event
def show
return unless current_ability.can?(:read, @webhook_event.record)
@data =
case @webhook_event.event_type
when 'form.started', 'form.completed', 'form.declined', 'form.viewed'
Submitters::SerializeForWebhook.call(@webhook_event.record)
when 'submission.created', 'submission.completed', 'submission.expired'
Submissions::SerializeForApi.call(@webhook_event.record)
when 'template.created', 'template.updated'
Templates::SerializeForApi.call(@webhook_event.record)
when 'submission.archived'
@webhook_event.record.as_json(only: %i[id archived_at])
end
end
def resend
id_key = WebhookUrls::EVENT_TYPE_ID_KEYS.fetch(@webhook_event.event_type.split('.').first)
last_attempt_id = @webhook_event.webhook_attempts.maximum(:id)
WebhookUrls::EVENT_TYPE_TO_JOB_CLASS[@webhook_event.event_type].perform_async(
id_key => @webhook_event.record_id,
'webhook_url_id' => @webhook_event.webhook_url_id,
'event_uuid' => @webhook_event.uuid,
'attempt' => SendWebhookRequest::MANUAL_ATTEMPT,
'last_status' => 0
)
render turbo_stream: [
turbo_stream.after(
params[:button_id],
helpers.tag.submit_form(
helpers.button_to('', refresh_settings_webhook_event_path(@webhook_url.id, @webhook_event.uuid),
params: { last_attempt_id: }),
class: 'hidden', data: { interval: 3_000 }
)
)
]
end
def refresh
return head :ok if @webhook_event.webhook_attempts.maximum(:id) == params[:last_attempt_id].to_i
render turbo_stream: [
turbo_stream.replace(helpers.dom_id(@webhook_event),
partial: 'event_row',
locals: { with_status: true, webhook_url: @webhook_url, webhook_event: @webhook_event }),
turbo_stream.replace("drawer_events_#{helpers.dom_id(@webhook_event)}",
partial: 'drawer_events',
locals: { webhook_url: @webhook_url, webhook_event: @webhook_event })
]
end
private
def load_webhook_event
@webhook_event = @webhook_url.webhook_events.find_by!(uuid: params[:id])
end
end
+25 -4
View File
@@ -8,10 +8,26 @@ class WebhookSettingsController < ApplicationController
@webhook_urls = @webhook_urls.order(id: :desc)
@webhook_url = @webhook_urls.first_or_initialize
render @webhook_urls.size > 1 ? 'index' : 'show'
if @webhook_urls.size > 1
render :index
else
@webhook_events = @webhook_url.webhook_events
@webhook_events = @webhook_events.where(status: params[:status]) if %w[success error].include?(params[:status])
@pagy, @webhook_events = pagy_countless(@webhook_events.order(id: :desc))
render :show
end
end
def show; end
def show
@webhook_events = @webhook_url.webhook_events
@webhook_events = @webhook_events.where(status: params[:status]) if %w[success error].include?(params[:status])
@pagy, @webhook_events = pagy_countless(@webhook_events.order(id: :desc))
end
def new; end
@@ -37,13 +53,18 @@ class WebhookSettingsController < ApplicationController
def resend
submitter = current_account.submitters.where.not(completed_at: nil).order(:id).last
authorize!(:read, submitter)
if submitter.blank? || @webhook_url.blank?
return redirect_back(fallback_location: settings_webhooks_path,
alert: I18n.t('unable_to_resend_webhook_request'))
end
SendFormCompletedWebhookRequestJob.perform_async('submitter_id' => submitter.id,
'webhook_url_id' => @webhook_url.id)
SendTestWebhookRequestJob.perform_async(
'submitter_id' => submitter.id,
'event_uuid' => SecureRandom.uuid,
'webhook_url_id' => @webhook_url.id
)
redirect_back(fallback_location: settings_webhooks_path, notice: I18n.t('webhook_request_has_been_sent'))
end
+2
View File
@@ -35,6 +35,7 @@ import SetDateButton from './elements/set_date_button'
import IndeterminateCheckbox from './elements/indeterminate_checkbox'
import AppTour from './elements/app_tour'
import DashboardDropzone from './elements/dashboard_dropzone'
import RequiredCheckboxGroup from './elements/required_checkbox_group'
import * as TurboInstantClick from './lib/turbo_instant_click'
@@ -105,6 +106,7 @@ safeRegisterElement('indeterminate-checkbox', IndeterminateCheckbox)
safeRegisterElement('app-tour', AppTour)
safeRegisterElement('dashboard-dropzone', DashboardDropzone)
safeRegisterElement('check-on-click', CheckOnClick)
safeRegisterElement('required-checkbox-group', RequiredCheckboxGroup)
safeRegisterElement('template-builder', class extends HTMLElement {
connectedCallback () {
+1 -1
View File
@@ -1,7 +1,7 @@
export default class extends HTMLElement {
connectedCallback () {
this.addEventListener('click', () => {
if (!this.element.checked) {
if (this.element && !this.element.disabled && !this.element.checked) {
this.element.checked = true
this.element.dispatchEvent(new Event('change', { bubbles: true }))
}
@@ -0,0 +1,17 @@
export default class extends HTMLElement {
connectedCallback () {
this.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
checkbox.addEventListener('change', this.handleChange)
})
}
handleChange = () => {
if (this.checkedCount !== 0) {
this.closest('form')?.requestSubmit()
}
}
get checkedCount () {
return this.querySelectorAll('input[type="checkbox"]:checked').length
}
}
+13 -1
View File
@@ -1,5 +1,17 @@
export default class extends HTMLElement {
connectedCallback () {
this.querySelector('form').requestSubmit()
if (this.dataset.interval) {
this.interval = setInterval(() => {
this.querySelector('form').requestSubmit()
}, parseInt(this.dataset.interval))
} else {
this.querySelector('form').requestSubmit()
}
}
disconnectedCallback () {
if (this.interval) {
clearInterval(this.interval)
}
}
}
+14
View File
@@ -1,4 +1,5 @@
const en = {
must_be_characters_length: 'Must be {number} characters length',
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete all required fields to proceed with identity verification.',
verify_id: 'Verify ID',
identity_verification: 'Identity verification',
@@ -98,6 +99,7 @@ const en = {
}
const es = {
must_be_characters_length: 'Debe tener {number} caracteres de longitud',
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete todos los campos requeridos para continuar con la verificación de identidad.',
verify_id: 'Verificar ID',
identity_verification: 'Verificación de identidad',
@@ -197,6 +199,7 @@ const es = {
}
const it = {
must_be_characters_length: 'Deve essere lungo {number} caratteri',
complete_all_required_fields_to_proceed_with_identity_verification: "Compila tutti i campi obbligatori per procedere con la verifica dell'identità.",
verify_id: 'Verifica ID',
identity_verification: "Verifica dell'identità",
@@ -296,6 +299,7 @@ const it = {
}
const de = {
must_be_characters_length: 'Muss {number} Zeichen lang sein',
complete_all_required_fields_to_proceed_with_identity_verification: 'Vervollständigen Sie alle erforderlichen Felder, um mit der Identitätsverifizierung fortzufahren.',
verify_id: 'ID überprüfen',
identity_verification: 'Identitätsüberprüfung',
@@ -395,6 +399,7 @@ const de = {
}
const fr = {
must_be_characters_length: 'Doit contenir {number} caractères',
complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour continuer la vérification de l'identité.",
verify_id: "Vérification de l'ID",
identity_verification: "Vérification de l'identité",
@@ -494,6 +499,7 @@ const fr = {
}
const pl = {
must_be_characters_length: 'Musi mieć długość {number} znaków',
complete_all_required_fields_to_proceed_with_identity_verification: 'Uzupełnij wszystkie wymagane pola, aby kontynuować weryfikację tożsamości.',
verify_id: 'Zweryfikuj ID',
identity_verification: 'Weryfikacja tożsamości',
@@ -593,6 +599,7 @@ const pl = {
}
const uk = {
must_be_characters_length: 'Має містити {number} символів',
complete_all_required_fields_to_proceed_with_identity_verification: "Заповніть всі обов'язкові поля, щоб продовжити перевірку особи.",
verify_id: 'Підтвердження ідентичності',
identity_verification: 'Ідентифікація особи',
@@ -692,6 +699,7 @@ const uk = {
}
const cs = {
must_be_characters_length: 'Musí mít délku {number} znaků',
complete_all_required_fields_to_proceed_with_identity_verification: 'Vyplňte všechna povinná pole, abyste mohli pokračovat v ověření identity.',
verify_id: 'Ověřit ID',
identity_verification: 'Ověření identity',
@@ -791,6 +799,7 @@ const cs = {
}
const pt = {
must_be_characters_length: 'Deve ter {number} caracteres',
complete_all_required_fields_to_proceed_with_identity_verification: 'Preencha todos os campos obrigatórios para prosseguir com a verificação de identidade.',
verify_id: 'Verificar ID',
identity_verification: 'Verificação de identidade',
@@ -890,6 +899,7 @@ const pt = {
}
const he = {
must_be_characters_length: 'חייב להיות באורך של {number} תווים',
complete_all_required_fields_to_proceed_with_identity_verification: 'מלא את כל השדות הנדרשים כדי להמשיך עם אימות זהות.',
verify_id: 'אמת מזהה',
identity_verification: 'אימות זהות',
@@ -989,6 +999,7 @@ const he = {
}
const nl = {
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',
identity_verification: 'Identiteitsverificatie',
@@ -1088,6 +1099,7 @@ const nl = {
}
const ar = {
must_be_characters_length: 'يجب أن يكون الطول {number} حرفًا',
complete_all_required_fields_to_proceed_with_identity_verification: 'أكمل جميع الحقول المطلوبة للمتابعة في التحقق من الهوية.',
verify_id: 'تحقق من الهوية',
identity_verification: 'التحقق من الهوية',
@@ -1187,6 +1199,7 @@ const ar = {
}
const ko = {
must_be_characters_length: '{number}자여야 합니다',
complete_all_required_fields_to_proceed_with_identity_verification: '신원 확인을 진행하려면 모든 필수 필드를 작성하십시오.',
verify_id: '아이디 확인',
identity_verification: '신원 확인',
@@ -1286,6 +1299,7 @@ const ko = {
}
const ja = {
must_be_characters_length: '{number}文字でなければなりません',
complete_all_required_fields_to_proceed_with_identity_verification: '本人確認を進めるには、すべての必須項目を入力してください。',
verify_id: '本人確認',
identity_verification: '本人確認',
@@ -203,7 +203,7 @@ export default {
emits: ['attached', 'update:model-value', 'start', 'minimize', 'focus'],
data () {
return {
isInitialsStarted: !!this.previousValue,
isInitialsStarted: false,
isUsePreviousValue: true,
isDrawInitials: false,
uploadImageInputKey: Math.random().toString()
@@ -218,6 +218,9 @@ export default {
}
}
},
created () {
this.isInitialsStarted = !!this.computedPreviousValue
},
async mounted () {
this.$nextTick(() => {
if (this.$refs.canvas) {
@@ -362,11 +365,20 @@ export default {
formData.append('file', file)
formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments')
formData.append('type', 'initials')
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: formData
}).then((resp) => resp.json()).then((attachment) => {
}).then(async (resp) => {
if (resp.status === 422 || resp.status === 500) {
const data = await resp.json()
return Promise.reject(new Error(data.error))
}
const attachment = await resp.json()
this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid)
@@ -201,7 +201,7 @@
</div>
</div>
<input
v-if="isTextSignature"
v-if="isTextSignature && !modelValue && !computedPreviousValue"
id="signature_text_input"
ref="textInput"
class="base-input !text-2xl w-full mt-6"
@@ -391,7 +391,7 @@ export default {
emits: ['attached', 'update:model-value', 'start', 'minimize', 'update:reason'],
data () {
return {
isSignatureStarted: !!this.previousValue,
isSignatureStarted: false,
isShowQr: false,
isOtherReason: false,
isUsePreviousValue: true,
@@ -422,6 +422,8 @@ export default {
}
},
created () {
this.isSignatureStarted = !!this.computedPreviousValue
if (this.requireSigningReason) {
this.field.preferences ||= {}
this.field.preferences.reason_field_uuid ||= v4()
@@ -716,11 +718,20 @@ export default {
formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments')
formData.append('remember_signature', this.rememberSignature)
formData.append('type', 'signature')
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: formData
}).then((resp) => resp.json()).then((attachment) => {
}).then(async (resp) => {
if (resp.status === 422 || resp.status === 500) {
const data = await resp.json()
return Promise.reject(new Error(data.error))
}
const attachment = await resp.json()
this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid)
+24 -2
View File
@@ -43,8 +43,8 @@
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
type="text"
:name="`values[${field.uuid}]`"
@invalid="field.validation?.message ? $event.target.setCustomValidity(field.validation.message) : ''"
@input="field.validation?.message ? $event.target.setCustomValidity('') : ''"
@invalid="validationMessage ? $event.target.setCustomValidity(validationMessage) : ''"
@input="validationMessage ? $event.target.setCustomValidity('') : ''"
@focus="$emit('focus')"
>
<textarea
@@ -127,6 +127,28 @@ export default {
return null
}
},
lengthValidation () {
if (this.field.validation?.pattern) {
return this.field.validation.pattern.match(/^\.{(?<min>\d+),(?<max>\d+)?}$/)?.groups
} else {
return null
}
},
validationMessage () {
if (this.field.validation?.message) {
return this.field.validation.message
} else if (this.lengthValidation) {
const number =
[this.lengthValidation.min, this.lengthValidation.max]
.filter(Boolean)
.filter((v, i, a) => a.indexOf(v) === i)
.join('-')
return this.t('must_be_characters_length').replace('{number}', number)
} else {
return ''
}
},
text: {
set (value) {
this.$emit('update:model-value', value)
@@ -153,13 +153,13 @@
<option
v-for="(key, value) in validations"
:key="key"
:selected="field.validation?.pattern ? value === field.validation.pattern : value === 'none'"
:value="value"
:selected="lengthValidation ? key == 'length' : (field.validation?.pattern ? value === field.validation.pattern : key === 'none')"
:value="key"
>
{{ t(key) }}
</option>
<option
:selected="field.validation && !validations[field.validation.pattern]"
:selected="field.validation && !validations[field.validation.pattern] && !lengthValidation"
:value="validations[field.validation?.pattern] || !field.validation?.pattern ? 'custom' : field.validation?.pattern"
>
{{ t('custom') }}
@@ -174,7 +174,51 @@
</label>
</div>
<div
v-if="['text', 'cells'].includes(field.type) && field.validation && !validations[field.validation.pattern]"
v-if="['text', 'cells'].includes(field.type) && field.validation && lengthValidation"
class="py-1.5 px-1 relative flex space-x-1"
@click.stop
>
<div class="w-1/2 relative">
<input
:placeholder="t('min')"
type="number"
min="0"
:value="lengthValidation.min"
class="input input-bordered w-full input-xs h-7 !outline-0 bg-transparent"
@input="field.validation.pattern = `.{${$event.target.value || 0},${lengthValidation.max || ''}}`"
@blur="save"
>
<label
v-if="lengthValidation.min"
:style="{ backgroundColor }"
class="absolute -top-2.5 left-1.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('min') }}
</label>
</div>
<div class="w-1/2 relative">
<input
:placeholder="t('max')"
type="number"
min="1"
class="input input-bordered w-full input-xs h-7 !outline-0 bg-transparent"
:value="lengthValidation.max"
@input="field.validation.pattern = `.{${lengthValidation.min},${$event.target.value || ''}}`"
@blur="save"
>
<label
v-if="lengthValidation.max"
:style="{ backgroundColor }"
class="absolute -top-2.5 left-1.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('max') }}
</label>
</div>
</div>
<div
v-if="['text', 'cells'].includes(field.type) && field.validation && !validations[field.validation.pattern] && !lengthValidation"
class="py-1.5 px-1 relative"
@click.stop
>
@@ -195,6 +239,27 @@
{{ t('regexp_validation') }}
</label>
</div>
<div
v-if="['text', 'cells'].includes(field.type) && field.validation && !validations[field.validation.pattern] && !lengthValidation"
class="py-1.5 px-1 relative"
@click.stop
>
<input
v-model="field.validation.message"
:placeholder="t('error_message')"
dir="auto"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0 bg-transparent"
@blur="save"
>
<label
v-if="field.validation.message"
:style="{ backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('error_message') }}
</label>
</div>
<div
v-if="field.type === 'date'"
class="py-1.5 px-1 relative"
@@ -330,7 +395,7 @@
v-if="field.type != 'stamp'"
class="pb-0.5 mt-0.5"
>
<li v-if="['text', 'number', 'date'].includes(field.type)">
<li v-if="['text', 'number', 'date', 'select'].includes(field.type)">
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="$emit('click-font')"
@@ -489,6 +554,7 @@ export default {
emits: ['set-draw', 'scroll-to', 'click-formula', 'click-description', 'click-condition', 'click-font', 'remove-area'],
data () {
return {
selectedValidation: ''
}
},
computed: {
@@ -533,8 +599,16 @@ export default {
return formats
},
lengthValidation () {
if (this.field.validation?.pattern && this.selectedValidation !== 'custom') {
return this.field.validation.pattern.match(/^\.{(?<min>\d+),(?<max>\d+)?}$/)?.groups
} else {
return null
}
},
validations () {
return {
'.{0,}': 'length',
'^[0-9]{3}-[0-9]{2}-[0-9]{4}$': 'ssn',
'^[0-9]{2}-[0-9]{7}$': 'ein',
'^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$': 'email',
@@ -553,13 +627,21 @@ export default {
methods: {
onChangeValidation (event) {
if (event.target.value === 'custom') {
this.field.validation = { pattern: '' }
this.selectedValidation = 'custom'
this.field.validation = { pattern: '', message: '' }
this.$nextTick(() => this.$refs.validationCustom.focus())
} else if (event.target.value) {
this.field.validation ||= {}
this.field.validation.pattern = event.target.value
this.field.validation.pattern =
Object.keys(this.validations).find(key => this.validations[key] === event.target.value)
this.selectedValidation = event.target.value
delete this.field.validation.message
} else {
this.selectedValidation = ''
delete this.field.validation
}
+24
View File
@@ -1,4 +1,8 @@
const en = {
error_message: 'Error message',
length: 'Length',
min: 'Min',
max: 'Max',
font: 'Font',
party: 'Party',
date_signed: 'Date Signed',
@@ -168,6 +172,10 @@ const en = {
}
const es = {
error_message: 'Mensaje de error',
length: 'Longitud',
min: 'Mín',
max: 'Máx',
date_signed: 'Fecha actual',
fuente: 'Fuente',
party: 'Parte',
@@ -337,6 +345,10 @@ const es = {
}
const it = {
error_message: 'Messaggio di errore',
length: 'Lunghezza',
min: 'Min',
max: 'Max',
date_signed: 'Data attuale',
font: 'Carattere',
party: 'Parte',
@@ -506,6 +518,10 @@ const it = {
}
const pt = {
error_message: 'Mensagem de erro',
length: 'Comprimento',
min: 'Mín',
max: 'Máx',
date_signed: 'Data atual',
fonte: 'Fonte',
party: 'Parte',
@@ -675,6 +691,10 @@ const pt = {
}
const fr = {
error_message: 'Message d\'erreur',
length: 'Longueur',
min: 'Min',
max: 'Max',
date_signed: 'Date actuelle',
font: 'Police',
party: 'Partie',
@@ -844,6 +864,10 @@ const fr = {
}
const de = {
error_message: 'Fehlermeldung',
length: 'Länge',
min: 'Min',
max: 'Max',
date_now: 'Akt. Datum',
font: 'Schriftart',
party: 'Partei',
@@ -406,7 +406,7 @@ export default {
this.mappings.every((m) => m.column_index !== index)
})
if (columnIndex !== -1) {
if (columnIndex !== -1 && this.rows.some((row) => row[columnIndex])) {
this.mappings.push({ uuid: v4(), field_name: field.name, column_index: columnIndex, submitter_uuid: submitter.uuid })
}
})
+1 -4
View File
@@ -11,9 +11,6 @@ class ProcessSubmissionExpiredJob
return if submission.submitters.where.not(declined_at: nil).exists?
return unless submission.submitters.exists?(completed_at: nil)
WebhookUrls.for_account_id(submission.account_id, %w[submission.expired]).each do |webhook|
SendSubmissionExpiredWebhookRequestJob.perform_async('submission_id' => submission.id,
'webhook_url_id' => webhook.id)
end
WebhookUrls.enqueue_events(submission, 'submission.expired')
end
end
+12 -4
View File
@@ -63,16 +63,24 @@ class ProcessSubmitterCompletionJob
end
def enqueue_completed_webhooks(submitter, is_all_completed: false)
event_uuids = {}
WebhookUrls.for_account_id(submitter.account_id, %w[form.completed submission.completed]).each do |webhook|
if webhook.events.include?('form.completed')
event_uuids['form.completed'] ||= SecureRandom.uuid
SendFormCompletedWebhookRequestJob.perform_async('submitter_id' => submitter.id,
'event_uuid' => event_uuids['form.completed'],
'webhook_url_id' => webhook.id)
end
if webhook.events.include?('submission.completed') && is_all_completed
SendSubmissionCompletedWebhookRequestJob.perform_async('submission_id' => submitter.submission_id,
'webhook_url_id' => webhook.id)
end
next unless webhook.events.include?('submission.completed') && is_all_completed
event_uuids['submission.completed'] ||= SecureRandom.uuid
SendSubmissionCompletedWebhookRequestJob.perform_async('submission_id' => submitter.submission_id,
'event_uuid' => event_uuids['submission.completed'],
'webhook_url_id' => webhook.id)
end
end
@@ -20,6 +20,9 @@ class SendFormCompletedWebhookRequestJob
ActiveStorage::Current.url_options = Docuseal.default_url_options
resp = SendWebhookRequest.call(webhook_url, event_type: 'form.completed',
event_uuid: params['event_uuid'],
record: submitter,
attempt:,
data: Submitters::SerializeForWebhook.call(submitter))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
@@ -18,13 +18,15 @@ class SendFormDeclinedWebhookRequestJob
ActiveStorage::Current.url_options = Docuseal.default_url_options
resp = SendWebhookRequest.call(webhook_url, event_type: 'form.declined',
event_uuid: params['event_uuid'],
record: submitter,
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, {
'submitter_id' => submitter.id,
'webhook_url_id' => webhook_url.id,
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
@@ -18,13 +18,15 @@ class SendFormStartedWebhookRequestJob
ActiveStorage::Current.url_options = Docuseal.default_url_options
resp = SendWebhookRequest.call(webhook_url, event_type: 'form.started',
event_uuid: params['event_uuid'],
record: submitter,
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, {
'submitter_id' => submitter.id,
'webhook_url_id' => webhook_url.id,
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
@@ -18,13 +18,15 @@ class SendFormViewedWebhookRequestJob
ActiveStorage::Current.url_options = Docuseal.default_url_options
resp = SendWebhookRequest.call(webhook_url, event_type: 'form.viewed',
event_uuid: params['event_uuid'],
record: submitter,
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, {
'submitter_id' => submitter.id,
'webhook_url_id' => webhook_url.id,
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
@@ -16,13 +16,15 @@ class SendSubmissionArchivedWebhookRequestJob
return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.archived')
resp = SendWebhookRequest.call(webhook_url, event_type: 'submission.archived',
event_uuid: params['event_uuid'],
record: submission,
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, {
'submission_id' => submission.id,
'webhook_url_id' => webhook_url.id,
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
@@ -16,6 +16,9 @@ class SendSubmissionCompletedWebhookRequestJob
return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.completed')
resp = SendWebhookRequest.call(webhook_url, event_type: 'submission.completed',
event_uuid: params['event_uuid'],
record: submission,
attempt:,
data: Submissions::SerializeForApi.call(submission))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
@@ -16,13 +16,15 @@ class SendSubmissionCreatedWebhookRequestJob
return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.created')
resp = SendWebhookRequest.call(webhook_url, event_type: 'submission.created',
event_uuid: params['event_uuid'],
record: submission,
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, {
'submission_id' => submission.id,
'webhook_url_id' => webhook_url.id,
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
@@ -16,13 +16,15 @@ class SendSubmissionExpiredWebhookRequestJob
return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.expired')
resp = SendWebhookRequest.call(webhook_url, event_type: 'submission.expired',
event_uuid: params['event_uuid'],
record: submission,
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, {
'submission_id' => submission.id,
'webhook_url_id' => webhook_url.id,
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
@@ -16,13 +16,15 @@ class SendTemplateCreatedWebhookRequestJob
return if webhook_url.url.blank? || webhook_url.events.exclude?('template.created')
resp = SendWebhookRequest.call(webhook_url, event_type: 'template.created',
event_uuid: params['event_uuid'],
record: template,
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, {
'template_id' => template.id,
'webhook_url_id' => webhook_url.id,
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
@@ -16,13 +16,15 @@ class SendTemplateUpdatedWebhookRequestJob
return if webhook_url.url.blank? || webhook_url.events.exclude?('template.updated')
resp = SendWebhookRequest.call(webhook_url, event_type: 'template.updated',
event_uuid: params['event_uuid'],
record: template,
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, {
'template_id' => template.id,
'webhook_url_id' => webhook_url.id,
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
+26
View File
@@ -0,0 +1,26 @@
# frozen_string_literal: true
class SendTestWebhookRequestJob
include Sidekiq::Job
sidekiq_options retry: 0
USER_AGENT = 'DocuSeal.com Webhook'
def perform(params = {})
submitter = Submitter.find(params['submitter_id'])
webhook_url = WebhookUrl.find(params['webhook_url_id'])
return unless webhook_url && submitter
Faraday.post(webhook_url.url,
{
event_type: 'form.completed',
timestamp: Time.current.iso8601,
data: Submitters::SerializeForWebhook.call(submitter)
}.to_json,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT,
**webhook_url.secret.to_h)
end
end
+1
View File
@@ -33,6 +33,7 @@ class Account < ApplicationRecord
has_many :account_linked_accounts, dependent: :destroy
has_many :email_events, dependent: :destroy
has_many :webhook_urls, dependent: :destroy
has_many :webhook_events, dependent: nil
has_many :account_accesses, dependent: :destroy
has_many :account_testing_accounts, -> { testing }, dependent: :destroy,
class_name: 'AccountLinkedAccount',
+1 -1
View File
@@ -40,7 +40,7 @@ class Submission < ApplicationRecord
belongs_to :account
belongs_to :created_by_user, class_name: 'User', optional: true
has_one :search_entry, as: :record, inverse_of: :record, dependent: :destroy
has_one :search_entry, as: :record, inverse_of: :record, dependent: :destroy if SearchEntry.table_exists?
has_many :submitters, dependent: :destroy
has_many :submission_events, dependent: :destroy
+1 -1
View File
@@ -43,7 +43,7 @@ class Submitter < ApplicationRecord
belongs_to :submission
belongs_to :account
has_one :template, through: :submission
has_one :search_entry, as: :record, inverse_of: :record, dependent: :destroy
has_one :search_entry, as: :record, inverse_of: :record, dependent: :destroy if SearchEntry.table_exists?
attribute :values, :string, default: -> { {} }
attribute :preferences, :string, default: -> { {} }
+1 -1
View File
@@ -44,7 +44,7 @@ class Template < ApplicationRecord
belongs_to :account
belongs_to :folder, class_name: 'TemplateFolder'
has_one :search_entry, as: :record, inverse_of: :record, dependent: :destroy
has_one :search_entry, as: :record, inverse_of: :record, dependent: :destroy if SearchEntry.table_exists?
before_validation :maybe_set_default_folder, on: :create
+25
View File
@@ -0,0 +1,25 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: webhook_attempts
#
# id :bigint not null, primary key
# attempt :integer not null
# response_body :text
# response_status_code :integer not null
# created_at :datetime not null
# updated_at :datetime not null
# webhook_event_id :bigint not null
#
# Indexes
#
# index_webhook_attempts_on_webhook_event_id (webhook_event_id)
#
class WebhookAttempt < ApplicationRecord
belongs_to :webhook_event
def success?
[2, 3].include?(response_status_code.to_i / 100)
end
end
+32
View File
@@ -0,0 +1,32 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: webhook_events
#
# id :bigint not null, primary key
# event_type :string not null
# record_type :string not null
# status :string not null
# uuid :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# record_id :bigint not null
# webhook_url_id :bigint not null
#
# Indexes
#
# index_webhook_events_error (webhook_url_id,id) WHERE ((status)::text = 'error'::text)
# index_webhook_events_on_uuid_and_webhook_url_id (uuid,webhook_url_id) UNIQUE
# index_webhook_events_on_webhook_url_id_and_id (webhook_url_id,id)
#
class WebhookEvent < ApplicationRecord
attribute :uuid, :string, default: -> { SecureRandom.uuid }
belongs_to :webhook_url, optional: true
belongs_to :account, optional: true
belongs_to :record, polymorphic: true, optional: true
has_many :webhook_attempts, dependent: nil
end
+1
View File
@@ -37,6 +37,7 @@ class WebhookUrl < ApplicationRecord
].freeze
belongs_to :account
has_many :webhook_events, dependent: nil
attribute :events, :string, default: -> { %w[form.viewed form.started form.completed form.declined] }
attribute :secret, :string, default: -> { {} }
+4 -1
View File
@@ -14,7 +14,10 @@
<div class="form-control">
<%= ff.label :timezone, t('time_zone'), class: 'label' %>
<%= ff.select :timezone, nil, {}, class: 'base-select' do %>
<%= time_zone_options_for_select(current_account.timezone) %>
<% tzinfo = TZInfo::Timezone.get(ActiveSupport::TimeZone::MAPPING[current_account.timezone] || current_account.timezone) %>
<% items = ActiveSupport::TimeZone.all.map { |z| [z.to_s, z.name] } %>
<% items.unshift([tzinfo.to_s, current_account.timezone]) unless ActiveSupport::TimeZone.all.find { |e| e.tzinfo == tzinfo } %>
<%= options_for_select(items, current_account.timezone) %>
<% end %>
</div>
<div class="form-control">
@@ -9,6 +9,10 @@
if (this.field.scrollHeight > this.field.clientHeight) {
this.field.classList.add('text-[0.8vw]', 'lg:text-[0.50rem]');
}
if (this.field.scrollHeight > this.field.clientHeight) {
this.field.classList.add('text-[0.7vw]', 'lg:text-[0.45rem]');
}
}
}
get field() {
@@ -1,4 +1,3 @@
<% dashboard_templates_order = cookies.permanent[:dashboard_templates_order] || 'created_at' %>
<form action="<%= url_for %>" method="get" class="dropdown dropdown-top hidden md:inline">
<label tabindex="0" class="btn btn-sm h-10">
<%= svg_icon('arrow_sort', class: 'w-5 h-5') %>
@@ -6,7 +5,7 @@
<ul tabindex="0" class="dropdown-content z-[10] menu p-2 shadow bg-base-100 rounded-box mb-1 min-w-48">
<toggle-cookies data-value="created_at" data-key="dashboard_templates_order">
<li>
<button class="<%= 'bg-base-200' if dashboard_templates_order == 'created_at' %>">
<button class="<%= 'bg-base-200' if selected_order == 'created_at' %>">
<%= svg_icon('sort_descending_numbers', class: 'w-4 h-4') %>
<span class="whitespace-nowrap"><%= t('newest_first') %></span>
</button>
@@ -15,7 +14,7 @@
<% if local_assigns[:with_recently_used] != false %>
<toggle-cookies data-value="used_at" data-key="dashboard_templates_order">
<li>
<button class="<%= 'bg-base-200' if dashboard_templates_order == 'used_at' %>">
<button class="<%= 'bg-base-200' if selected_order == 'used_at' %>">
<%= svg_icon('sort_descending_small_big', class: 'w-4 h-4') %>
<span class="whitespace-nowrap"><%= t('recently_used') %></span>
</button>
@@ -24,7 +23,7 @@
<% end %>
<toggle-cookies data-value="name" data-key="dashboard_templates_order">
<li>
<button class="<%= 'bg-base-200' if dashboard_templates_order == 'name' %>">
<button class="<%= 'bg-base-200' if selected_order == 'name' %>">
<%= svg_icon('sort_ascending_letters', class: 'w-4 h-4') %>
<span class="whitespace-nowrap"><%= t('name_a_z') %></span>
</button>
+1 -1
View File
@@ -14,7 +14,7 @@
&times;
</span>
<% end %>
<div class="w-full md:w-[620px] overflow-y-auto" style="max-height: calc(100vh - <%= local_assigns[:title] ? '45px' : '0px' %>)">
<div class="w-screen md:w-[620px] overflow-y-auto" style="max-height: calc(100vh - <%= local_assigns[:title] ? '45px' : '0px' %>)">
<%= yield %>
</div>
</div>
+1 -1
View File
@@ -28,7 +28,7 @@
<% end %>
<% if Templates.filter_undefined_submitters(@template.submitters).size == 1 && %w[api embed].exclude?(@submitter.submission.source) && @submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false && @template.shared_link? %>
<toggle-submit class="block">
<%= button_to button_title(title: t('resubmit'), disabled_with: t('resubmit'), icon: svg_icon('reload', class: 'w-6 h-6')), start_form_path(@template.slug), params: { submitter: { email: params[:email] }, resubmit: true }, method: :put, class: 'white-button w-full' %>
<%= button_to button_title(title: t('resubmit'), disabled_with: t('resubmit'), icon: svg_icon('reload', class: 'w-6 h-6')), start_form_path(@template.slug), params: { submitter: params.permit(:name, :email, :phone).compact_blank, resubmit: true }, method: :put, class: 'white-button w-full' %>
</toggle-submit>
<% end %>
</div>
+24 -21
View File
@@ -1,28 +1,31 @@
<% content_for(:html_title, "#{@template.name} | DocuSeal") %>
<% content_for(:html_description, t('share_link_is_currently_disabled')) %>
<div class="max-w-md mx-auto px-2 mt-12 mb-4">
<div class="space-y-6 mx-auto">
<div class="space-y-6">
<div class="text-center w-full space-y-6">
<%= render 'banner' %>
<p class="text-xl font-semibold text-center">
<%= t('share_link_is_currently_disabled') %>
</p>
<% I18n.with_locale(@template.account.locale) do %>
<% content_for(:html_description, t('share_link_is_currently_disabled')) %>
<% end %>
<div class="max-w-md space-y-6 mx-auto px-2 mt-12 mb-4">
<div class="text-center w-full space-y-6">
<%= render 'banner' %>
<p class="text-xl font-semibold text-center">
<%= t('share_link_is_currently_disabled') %>
</p>
</div>
<div class="flex items-center bg-base-200 rounded-xl p-4 mb-4">
<div class="flex items-center">
<div class="mr-3">
<%= svg_icon('writing_sign', class: 'w-10 h-10') %>
</div>
<div class="flex items-center bg-base-200 rounded-xl p-4 mb-4">
<div class="flex items-center">
<div class="mr-3">
<%= svg_icon('writing_sign', class: 'w-10 h-10') %>
</div>
<div>
<p class="text-lg font-bold mb-1"><%= @template.name %></p>
<% if @template.archived_at? %>
<p dir="auto" class="text-sm"><%= t('form_has_been_deleted_by_html', name: @template.account.name) %></p>
<% end %>
</div>
</div>
<div>
<p class="text-lg font-bold mb-1"><%= @template.name %></p>
<% if @template.archived_at? %>
<p dir="auto" class="text-sm"><%= t('form_has_been_deleted_by_html', name: @template.account.name) %></p>
<% end %>
</div>
</div>
</div>
<% if can?(:update, @template) %>
<toggle-submit class="block">
<%= button_to button_title(title: t('enable_shared_link'), icon: svg_icon('lock_open', class: 'w-6 h-6')), template_share_link_path(@template), params: { template: { shared_link: true }, redir: start_form_path(slug: @template.slug) }, method: :post, class: 'white-button w-full' %>
</toggle-submit>
<% end %>
</div>
<%= render 'shared/attribution', link_path: '/start', account: @template.account %>
+29 -8
View File
@@ -1,5 +1,7 @@
<% content_for(:html_title, "#{@template.name} | DocuSeal") %>
<% content_for(:html_description, "#{@template.account.name} has invited you to fill and sign documents online effortlessly with a secure, fast, and user-friendly digital document signing solution.") %>
<% I18n.with_locale(@template.account.locale) do %>
<% content_for(:html_description, t('account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution', account_name: @template.account.name)) %>
<% end %>
<div class="max-w-md mx-auto px-2 mt-12 mb-4">
<div class="space-y-6 mx-auto">
<div class="space-y-6">
@@ -27,13 +29,32 @@
</div>
<% if !@template.archived_at? && !@template.account.archived_at? %>
<%= form_for @submitter, url: start_form_path(@template.slug), data: { turbo_frame: :_top }, method: :put, html: { class: 'space-y-4' } do |f| %>
<div dir="auto" class="form-control !mt-0">
<%= f.label :email, t('email'), class: 'label' %>
<%= f.email_field :email, value: current_user&.email || params[:email] || @submitter.email, required: true, class: 'base-input', placeholder: t('provide_your_email_to_start') %>
<% if @error_message %>
<span class="label-text-alt text-red-400 mt-1"><%= @error_message %></span>
<% end %>
</div>
<% if @error_message %>
<div class="alert items-start bg-base-100 border border-red-400 text-red-400">
<%= svg_icon('info_circle', class: 'stroke-current shrink-0 h-6 w-6 mt-1') %>
<div class="text-sm"><%= @error_message %></div>
</div>
<% end %>
<% link_form_fields = @template.preferences.fetch('link_form_fields', ['email']) %>
<% multiple_fields = link_form_fields.size > 1 %>
<% if link_form_fields.include?('name') %>
<div dir="auto" class="form-control !mt-0">
<%= f.label :name, t('name'), class: 'label' %>
<%= f.text_field :name, value: current_user&.full_name || params[:name] || @submitter.name, required: true, class: 'base-input', placeholder: t(multiple_fields ? 'provide_your_name' : 'provide_your_name_to_start') %>
</div>
<% end %>
<% if link_form_fields.include?('email') %>
<div dir="auto" class="form-control !mt-0">
<%= f.label :email, t('email'), class: 'label' %>
<%= f.email_field :email, value: current_user&.email || params[:email] || @submitter.email, required: true, class: 'base-input', placeholder: t(multiple_fields ? 'provide_your_email' : 'provide_your_email_to_start') %>
</div>
<% end %>
<% if link_form_fields.include?('phone') %>
<div dir="auto" class="form-control !mt-0">
<%= f.label :phone, t('phone'), class: 'label' %>
<%= f.telephone_field :phone, value: params[:phone] || @submitter.phone, pattern: '^\+[0-9\s\-]+$', oninvalid: "this.value ? this.setCustomValidity('#{t('use_international_format_1xxx_')}') : ''", oninput: "this.setCustomValidity('')", required: true, class: 'base-input', placeholder: t(multiple_fields ? 'provide_your_phone_in_international_format' : 'provide_your_phone_in_international_format_to_start') %>
</div>
<% end %>
<toggle-submit dir="auto" class="form-control">
<%= f.button button_title(title: t('start'), disabled_with: t('starting')), class: 'base-button' %>
</toggle-submit>
+2 -2
View File
@@ -28,9 +28,9 @@
</div>
<% end %>
</div>
<% elsif field['type'].in?(['image', 'initials', 'stamp']) %>
<% elsif field['type'].in?(['image', 'initials', 'stamp']) && attachments_index[value].image? %>
<img class="object-contain mx-auto" src="<%= attachments_index[value].url %>" loading="lazy">
<% elsif field['type'].in?(['file', 'payment']) %>
<% elsif field['type'].in?(['file', 'payment', 'image']) %>
<autosize-field></autosize-field>
<div class="px-0.5 flex flex-col justify-center">
<% Array.wrap(value).each do |val| %>
+11 -9
View File
@@ -74,12 +74,14 @@
<% schema = Submissions.filtered_conditions_schema(@submission, values:) %>
<% schema.each do |item| %>
<% document = @submission.schema_documents.find { |a| item['attachment_uuid'] == a.uuid } %>
<a href="#<%= "page-#{document.uuid}-0" %>" onclick="[event.preventDefault(), window[event.target.closest('a').href.split('#')[1]].scrollIntoView({ behavior: 'smooth', block: 'start' })]" class="block cursor-pointer">
<img src="<%= Docuseal::URL_CACHE.fetch([document.id, document.uuid, 0].join(':'), expires_in: 10.minutes) { document.preview_images.first.url } %>" width="<%= document.preview_images.first.metadata['width'] %>" height="<%= document.preview_images.first.metadata['height'] %>" class="rounded border" loading="lazy">
<div class="pb-2 pt-1.5 text-center" dir="auto">
<%= item['name'].presence || document.filename.base %>
</div>
</a>
<% if document.preview_images.first %>
<a href="#<%= "page-#{document.uuid}-0" %>" onclick="[event.preventDefault(), window[event.target.closest('a').href.split('#')[1]].scrollIntoView({ behavior: 'smooth', block: 'start' })]" class="block cursor-pointer">
<img src="<%= Docuseal::URL_CACHE.fetch([document.id, document.uuid, 0].join(':'), expires_in: 10.minutes) { document.preview_images.first.url } %>" width="<%= document.preview_images.first.metadata['width'] %>" height="<%= document.preview_images.first.metadata['height'] %>" class="rounded border" loading="lazy">
<div class="pb-2 pt-1.5 text-center" dir="auto">
<%= item['name'].presence || document.filename.base %>
</div>
</a>
<% end %>
<% end %>
</div>
<div id="document_view" class="w-full overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5">
@@ -92,7 +94,7 @@
<% document = @submission.schema_documents.find { |e| e.uuid == item['attachment_uuid'] } %>
<% document_annots_index = document.metadata.dig('pdf', 'annotations')&.group_by { |e| e['page'] } || {} %>
<% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %>
<% lazyload_metadata = document.preview_images.first.metadata %>
<% lazyload_metadata = document.preview_images.first&.metadata || {} %>
<% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %>
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_uuid, "#{index}.jpg")) %>
<div id="<%= "page-#{document.uuid}-#{index}" %>" class="relative">
@@ -242,9 +244,9 @@
<div class="w-full bg-base-300 py-1">
<img class="object-contain mx-auto" style="max-height: <%= field['type'] == 'signature' ? 100 : 50 %>px" height="<%= attachments_index[value].metadata['height'] %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy">
</div>
<% elsif field['type'].in?(['image', 'stamp']) %>
<% elsif field['type'].in?(['image', 'stamp']) && attachments_index[value].image? %>
<img class="object-contain mx-auto max-h-28" style="max-height: 200px" height="<%= attachments_index[value].metadata['height'] %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy">
<% elsif field['type'] == 'file' || field['type'] == 'payment' %>
<% elsif field['type'].in?(['file', 'payment', 'image']) %>
<div class="flex flex-col justify-center">
<% Array.wrap(value).each do |val| %>
<a target="_blank" class="flex items-center space-x-1" href="<%= attachments_index[val].url %>">
+3 -1
View File
@@ -1,5 +1,7 @@
<% content_for(:html_title, "#{@submitter.submission.name || @submitter.submission.template.name} | DocuSeal") %>
<% content_for(:html_description, "#{@submitter.account.name} has invited you to fill and sign documents online effortlessly with a secure, fast, and user-friendly digital document signing solution.") %>
<% I18n.with_locale(@submitter.account.locale) do %>
<% content_for(:html_description, t('account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution', account_name: @submitter.account.name)) %>
<% end %>
<% fields_index = Templates.build_field_areas_index(@submitter.submission.template_fields || @submitter.submission.template.fields) %>
<% values = @submitter.submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } %>
<% submitters_index = @submitter.submission.submitters.index_by(&:uuid) %>
+1 -1
View File
@@ -45,7 +45,7 @@
</div>
<% templates_order_select_html = capture do %>
<% if params[:q].blank? && @pagy.pages > 1 %>
<%= render('shared/templates_order_select', with_recently_used: @pagy.count.present? && @pagy.count < 10_000) %>
<%= render 'shared/templates_order_select', with_recently_used: @pagy.count.present? && @pagy.count < 10_000 && !can?(:manage, :countless), selected_order: %>
<% end %>
<% end %>
<%= render 'shared/pagination', pagy: @pagy, items_name: 'templates', right_additional_html: templates_order_select_html %>
+1 -1
View File
@@ -45,7 +45,7 @@
<% end %>
<% templates_order_select_html = capture do %>
<% if params[:q].blank? && @pagy.pages > 1 %>
<%= render('shared/templates_order_select', with_recently_used: @pagy.count.present? && @pagy.count < 10_000) %>
<%= render 'shared/templates_order_select', with_recently_used: @pagy.count.present? && @pagy.count < 10_000 && !can?(:manage, :countless), selected_order: %>
<% end %>
<% end %>
<% if @template_folders.present? %>
@@ -0,0 +1,87 @@
<% close_on_submit = local_assigns.fetch(:close_on_submit, true) %>
<%= form_for template, url: template_recipients_path(template), method: :post, html: { autocomplete: 'off', class: 'mt-1', id: :submitters_form }, data: { close_on_submit: } do |f| %>
<% unless close_on_submit %>
<toggle-on-submit data-element-id="form_saved_alert"></toggle-on-submit>
<% end %>
<div class="space-y-3 divide-y">
<% template.submitters.each_with_index do |submitter, index| %>
<div class="<%= 'pt-3' if index.positive? %>">
<%= f.fields_for :submitters, item = Struct.new(:name, :uuid, :is_requester, :email, :invite_by_uuid, :optional_invite_by_uuid, :linked_to_uuid, :option).new(*submitter.values_at('name', 'uuid', 'is_requester', 'email', 'invite_by_uuid', 'optional_invite_by_uuid', 'linked_to_uuid')), index: do |ff| %>
<% item.option = item.is_requester.present? ? 'is_requester' : (item.email.present? ? 'email' : (item.linked_to_uuid.present? ? "linked_to_#{item.linked_to_uuid}" : (item.invite_by_uuid.present? ? "invite_by_#{item.invite_by_uuid}" : (item.optional_invite_by_uuid.present? ? "optional_invite_by_#{item.optional_invite_by_uuid}" : '')))) %>
<%= ff.hidden_field :uuid %>
<div class="form-control">
<%= ff.text_field :name, class: 'w-full outline-none border-transparent focus:border-transparent focus:ring-0 bg-base-100 px-1 peer mb-2', autocomplete: 'off', placeholder: "#{index + 1}#{(index + 1).ordinal} Party", required: true %>
<% if template.submitters.size == 2 %>
<%= tag.input name: ff.field_name(:email), value: ff.object.email, type: :email, class: 'base-input', multiple: true, autocomplete: 'off', placeholder: t('default_email'), disabled: ff.object.is_requester || ff.object.invite_by_uuid.present? || ff.object.optional_invite_by_uuid.present?, id: field_uuid = SecureRandom.uuid %>
<% else %>
<toggle-attribute data-target-id="<%= email_field_uuid = SecureRandom.uuid %>" data-class-name="hidden" data-value="email">
<%= ff.select :option, [[t('not_specified'), 'not_set'], (local_assigns[:with_submission_requester] == false ? nil : [t('submission_requester'), 'is_requester']), [t('specified_email'), 'email'], *(template.submitters - [submitter]).flat_map { |e| [[t('invite_by_name', name: e['name']), "invite_by_#{e['uuid']}"], [t('invite_by_name', name: e['name']) + " (#{t(:optional).capitalize})", "optional_invite_by_#{e['uuid']}"]] }, *(template.submitters - [submitter]).map { |e| [t('same_as_name', name: e['name']), "linked_to_#{e['uuid']}"] }].compact, {}, class: 'base-select mb-3' %>
</toggle-attribute>
<%= tag.input name: ff.field_name(:email), type: :email, value: ff.object.email, multiple: true, class: "base-input #{'hidden' if item.option != 'email'}", autocomplete: 'off', placeholder: t('default_email'), id: email_field_uuid %>
<% end %>
</div>
<% if template.submitters.size == 2 %>
<checkbox-group class="mt-3 flex items-center space-x-4">
<% if local_assigns[:with_submission_requester] != false %>
<label class="flex items-center space-x-2 cursor-pointer">
<toggle-attribute data-target-id="<%= field_uuid %>" class="flex" data-attribute="disabled">
<%= ff.check_box :is_requester, class: 'base-checkbox' %>
</toggle-attribute>
<span class="select-none">
<%= t('submission_requester') %>
</span>
</label>
<% end %>
<% if index == 1 %>
<label class="flex items-center space-x-2 cursor-pointer">
<toggle-attribute data-target-id="<%= field_uuid %>" class="flex" data-attribute="disabled">
<indeterminate-checkbox data-indeterminate="<%= ff.object.optional_invite_by_uuid.present? %>" data-show-indeterminate-id="invite_optional" data-name="<%= ff.field_name(:invite_by_uuid) %>" data-indeterminate-name="<%= ff.field_name(:optional_invite_by_uuid) %>" class="flex">
<%= ff.check_box ff.object.optional_invite_by_uuid.present? ? :optional_invite_by_uuid : :invite_by_uuid, { class: 'base-checkbox' }, template.submitters.first['uuid'], '' %>
</indeterminate-checkbox>
</toggle-attribute>
<span class="select-none">
<%= t('invite_by_name', name: template.submitters.first['name']) %> <span id="invite_optional" class="<%= 'hidden' if ff.object.optional_invite_by_uuid.blank? %>">(<%= t(:optional).capitalize %>)</span>
</span>
</label>
<% end %>
</checkbox-group>
<% end %>
<% end %>
</div>
<% end %>
</div>
<% end %>
<% if local_assigns[:with_toggles] != false %>
<% unless current_account.account_configs.exists?(key: AccountConfig::ENFORCE_SIGNING_ORDER_KEY, value: true) %>
<%= form_for template, url: template_preferences_path(template), method: :post, html: { autocomplete: 'off', class: 'mt-2' }, data: { close_on_submit: false } do |f| %>
<div class="flex items-center pt-4 mt-4 justify-between border-t w-full">
<span>
<%= t('enforce_recipients_order') %>
</span>
<%= f.fields_for :preferences, Struct.new(:submitters_order).new(template.preferences['submitters_order']) do |ff| %>
<%= ff.check_box :submitters_order, { class: 'toggle', onchange: 'this.form.requestSubmit()' }, 'preserved', '' %>
<% end %>
</div>
<% end %>
<% end %>
<% if can?(:manage, :personalization_advanced) %>
<%= form_for template, url: template_preferences_path(template), method: :post, html: { autocomplete: 'off', class: 'mt-2' }, data: { close_on_submit: false } do |f| %>
<div class="flex items-center mt-4 justify-between w-full">
<span>
<%= t('ensure_unique_recipients') %>
</span>
<%= f.fields_for :preferences, Struct.new(:validate_unique_submitters).new(template.preferences['validate_unique_submitters']) do |ff| %>
<%= ff.check_box :validate_unique_submitters, { class: 'toggle', onchange: 'this.form.requestSubmit()' }, 'true', '' %>
<% end %>
</div>
<% end %>
<% end %>
<% end %>
<div class="form-control mt-5">
<%= button_tag button_title(title: t('save'), disabled_with: t('updating')), class: 'base-button', form: :submitters_form %>
<% unless close_on_submit %>
<div class="flex justify-center">
<span id="form_saved_alert" class="text-sm invisible font-normal mt-0.5"><%= t('changes_have_been_saved') %></span>
</div>
<% end %>
</div>
+2 -85
View File
@@ -287,91 +287,8 @@
<%= render 'templates_code_modal/preferences', class: 'pt-2' %>
</div>
<% if show_recipients %>
<div id="recipients" class="hidden mt-2 mb-4 px-5">
<%= form_for @template, url: template_recipients_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-1', id: :submitters_form } do |f| %>
<div class="space-y-3 divide-y">
<% @template.submitters.each_with_index do |submitter, index| %>
<div class="pt-3">
<%= f.fields_for :submitters, item = Struct.new(:name, :uuid, :is_requester, :email, :invite_by_uuid, :optional_invite_by_uuid, :linked_to_uuid, :option).new(*submitter.values_at('name', 'uuid', 'is_requester', 'email', 'invite_by_uuid', 'optional_invite_by_uuid', 'linked_to_uuid')), index: do |ff| %>
<% item.option = item.is_requester.present? ? 'is_requester' : (item.email.present? ? 'email' : (item.linked_to_uuid.present? ? "linked_to_#{item.linked_to_uuid}" : (item.invite_by_uuid.present? ? "invite_by_#{item.invite_by_uuid}" : (item.optional_invite_by_uuid.present? ? "optional_invite_by_#{item.optional_invite_by_uuid}" : '')))) %>
<%= ff.hidden_field :uuid %>
<div class="form-control">
<%= ff.text_field :name, class: 'w-full outline-none border-transparent focus:border-transparent focus:ring-0 bg-base-100 px-1 peer mb-2', autocomplete: 'off', placeholder: "#{index + 1}#{(index + 1).ordinal} Party", required: true %>
<% if @template.submitters.size == 2 %>
<%= tag.input name: ff.field_name(:email), value: ff.object.email, type: :email, class: 'base-input', multiple: true, autocomplete: 'off', placeholder: t('default_email'), disabled: ff.object.is_requester || ff.object.invite_by_uuid.present? || ff.object.optional_invite_by_uuid.present?, id: field_uuid = SecureRandom.uuid %>
<% else %>
<toggle-attribute data-target-id="<%= email_field_uuid = SecureRandom.uuid %>" data-class-name="hidden" data-value="email">
<%= ff.select :option, [[t('not_specified'), 'not_set'], [t('submission_requester'), 'is_requester'], [t('specified_email'), 'email'], *(@template.submitters - [submitter]).flat_map { |e| [[t('invite_by_name', name: e['name']), "invite_by_#{e['uuid']}"], [t('invite_by_name', name: e['name']) + " (#{t(:optional).capitalize})", "optional_invite_by_#{e['uuid']}"]] }, *(@template.submitters - [submitter]).map { |e| [t('same_as_name', name: e['name']), "linked_to_#{e['uuid']}"] }], {}, class: 'base-select mb-3' %>
</toggle-attribute>
<%= tag.input name: ff.field_name(:email), type: :email, value: ff.object.email, multiple: true, class: "base-input #{'hidden' if item.option != 'email'}", autocomplete: 'off', placeholder: t('default_email'), id: email_field_uuid %>
<% end %>
</div>
<% if @template.submitters.size == 2 %>
<checkbox-group class="mt-3 flex items-center space-x-4">
<label class="flex items-center space-x-2 cursor-pointer">
<toggle-attribute data-target-id="<%= field_uuid %>" class="flex" data-attribute="disabled">
<%= ff.check_box :is_requester, class: 'base-checkbox' %>
</toggle-attribute>
<span class="select-none">
<%= t('submission_requester') %>
</span>
</label>
<% if index == 1 %>
<label class="flex items-center space-x-2 cursor-pointer">
<toggle-attribute data-target-id="<%= field_uuid %>" class="flex" data-attribute="disabled">
<indeterminate-checkbox data-indeterminate="<%= ff.object.optional_invite_by_uuid.present? %>" data-show-indeterminate-id="invite_optional" data-name="<%= ff.field_name(:invite_by_uuid) %>" data-indeterminate-name="<%= ff.field_name(:optional_invite_by_uuid) %>" class="flex">
<%= ff.check_box ff.object.optional_invite_by_uuid.present? ? :optional_invite_by_uuid : :invite_by_uuid, { class: 'base-checkbox' }, @template.submitters.first['uuid'], '' %>
</indeterminate-checkbox>
</toggle-attribute>
<span class="select-none">
<%= t('invite_by_name', name: @template.submitters.first['name']) %> <span id="invite_optional" class="<%= 'hidden' if ff.object.optional_invite_by_uuid.blank? %>">(<%= t(:optional).capitalize %>)</span>
</span>
</label>
<% end %>
</checkbox-group>
<% end %>
<% end %>
</div>
<% end %>
</div>
<% end %>
<% unless current_account.account_configs.exists?(key: AccountConfig::ENFORCE_SIGNING_ORDER_KEY, value: true) %>
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-2' }, data: { close_on_submit: false } do |f| %>
<div class="flex items-center pt-4 mt-4 justify-between border-t w-full">
<span>
<%= t('enforce_recipients_order') %>
</span>
<%= f.fields_for :preferences, Struct.new(:submitters_order).new(@template.preferences['submitters_order']) do |ff| %>
<%= ff.check_box :submitters_order, { class: 'toggle', onchange: 'this.form.requestSubmit()' }, 'preserved', '' %>
<% end %>
</div>
<% end %>
<% end %>
<% if can?(:manage, :personalization_advanced) %>
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-2' }, data: { close_on_submit: false } do |f| %>
<div class="flex items-center mt-4 justify-between w-full">
<span>
<%= t('ensure_unique_recipients') %>
</span>
<%= f.fields_for :preferences, Struct.new(:validate_unique_submitters).new(@template.preferences['validate_unique_submitters']) do |ff| %>
<%= ff.check_box :validate_unique_submitters, { class: 'toggle', onchange: 'this.form.requestSubmit()' }, 'true', '' %>
<% end %>
</div>
<% end %>
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-2' }, data: { close_on_submit: false } do |f| %>
<div class="flex items-center mt-4 justify-between w-full">
<span>
<%= t('require_all_recipients') %>
</span>
<%= f.fields_for :preferences, Struct.new(:require_all_submitters).new(@template.preferences['require_all_submitters']) do |ff| %>
<%= ff.check_box :require_all_submitters, { class: 'toggle', onchange: 'this.form.requestSubmit()' }, 'true', '' %>
<% end %>
</div>
<% end %>
<% end %>
<div class="form-control mt-5 pb-2">
<%= button_tag button_title(title: t('save'), disabled_with: t('updating')), class: 'base-button', form: :submitters_form %>
</div>
<div id="recipients" class="hidden mt-2 mb-4 px-5 pt-1 pb-2">
<%= render 'recipients', template: @template %>
</div>
<% end %>
<% if show_api %>
+31 -2
View File
@@ -1,10 +1,10 @@
<%= render 'shared/turbo_modal_large', title: t('share_link') do %>
<div class="mt-2 mb-4 px-5">
<%= form_for @template, url: template_share_link_path(@template), method: :post, html: { id: 'shared_link_form', autocomplete: 'off', class: 'mt-3' }, data: { close_on_submit: false } do |f| %>
<div class="flex items-center justify-between gap-1 px-1">
<label for="template_shared_link" class="flex items-center my-4 justify-between gap-1 alert bg-base-100 border-base-300">
<span><%= t('enable_shared_link') %></span>
<%= f.check_box :shared_link, { disabled: !can?(:update, @template), class: 'toggle', onchange: 'this.form.requestSubmit()' }, 'true', 'false' %>
</div>
</label>
<div class="flex gap-2 mt-3">
<input id="embedding_url" type="text" value="<%= start_form_url(slug: @template.slug) %>" class="base-input w-full" autocomplete="off" readonly>
<check-on-click data-element-id="template_shared_link">
@@ -12,5 +12,34 @@
</check-on-click>
</div>
<% end %>
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-4 mb-5' }, data: { close_on_submit: false } do |f| %>
<% link_form_fields = @template.preferences.fetch('link_form_fields', ['email']) %>
<label class="pl-1"><%= t('link_form_fields') %></label>
<required-checkbox-group class="flex flex-col md:flex-row items-center gap-2 w-full mt-2">
<% %w[name email phone].each do |field| %>
<%= label_tag "link_form_fields_#{field}", t(field), class: 'relative flex w-full md:w-1/3 items-center h-12 border-base-300 py-3.5 border rounded-xl' do %>
<%= check_box_tag 'template[preferences][link_form_fields][]', field, link_form_fields.include?(field), class: 'absolute !animate-none checkbox left-3', id: "link_form_fields_#{field}" %>
<span class="font-medium w-full text-center"><%= t(field) %></span>
<% end %>
<% end %>
</required-checkbox-group>
<% end %>
<% if Templates.filter_undefined_submitters(@template.submitters).size > 1 %>
<div class="alert items-start bg-base-100 border-base-300 mt-4">
<%= svg_icon('info_circle', class: 'stroke-current shrink-0 h-6 w-6 mt-1') %>
<div><%= t('this_template_has_multiple_parties_which_prevents_the_use_of_a_sharing_link') %></div>
</div>
<% end %>
<% if @template.submitters.to_a.length > 1 %>
<div class="collapse collapse-arrow join-item border border-base-300 mt-4 mb-5">
<input type="checkbox" name="accordion">
<div class="collapse-title text-xl font-medium">
<%= t('default_parties') %>
</div>
<div class="collapse-content !pb-0">
<%= render 'templates_preferences/recipients', template: @template, close_on_submit: false, with_toggles: false, with_submission_requester: false %>
</div>
</div>
<% end %>
</div>
<% end %>
@@ -0,0 +1,57 @@
<div id="drawer_events_<%= dom_id(webhook_event) %>">
<ol class="relative border-s border-base-300 space-y-6 ml-3">
<% 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 %>
<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)) %>
</p>
</li>
<% end %>
<% end %>
<% if webhook_attempts.present? %>
<% webhook_attempts.each do |webhook_attempt| %>
<li class="ml-7">
<span class="btn btn-outline btn-xs btn-circle pointer-events-none absolute justify-center <%= webhook_attempt.success? ? 'btn-success bg-lime-50' : 'btn-error bg-red-50' %>" style="left: -12px;">
<%= svg_icon(webhook_attempt.success? ? 'check' : 'x', class: 'w-4 h-4 shrink-0') %>
</span>
<p class="leading-none text-sm text-base-content/60 pt-1">
<%= l(webhook_attempt.created_at.in_time_zone(current_account.timezone), format: :long, locale: current_account.locale) %>
</p>
<div class="mt-2">
<p class="text-sm font-bold text-base-content/80">
<span><%= Rack::Utils::HTTP_STATUS_CODES[webhook_attempt.response_status_code] %></span>
<% if webhook_attempt.response_status_code.positive? %>
<span>(<%= webhook_attempt.response_status_code %>)</span>
<% end %>
</p>
<% unless webhook_attempt.success? %>
<p class="text-sm text-base-content/80 mt-1">
<%= webhook_attempt.response_body.presence || Rack::Utils::HTTP_STATUS_CODES[webhook_attempt.response_status_code] %>
</p>
<% end %>
</div>
</li>
<% end %>
<% else %>
<li class="ml-7">
<span class="btn btn-outline btn-xs btn-circle pointer-events-none absolute justify-center btn-info bg-blue-50" style="left: -12px;">
<%= svg_icon('clock', class: 'w-4 h-4 shrink-0') %>
</span>
<p class="leading-none text-base-content/60 pt-1">
<%= l(webhook_event.created_at.in_time_zone(current_account.timezone), format: :long, locale: current_account.locale) %>
</p>
</li>
<% end %>
</ol>
<% unless webhook_event.status == 'pending' %>
<div class="absolute right-4 top-3">
<%= button_to button_title(title: t('resend'), disabled_with: t('awaiting'), icon: svg_icon('rotate', class: 'w-4 h-4'), icon_disabled: svg_icon('loader', class: 'w-4 h-4 animate-spin')), resend_settings_webhook_event_path(webhook_url.id, webhook_event.uuid), form: { id: button_uuid = SecureRandom.uuid }, params: { button_id: button_uuid }, class: 'btn btn-neutral btn-sm text-white', method: :post, onclick: '[this.form.requestSubmit(), this.disabled = true]' %>
</div>
<% end %>
</div>
@@ -0,0 +1,28 @@
<div id="<%= dom_id(webhook_event) %>" class="group relative hover:cursor-pointer hover:bg-base-200">
<a href="<%= settings_webhook_event_path(webhook_url.id, webhook_event.uuid) %>" data-turbo-frame="drawer" class="top-0 bottom-0 left-0 right-0 absolute"></a>
<div class="min-h-12 flex flex-col md:flex-row md:items-center md:justify-between gap-2 px-3 py-2">
<div class="flex items-center gap-4">
<% if webhook_event.status == 'success' %>
<div class="btn btn-outline btn-xs btn-success bg-lime-50 gap-1">
<%= svg_icon('check', class: 'w-4 h-4 shrink-0 stroke-2') %>
<%= webhook_event.webhook_attempts.max_by(&:id)&.response_status_code if local_assigns[:with_status] %>
</div>
<% elsif webhook_event.status == 'pending' %>
<div class="btn btn-outline btn-xs btn-info bg-blue-50 gap-1">
<%= svg_icon('clock', class: 'w-4 h-4 shrink-0 stroke-2') %>
<%= webhook_event.webhook_attempts.max_by(&:id)&.response_status_code if local_assigns[:with_status] %>
</div>
<% elsif webhook_event.status == 'error' %>
<div class="btn btn-outline btn-xs btn-error bg-red-50 gap-1">
<%= svg_icon('x', class: 'w-4 h-4 shrink-0') %>
<%= webhook_event.webhook_attempts.max_by(&:id)&.response_status_code if local_assigns[:with_status] %>
</div>
<% end %>
<div><%= webhook_event.event_type %></div>
</div>
<div class="flex items-center gap-3">
<%= button_to button_title(title: t('resend'), disabled_with: t('awaiting'), icon: svg_icon('rotate', class: 'w-4 h-4'), icon_disabled: svg_icon('loader', class: 'w-4 h-4 animate-spin')), resend_settings_webhook_event_path(webhook_url.id, webhook_event.uuid), form: { id: button_uuid = SecureRandom.uuid }, params: { button_id: button_uuid }, class: 'btn btn-neutral btn-xs h-2 text-white relative z-[1] hidden md:group-hover:inline-block', data: { turbo_frame: :drawer }, method: :post, onclick: "[this.form.requestSubmit(), this.disabled = true, this.classList.remove('hidden')]" %>
<span><%= l(webhook_event.created_at, locale: current_account.locale, format: :short) %></span>
</div>
</div>
</div>
+14
View File
@@ -0,0 +1,14 @@
<%= render 'shared/turbo_drawer', title: @webhook_event.event_type, close_after_submit: false do %>
<div class="relative px-4 py-4">
<%= render 'drawer_events', webhook_url: @webhook_url, webhook_event: @webhook_event %>
<% if @data %>
<div class="mockup-code overflow-hidden relative pb-0 mt-6">
<% response = JSON.pretty_generate({ event_type: @webhook_event.event_type, timestamp: @webhook_event.created_at.as_json, data: @data }) %>
<span class="top-0 right-0 absolute">
<%= render 'shared/clipboard_copy', icon: 'copy', text: response, class: 'btn btn-ghost text-white', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
</span>
<pre class="before:!m-0 pl-4 pb-4"><code class="overflow-hidden text-sm w-full"><%== HighlightCode.call(response, 'JSON', theme: 'base16.dark') %></code></pre>
</div>
<% end %>
</div>
<% end %>
+44 -3
View File
@@ -79,8 +79,49 @@
<% end %>
</div>
</div>
<% submitter = current_account.submitters.where.not(completed_at: nil).order(:id).last %>
<% if submitter %>
<% if @webhook_events.present? || params[:status].present? %>
<div class="mt-6">
<h2 id="log" class="text-3xl font-bold"><%= t('events_log') %></h2>
<div class="tabs border-b mt-4">
<%= link_to t('all'), url_for(params.to_unsafe_h.except(:status)), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status].blank? ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
<%= link_to t('successed'), url_for(params.to_unsafe_h.merge(status: 'success')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status] == 'success' ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
<%= link_to t('failed'), url_for(params.to_unsafe_h.merge(status: 'error')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status] == 'error' ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
</div>
<% if @webhook_events.present? %>
<div class="divide-y divide-base-300 rounded-lg">
<%= render partial: 'webhook_events/event_row', collection: @webhook_events, as: :webhook_event, locals: { webhook_url: @webhook_url } %>
</div>
<% else %>
<div class="text-center py-4">
<%= t('there_are_no_events') %>
</div>
<% end %>
<% if @pagy.pages > 1 %>
<div class="flex my-4 justify-center md:justify-between">
<div class="hidden md:block text-sm">
<%= "#{@pagy.from}-#{@pagy.to} events" %>
</div>
<div class="flex items-center space-x-1.5">
<div class="join">
<% if @pagy.prev %>
<%= link_to '«', url_for(page: @pagy.prev, anchor: 'log'), class: 'join-item btn min-h-full h-10' %>
<% else %>
<span class="join-item btn btn-disabled !bg-base-200 min-h-full h-10">«</span>
<% end %>
<span class="join-item btn font-medium uppercase min-h-full h-10">
<%= "Page #{@pagy.page}" %>
</span>
<% if @pagy.next %>
<%= link_to '»', url_for(page: @pagy.next, anchor: 'log'), class: 'join-item btn min-h-full h-10' %>
<% else %>
<span class="join-item btn btn-disabled !bg-base-200 min-h-full h-10">»</span>
<% end %>
</div>
</div>
</div>
<% end %>
</div>
<% elsif (submitter = current_account.submitters.where.not(completed_at: nil).order(:id).last) && can?(:read, submitter) %>
<div class="space-y-4 mt-4">
<div class="collapse collapse-open bg-base-200 px-1">
<div class="p-4 text-xl font-medium">
@@ -98,7 +139,7 @@
<span class="top-0 right-0 absolute">
<%= render 'shared/clipboard_copy', icon: 'copy', text: code = JSON.pretty_generate({ event_type: 'form.completed', timestamp: Time.current.iso8601, data: Submitters::SerializeForWebhook.call(submitter) }).gsub(/^/, ' ').sub(/^\s+/, ''), class: 'btn btn-ghost text-white', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
</span>
<pre><code class="overflow-hidden w-full"><%= code %></code></pre>
<pre><code class="overflow-hidden w-full"><%== HighlightCode.call(code, 'JSON', theme: 'base16.dark') %></code></pre>
</div>
</div>
</div>
+5 -1
View File
@@ -1,7 +1,7 @@
{
"ignored_warnings": [
{
"fingerprint": "25f4ce5fee1e1180fa1919dc4ee78db3ab3457a956e4679503aa745771a43836",
"fingerprint": "bbd1bdad94998e53a48921859065e06cd1595502d4dc40362afcaa90307b591a",
"note": "Permitted parameters are necessary for creating submitters via API"
},
{
@@ -19,6 +19,10 @@
{
"fingerprint": "5f52190d03ee922bba9792012d8fcbeb7d4736006bb899b3be9cc10d679e0af1",
"note": "Safe Param"
},
{
"fingerprint": "dbbfb4a4ace7f43d8247cbb44afa8b628e005e6194ca5552e029b200f725a2d5",
"message": "Unescaped find_by!(uuid: params[:id]) is not risky"
}
]
}
+21
View File
@@ -0,0 +1,21 @@
# frozen_string_literal: true
module Rouge
autoload :InheritableHash, 'rouge/util'
autoload :Token, 'rouge/token'
autoload :Lexer, 'rouge/lexer'
autoload :RegexLexer, 'rouge/regex_lexer'
module Lexers
autoload :JSON, 'rouge/lexers/json'
end
autoload :Formatter, 'rouge/formatter'
module Formatters
autoload :HTML, 'rouge/formatters/html'
autoload :HTMLInline, 'rouge/formatters/html_inline'
end
autoload :Theme, 'rouge/theme'
end
+178 -10
View File
@@ -22,6 +22,7 @@ en: &en
hi_there: Hi there
thanks: Thanks
private: Private
default_parties: Default parties
authenticate_embedded_form_preview_with_token: Authenticate embedded form preview with token
stripe_integration: Stripe Integration
require_all_recipients: Require all recipients
@@ -115,6 +116,11 @@ en: &en
role: Role
reason: Reason
provide_your_email_to_start: Provide your email to start
provide_your_email: Provide your email
provide_your_name_to_start: Provide your name to start
provide_your_name: Provide your name
provide_your_phone_in_international_format_to_start: Provide your phone in international format to start
provide_your_phone_in_international_format: Provide your phone in international format
start: Start
enforce_recipients_order: Enforce recipients order
starting: Starting
@@ -752,6 +758,17 @@ en: &en
enable_shared_link: Enable shared link
share_link_is_currently_disabled: Share link is currently disabled
select_data_residency: Select data residency
account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: '%{account_name} has invited you to fill and sign documents online effortlessly with a secure, fast, and user-friendly digital document signing solution.'
review_or_download_completed_documents_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: Review or download completed documents. Fill and sign documents online effortlessly with a secure, fast, and user-friendly digital document signing solution.
link_form_fields: Link form fields
at_least_one_field_must_be_displayed_in_the_form: At least one field must be displayed in the form.
this_template_has_multiple_parties_which_prevents_the_use_of_a_sharing_link: This template has multiple parties, which prevents the use of a shared link as it's unclear which party is responsible for specific fields. To resolve this, define the default party details.
events_log: Events Log
successed: Successed
failed: Failed
there_are_no_events: There are no events
resend: Resend
next_attempt_in_time_in_words: Next attempt in %{time_in_words}
submission_sources:
api: API
bulk: Bulk Send
@@ -852,8 +869,12 @@ en: &en
items:
range_with_total: "%{from}-%{to} of %{count} items"
range_without_total: "%{from}-%{to} items"
events:
range_with_total: "%{from}-%{to} of %{count} events"
range_without_total: "%{from}-%{to} events"
es: &es
default_parties: Partes predeterminadas
authenticate_embedded_form_preview_with_token: Autenticar vista previa del formulario incrustado con token
require_all_recipients: Requerir a todos los destinatarios
stripe_integration: Integración con Stripe
@@ -951,6 +972,11 @@ es: &es
role: Rol
reason: Razón
provide_your_email_to_start: Proporciona tu correo electrónico para comenzar
provide_your_email: Proporciona tu correo electrónico
provide_your_name_to_start: Proporciona tu nombre para comenzar
provide_your_name: Proporciona tu nombre
provide_your_phone_in_international_format_to_start: Proporciona tu teléfono en formato internacional para comenzar
provide_your_phone_in_international_format: Proporciona tu teléfono en formato internacional
start: Comenzar
starting: Comenzando
form_has_been_deleted_by_html: 'El formulario ha sido eliminado por <span class="font-semibold">%{name}</span>.'
@@ -1587,6 +1613,17 @@ es: &es
enable_shared_link: Habilitar enlace compartido
share_link_is_currently_disabled: El enlace compartido está deshabilitado actualmente
select_data_residency: Seleccionar ubicación de datos
account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: '%{account_name} te ha invitado a completar y firmar documentos en línea fácilmente con una solución de firma digital segura, rápida y fácil de usar.'
review_or_download_completed_documents_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: Revisa o descarga los documentos completados. Completa y firma documentos en línea fácilmente con una solución de firma digital segura, rápida y fácil de usar.
link_form_fields: Vincular campos del formulario
at_least_one_field_must_be_displayed_in_the_form: Al menos un campo debe mostrarse en el formulario.
this_template_has_multiple_parties_which_prevents_the_use_of_a_sharing_link: Esta plantilla tiene varias partes, lo que impide el uso de un enlace compartido porque no está claro qué parte es responsable de campos específicos. Para resolverlo, define los detalles predeterminados de la parte.
events_log: Registro de eventos
successed: Exitoso
failed: Fallido
there_are_no_events: No hay eventos
resend: Reenviar
next_attempt_in_time_in_words: Próximo intento en %{time_in_words}
submission_sources:
api: API
bulk: Envío masivo
@@ -1687,8 +1724,12 @@ es: &es
items:
range_with_total: "%{from}-%{to} de %{count} elementos"
range_without_total: "%{from}-%{to} elementos"
events:
range_with_total: "%{from}-%{to} de %{count} eventos"
range_without_total: "%{from}-%{to} eventos"
it: &it
default_parties: Parti predefiniti
authenticate_embedded_form_preview_with_token: "Autentica l'anteprima del modulo incorporato con il token"
require_all_recipients: Richiedi tutti i destinatari
stripe_integration: Integrazione Stripe
@@ -1785,6 +1826,11 @@ it: &it
role: Ruolo
reason: Ragione
provide_your_email_to_start: Fornisci la tua email per iniziare
provide_your_email: Fornisci la tua email
provide_your_name_to_start: Fornisci il tuo nome per iniziare
provide_your_name: Fornisci il tuo nome
provide_your_phone_in_international_format_to_start: Fornisci il tuo telefono in formato internazionale per iniziare
provide_your_phone_in_international_format: Fornisci il tuo telefono in formato internazionale
start: Inizia
starting: Iniziando
form_has_been_deleted_by_html: 'Il modulo è stato eliminato da <span class="font-semibold">%{name}</span>.'
@@ -2420,6 +2466,17 @@ it: &it
enable_shared_link: Abilita link condiviso
share_link_is_currently_disabled: Il link condiviso è attualmente disabilitato
select_data_residency: Seleziona la residenza dei dati
account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: '%{account_name} ti ha invitato a compilare e firmare documenti online con facilità utilizzando una soluzione di firma digitale sicura, veloce e facile da usare.'
review_or_download_completed_documents_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: Rivedi o scarica i documenti completati. Compila e firma documenti online facilmente con una soluzione di firma digitale sicura, veloce e facile da usare.
link_form_fields: Collega i campi del modulo
at_least_one_field_must_be_displayed_in_the_form: Almeno un campo deve essere visualizzato nel modulo.
this_template_has_multiple_parties_which_prevents_the_use_of_a_sharing_link: Questo modello ha più parti, il che impedisce l’uso di un link di condivisione perché non è chiaro quale parte sia responsabile di campi specifici. Per risolvere, definisci i dettagli predefiniti della parte.
events_log: Registro eventi
successed: Riuscito
failed: Fallito
there_are_no_events: Nessun evento
resend: Invia di nuovo
next_attempt_in_time_in_words: Prossimo tentativo tra %{time_in_words}
submission_sources:
api: API
bulk: Invio massivo
@@ -2520,8 +2577,12 @@ it: &it
items:
range_with_total: "%{from}-%{to} di %{count} elementi"
range_without_total: "%{from}-%{to} elementi"
events:
range_with_total: "%{from}-%{to} di %{count} eventi"
range_without_total: "%{from}-%{to} eventi"
fr: &fr
default_parties: Parties par défaut
authenticate_embedded_form_preview_with_token: Authentifier l’aperçu du formulaire intégré avec un jeton
require_all_recipients: Exiger tous les destinataires
stripe_integration: Intégration Stripe
@@ -2618,7 +2679,12 @@ fr: &fr
digitally_signed_by: Signé numériquement par
role: Rôle
reason: Raison
provide_your_email_to_start: Entrez votre adresse email pour commencer
provide_your_email_to_start: Fournissez votre e-mail pour commencer
provide_your_email: Fournissez votre e-mail
provide_your_name_to_start: Fournissez votre nom pour commencer
provide_your_name: Fournissez votre nom
provide_your_phone_in_international_format_to_start: Fournissez votre téléphone au format international pour commencer
provide_your_phone_in_international_format: Fournissez votre téléphone au format international
start: Démarrer
starting: Démarrage
form_has_been_deleted_by_html: 'Le formulaire a été supprimé par <span class="font-semibold">%{name}</span>.'
@@ -3256,6 +3322,17 @@ fr: &fr
enable_shared_link: Activer le lien de partage
share_link_is_currently_disabled: Le lien de partage est actuellement désactivé
select_data_residency: Sélectionner la résidence des données
account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: '%{account_name} vous a invité à remplir et signer des documents en ligne facilement avec une solution de signature numérique sécurisée, rapide et conviviale.'
review_or_download_completed_documents_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: Consultez ou téléchargez les documents complétés. Remplissez et signez des documents en ligne facilement avec une solution de signature numérique sécurisée, rapide et conviviale.
link_form_fields: Lier les champs du formulaire
at_least_one_field_must_be_displayed_in_the_form: Au moins un champ doit être affiché dans le formulaire.
this_template_has_multiple_parties_which_prevents_the_use_of_a_sharing_link: Ce modèle contient plusieurs parties, ce qui empêche l’utilisation d’un lien de partage car il n’est pas clair quelle partie est responsable de certains champs. Pour résoudre cela, définissez les détails de la partie par défaut.
events_log: Journal des événements
successed: Réussi
failed: Échoué
there_are_no_events: Aucun événement
resend: Renvoyer
next_attempt_in_time_in_words: Nouvelle tentative dans %{time_in_words}
submission_sources:
api: API
bulk: Envoi en masse
@@ -3356,8 +3433,12 @@ fr: &fr
items:
range_with_total: "%{from} à %{to} sur %{count} éléments"
range_without_total: "%{from} à %{to} éléments"
events:
range_with_total: "%{from} à %{to} sur %{count} événements"
range_without_total: "%{from} à %{to} événements"
pt: &pt
default_parties: Partes padrão
authenticate_embedded_form_preview_with_token: Autenticar visualização incorporada do formulário com token
require_all_recipients: Exigir todos os destinatários
stripe_integration: Integração com Stripe
@@ -3454,7 +3535,12 @@ pt: &pt
digitally_signed_by: Assinado digitalmente por
role: Função
reason: Motivo
provide_your_email_to_start: Forneça o seu email para começar
provide_your_email_to_start: Informe seu e-mail para começar
provide_your_email: Informe seu e-mail
provide_your_name_to_start: Informe seu nome para começar
provide_your_name: Informe seu nome
provide_your_phone_in_international_format_to_start: Informe seu telefone no formato internacional para começar
provide_your_phone_in_international_format: Informe seu telefone no formato internacional
start: Iniciar
starting: Iniciando
form_has_been_deleted_by_html: 'O formulário foi eliminado por <span class="font-semibold">%{name}</span>.'
@@ -4091,6 +4177,17 @@ pt: &pt
enable_shared_link: Ativar link compartilhado
share_link_is_currently_disabled: O link compartilhado está desativado no momento
select_data_residency: Selecionar local dos dados
account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: '%{account_name} convidou você para preencher e assinar documentos online com facilidade usando uma solução de assinatura digital segura, rápida e fácil de usar.'
review_or_download_completed_documents_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: Reveja ou baixe os documentos concluídos. Preencha e assine documentos online com facilidade usando uma solução de assinatura digital segura, rápida e fácil de usar.
link_form_fields: Vincular campos do formulário
at_least_one_field_must_be_displayed_in_the_form: Pelo menos um campo deve ser exibido no formulário.
this_template_has_multiple_parties_which_prevents_the_use_of_a_sharing_link: Este modelo tem várias partes, o que impede o uso de um link de compartilhamento, pois não está claro qual parte é responsável por campos específicos. Para resolver isso, defina os detalhes padrão da parte.
events_log: Registro de eventos
successed: Sucesso
failed: Falhou
there_are_no_events: Nenhum evento
resend: Reenviar
next_attempt_in_time_in_words: Próxima tentativa em %{time_in_words}
submission_sources:
api: API
bulk: Envio em massa
@@ -4192,8 +4289,12 @@ pt: &pt
items:
range_with_total: "%{from}-%{to} de %{count} itens"
range_without_total: "%{from}-%{to} itens"
events:
range_with_total: "%{from}-%{to} de %{count} eventos"
range_without_total: "%{from}-%{to} eventos"
de: &de
default_parties: Standardparteien
authenticate_embedded_form_preview_with_token: Authentifizieren Sie die eingebettete Formularvorschau mit Token
require_all_recipients: Alle Empfänger erforderlich
stripe_integration: Stripe-Integration
@@ -4290,7 +4391,12 @@ de: &de
digitally_signed_by: Digital signiert von
role: Rolle
reason: Grund
provide_your_email_to_start: Gib deine E-Mail-Adresse ein, um zu starten
provide_your_email_to_start: Geben Sie Ihre E-Mail-Adresse ein, um zu starten
provide_your_email: Geben Sie Ihre E-Mail-Adresse ein
provide_your_name_to_start: Geben Sie Ihren Namen ein, um zu starten
provide_your_name: Geben Sie Ihren Namen ein
provide_your_phone_in_international_format_to_start: Geben Sie Ihre Telefonnummer im internationalen Format ein, um zu starten
provide_your_phone_in_international_format: Geben Sie Ihre Telefonnummer im internationalen Format ein
start: Starten
starting: Starten
form_has_been_deleted_by_html: 'Das Formular wurde von <span class="font-semibold">%{name}</span> gelöscht.'
@@ -4927,6 +5033,17 @@ de: &de
enable_shared_link: 'Freigabelink aktivieren'
share_link_is_currently_disabled: 'Freigabelink ist derzeit deaktiviert'
select_data_residency: Datenstandort auswählen
account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: '%{account_name} hat Sie eingeladen, Dokumente mühelos online mit einer sicheren, schnellen und benutzerfreundlichen digitalen Signaturlösung auszufüllen und zu unterschreiben.'
review_or_download_completed_documents_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: Überprüfen oder laden Sie abgeschlossene Dokumente herunter. Füllen und unterschreiben Sie Dokumente mühelos online mit einer sicheren, schnellen und benutzerfreundlichen digitalen Signaturlösung.
link_form_fields: Formularfelder verknüpfen
at_least_one_field_must_be_displayed_in_the_form: Mindestens ein Feld muss im Formular angezeigt werden.
this_template_has_multiple_parties_which_prevents_the_use_of_a_sharing_link: Diese Vorlage enthält mehrere Parteien, was die Verwendung eines Freigabelinks verhindert, da unklar ist, welche Partei für bestimmte Felder verantwortlich ist. Um dies zu beheben, definieren Sie die Standardparteidetails.
events_log: Ereignisprotokoll
successed: Erfolgreich
failed: Fehlgeschlagen
there_are_no_events: Keine Ereignisse vorhanden
resend: Erneut senden
next_attempt_in_time_in_words: Nächster Versuch in %{time_in_words}
submission_sources:
api: API
bulk: Massenversand
@@ -5027,6 +5144,9 @@ de: &de
items:
range_with_total: "%{from}-%{to} von %{count} Elementen"
range_without_total: "%{from}-%{to} Elemente"
events:
range_with_total: "%{from}-%{to} von %{count} Ereignissen"
range_without_total: "%{from}-%{to} Ereignisse"
pl:
require_phone_2fa_to_open: Wymagaj uwierzytelniania telefonicznego 2FA do otwarcia
@@ -5051,7 +5171,13 @@ pl:
digitally_signed_by: Podpis cyfrowy przez
form_expired_at_html: 'Formularz wygasł o <span class="font-semibold">%{time}</span>'
role: Rola
provide_your_email_to_start: Podaj swój adres email, aby rozpocząć
provide_your_email_to_start: Podaj swój e-mail, aby rozpocząć
provide_your_email: Podaj swój e-mail
provide_your_name_to_start: Podaj swoje imię, aby rozpocząć
provide_your_name: Podaj swoje imię
provide_your_phone_in_international_format_to_start: Podaj numer telefonu w formacie międzynarodowym, aby rozpocząć
provide_your_phone_in_international_format: Podaj numer telefonu w formacie międzynarodowym
use_international_format_1xxx_: 'Użyj formatu międzynarodowego: +1xxx...'
start: Rozpocznij
reason: Powód
starting: Rozpoczynanie
@@ -5124,7 +5250,13 @@ uk:
digitally_signed_by: Цифровий підпис від
verification_code_code: 'Код підтвердження: %{code}'
role: Роль
provide_your_email_to_start: Введіть свій email, щоб почати
provide_your_email_to_start: Введіть свій емейл, щоб почати
provide_your_email: Введіть свій емейл
provide_your_name_to_start: Введіть своє ім’я, щоб почати
provide_your_name: Введіть своє ім’я
provide_your_phone_in_international_format_to_start: Введіть номер телефону у міжнародному форматі, щоб почати
provide_your_phone_in_international_format: Введіть номер телефону у міжнародному форматі
use_international_format_1xxx_: 'Використовуйте міжнародний формат: +1xxx...'
form_expired_at_html: 'Строк подачі завершився о <span class="font-semibold">%{time}</span>'
start: Почати
reason: Причина
@@ -5198,7 +5330,13 @@ cs:
digitally_signed_by: Digitálně podepsáno uživatelem
verification_code_code: 'Ověřovací kód: %{code}'
role: Role
provide_your_email_to_start: Zadejte svůj email pro zahájení
provide_your_email_to_start: Zadejte svůj e-mail pro zahájení
provide_your_email: Zadejte svůj e-mail
provide_your_name_to_start: Zadejte své jméno pro zahájení
provide_your_name: Zadejte své jméno
provide_your_phone_in_international_format_to_start: Zadejte své telefonní číslo v mezinárodním formátu pro zahájení
provide_your_phone_in_international_format: Zadejte své telefonní číslo v mezinárodním formátu
use_international_format_1xxx_: 'Použijte mezinárodní formát: +1xxx...'
reason: Důvod
form_expired_at_html: 'Formulář vypršel <span class="font-semibold">%{time}</span>'
start: Zahájit
@@ -5273,7 +5411,13 @@ he:
role: תפקיד
reason: סיבה
verification_code_code: 'קוד אימות: %{code}'
provide_your_email_to_start: ספק את כתובת הדוא"ל שלך כדי להתחיל
provide_your_email_to_start: הזן את האימייל שלך כדי להתחיל
provide_your_email: הזן את האימייל שלך
provide_your_name_to_start: הזן את שמך כדי להתחיל
provide_your_name: הזן את שמך
provide_your_phone_in_international_format_to_start: הזן את מספר הטלפון שלך בפורמט בינלאומי כדי להתחיל
provide_your_phone_in_international_format: הזן את מספר הטלפון שלך בפורמט בינלאומי
use_international_format_1xxx_: 'השתמש בפורמט בינלאומי: +1xxx...'
start: התחל
starting: מתחיל
form_expired_at_html: 'הטופס פג תוקף ב- <span class="font-semibold">%{time}</span>'
@@ -5346,7 +5490,13 @@ nl:
digitally_signed_by: Digitaal ondertekend door
role: Rol
verification_code_code: 'Verificatiecode: %{code}'
provide_your_email_to_start: Geef uw e-mailadres om te beginnen
provide_your_email_to_start: Voer je e-mailadres in om te beginnen
provide_your_email: Voer je e-mailadres in
provide_your_name_to_start: Voer je naam in om te beginnen
provide_your_name: Voer je naam in
provide_your_phone_in_international_format_to_start: Voer je telefoonnummer in internationaal formaat in om te beginnen
provide_your_phone_in_international_format: Voer je telefoonnummer in internationaal formaat in
use_international_format_1xxx_: 'Gebruik het internationale formaat: +1xxx...'
start: Start
reason: Reden
form_expired_at_html: 'Formulier is verlopen op <span class="font-semibold">%{time}</span>'
@@ -5419,7 +5569,13 @@ ar:
email: البريد الإلكتروني
digitally_signed_by: تم التوقيع الرقمي بواسطة
role: الدور
provide_your_email_to_start: قدم بريدك الإلكتروني للبدء
provide_your_email_to_start: أدخل بريدك الإلكتروني للبدء
provide_your_email: أدخل بريدك الإلكتروني
provide_your_name_to_start: أدخل اسمك للبدء
provide_your_name: أدخل اسمك
provide_your_phone_in_international_format_to_start: أدخل رقم هاتفك بصيغة دولية للبدء
provide_your_phone_in_international_format: أدخل رقم هاتفك بصيغة دولية
use_international_format_1xxx_: 'استخدم التنسيق الدولي: +1xxx...'
start: بدء
starting: بداية
verification_code_code: 'رمز التحقق: %{code}'
@@ -5493,7 +5649,13 @@ ko:
email: 이메일
digitally_signed_by: 디지털로 서명됨
role: 역할
provide_your_email_to_start: 시작하려면 이메일을 제공하세요
provide_your_email_to_start: 시작하려면 이메일을 입력하세요
provide_your_email: 이메일을 입력하세요
provide_your_name_to_start: 시작하려면 이름을 입력하세요
provide_your_name: 이름을 입력하세요
provide_your_phone_in_international_format_to_start: 시작하려면 국제 형식의 전화번호를 입력하세요
provide_your_phone_in_international_format: 국제 형식의 전화번호를 입력하세요
use_international_format_1xxx_: '국제 형식을 사용하세요: +1xxx...'
start: 시작
reason: 이유
starting: 시작 중
@@ -5568,6 +5730,12 @@ ja:
digitally_signed_by: 電子署名者
role: 役割
provide_your_email_to_start: 開始するにはメールアドレスを入力してください
provide_your_email: メールアドレスを入力してください
provide_your_name_to_start: 開始するには名前を入力してください
provide_your_name: 名前を入力してください
provide_your_phone_in_international_format_to_start: 開始するには国際形式の電話番号を入力してください
provide_your_phone_in_international_format: 国際形式の電話番号を入力してください
use_international_format_1xxx_: '国際形式を使用してください:+1xxx...'
start: 開始
reason: 理由
starting: 開始中
+5
View File
@@ -179,6 +179,11 @@ Rails.application.routes.draw do
resources :api, only: %i[index create], controller: 'api_settings'
resources :webhooks, only: %i[index show new create update destroy], controller: 'webhook_settings' do
post :resend
resources :events, only: %i[show], controller: 'webhook_events' do
post :resend, on: :member
post :refresh, on: :member
end
end
resource :account, only: %i[show update destroy]
resources :profile, only: %i[index] do
@@ -0,0 +1,30 @@
# frozen_string_literal: true
class CreateWebhookEventsAndAttempts < ActiveRecord::Migration[8.0]
def change
create_table :webhook_events do |t|
t.string :uuid, null: false
t.bigint :webhook_url_id, null: false
t.bigint :account_id, null: false
t.bigint :record_id, null: false
t.string :record_type, null: false
t.string :event_type, null: false
t.string :status, null: false
t.index %i[uuid webhook_url_id], unique: true
t.index %i[webhook_url_id id]
t.index %i[webhook_url_id id], where: "status = 'error'", name: 'index_webhook_events_error'
t.timestamps
end
create_table :webhook_attempts do |t|
t.bigint :webhook_event_id, null: false, index: true
t.text :response_body
t.integer :response_status_code, null: false
t.integer :attempt, null: false
t.timestamps
end
end
end
+26 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_06_18_085322) do
ActiveRecord::Schema[8.0].define(version: 2025_06_27_130628) do
# These are extensions that must be enabled in order to support this database
enable_extension "btree_gin"
enable_extension "plpgsql"
@@ -437,6 +437,31 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_18_085322) do
t.index ["uuid"], name: "index_users_on_uuid", unique: true
end
create_table "webhook_attempts", force: :cascade do |t|
t.bigint "webhook_event_id", null: false
t.text "response_body"
t.integer "response_status_code", null: false
t.integer "attempt", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["webhook_event_id"], name: "index_webhook_attempts_on_webhook_event_id"
end
create_table "webhook_events", force: :cascade do |t|
t.string "uuid", null: false
t.bigint "webhook_url_id", null: false
t.bigint "account_id", null: false
t.bigint "record_id", null: false
t.string "record_type", null: false
t.string "event_type", null: false
t.string "status", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["uuid", "webhook_url_id"], name: "index_webhook_events_on_uuid_and_webhook_url_id", unique: true
t.index ["webhook_url_id", "id"], name: "index_webhook_events_error", where: "((status)::text = 'error'::text)"
t.index ["webhook_url_id", "id"], name: "index_webhook_events_on_webhook_url_id_and_id"
end
create_table "webhook_urls", force: :cascade do |t|
t.bigint "account_id", null: false
t.text "url", null: false
+1203
View File
File diff suppressed because it is too large. Load diff
+1272
View File
File diff suppressed because it is too large. Load diff
+1200
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1290
View File
File diff suppressed because it is too large. Load diff
+1266
View File
File diff suppressed because it is too large. Load diff
+1275
View File
File diff suppressed because it is too large. Load diff
+1275
View File
File diff suppressed because it is too large. Load diff
+1200
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+21 -1
View File
@@ -132,7 +132,27 @@ const token = jwt.sign({
"fieldTypes": {
"type": "array",
"required": false,
"description": "Field type names to be used in the form builder. All field types are used by default."
"description": "Field type names to be used in the form builder. All field types are used by default.",
"enum": [
"heading",
"text",
"signature",
"initials",
"date",
"datenow",
"number",
"image",
"checkbox",
"multiple",
"file",
"radio",
"select",
"cells",
"stamp",
"payment",
"phone",
"verification"
]
},
"drawFieldType": {
"type": "string",
+21 -1
View File
@@ -317,7 +317,27 @@
"type": "string",
"required": false,
"description": "Comma separated field type names to be used in the form builder. All field types are used by default.",
"example": "text,date"
"example": "text,date",
"enum": [
"heading",
"text",
"signature",
"initials",
"date",
"datenow",
"number",
"image",
"checkbox",
"multiple",
"file",
"radio",
"select",
"cells",
"stamp",
"payment",
"phone",
"verification"
]
},
"data-draw-field-type": {
"type": "string",
+21 -1
View File
@@ -123,7 +123,27 @@ const token = jwt.sign({
"fieldTypes": {
"type": "array",
"required": false,
"description": "Field type names to be used in the form builder. All field types are used by default."
"description": "Field type names to be used in the form builder. All field types are used by default.",
"enum": [
"heading",
"text",
"signature",
"initials",
"date",
"datenow",
"number",
"image",
"checkbox",
"multiple",
"file",
"radio",
"select",
"cells",
"stamp",
"payment",
"phone",
"verification"
]
},
"drawFieldType": {
"type": "string",
+21 -1
View File
@@ -144,7 +144,27 @@ const token = jwt.sign({
"field-types": {
"type": "array",
"required": false,
"description": "Field type names to be used in the form builder. All field types are used by default."
"description": "Field type names to be used in the form builder. All field types are used by default.",
"enum": [
"heading",
"text",
"signature",
"initials",
"date",
"datenow",
"number",
"image",
"checkbox",
"multiple",
"file",
"radio",
"select",
"cells",
"stamp",
"payment",
"phone",
"verification"
]
},
"draw-field-type": {
"type": "string",
+40 -11
View File
@@ -48,6 +48,41 @@ export class AppComponent {}
"description": "The role name or title of the signer.",
"example": "First Party"
},
"token": {
"type": "string",
"doc_type": "object",
"description": "JSON Web Token (JWT HS256) with a payload signed using the API key. <b>JWT can be generated only on the backend.</b>.",
"required": false,
"properties": {
"slug": {
"type": "string",
"required": true,
"description": "Template or Submitter slug. When Submitter slug is used no need to pass additional email param."
},
"email": {
"type": "string",
"required": false,
"description": "Email address of the signer. Additional email form step will be displayed if the email attribute is not specified with Template slug."
},
"external_id": {
"type": "string",
"required": false,
"description": "Your application-specific unique string key to identify signer within your app."
},
"preview": {
"type": "boolean",
"required": false,
"default": false,
"description": "Show form in preview mode without ability to submit it."
}
}
},
"preview": {
"type": "boolean",
"required": false,
"default": false,
"description": "Show form in preview mode without ability to submit it. Completed documents embedded in preview mode require `data-token` authentication."
},
"expand": {
"type": "boolean",
"required": false,
@@ -66,11 +101,6 @@ export class AppComponent {}
"default": false,
"description": "Order form fields based on their position on the pages."
},
"externalId": {
"type": "string",
"required": false,
"description": "Your application-specific unique string key to identify signer within your app."
},
"logo": {
"type": "string",
"required": false,
@@ -87,12 +117,6 @@ export class AppComponent {}
"default": "{}",
"description": "Object that contains i18n keys to replace the default UI text with custom values. See <a href=\"https://github.com/docusealco/docuseal/blob/master/app/javascript/submission_form/i18n.js\" class=\"link\" target=\"_blank\" rel=\"nofollow\">submission_form/i18n.js</a> for available i18n keys."
},
"preview": {
"type": "boolean",
"required": false,
"default": false,
"description": "Show form in preview mode without ability to submit it."
},
"goToLast": {
"type": "boolean",
"required": false,
@@ -242,6 +266,11 @@ export class AppComponent {}
"description": "Pre-assigned values for form fields.",
"example": "{ 'First Name': 'Jon', 'Last Name': 'Doe' }"
},
"externalId": {
"type": "string",
"required": false,
"description": "Your application-specific unique string key to identify signer within your app."
},
"metadata": {
"type": "object",
"required": false,
+35 -6
View File
@@ -42,6 +42,41 @@
"description": "The role name or title of the signer.",
"example": "First Party"
},
"data-token": {
"type": "string",
"doc_type": "object",
"description": "JSON Web Token (JWT HS256) with a payload signed using the API key. <b>JWT can be generated only on the backend.</b>.",
"required": false,
"properties": {
"slug": {
"type": "string",
"required": true,
"description": "Template or Submitter slug. When Submitter slug is used no need to pass additional email param."
},
"email": {
"type": "string",
"required": false,
"description": "Email address of the signer. Additional email form step will be displayed if the email attribute is not specified with Template slug."
},
"external_id": {
"type": "string",
"required": false,
"description": "Your application-specific unique string key to identify signer within your app."
},
"preview": {
"type": "boolean",
"required": false,
"default": false,
"description": "Show form in preview mode without ability to submit it."
}
}
},
"data-preview": {
"type": "boolean",
"required": false,
"default": false,
"description": "Show form in preview mode without ability to submit it. Completed documents embedded in preview mode require `data-token` authentication."
},
"data-expand": {
"type": "boolean",
"required": false,
@@ -60,12 +95,6 @@
"default": false,
"description": "Order form fields based on their position on the pages."
},
"data-preview": {
"type": "boolean",
"required": false,
"default": false,
"description": "Show form in preview mode without ability to submit it."
},
"data-logo": {
"type": "string",
"required": false,
+40 -11
View File
@@ -45,6 +45,41 @@ export function App() {
"description": "The role name or title of the signer.",
"example": "First Party"
},
"token": {
"type": "string",
"doc_type": "object",
"description": "JSON Web Token (JWT HS256) with a payload signed using the API key. <b>JWT can be generated only on the backend.</b>.",
"required": false,
"properties": {
"slug": {
"type": "string",
"required": true,
"description": "Template or Submitter slug. When Submitter slug is used no need to pass additional email param."
},
"email": {
"type": "string",
"required": false,
"description": "Email address of the signer. Additional email form step will be displayed if the email attribute is not specified with Template slug."
},
"external_id": {
"type": "string",
"required": false,
"description": "Your application-specific unique string key to identify signer within your app."
},
"preview": {
"type": "boolean",
"required": false,
"default": false,
"description": "Show form in preview mode without ability to submit it."
}
}
},
"preview": {
"type": "boolean",
"required": false,
"default": false,
"description": "Show form in preview mode without ability to submit it. Completed documents embedded in preview mode require `token` authentication."
},
"expand": {
"type": "boolean",
"required": false,
@@ -63,11 +98,6 @@ export function App() {
"default": false,
"description": "Order form fields based on their position on the pages."
},
"externalId": {
"type": "string",
"required": false,
"description": "Your application-specific unique string key to identify signer within your app."
},
"logo": {
"type": "string",
"required": false,
@@ -84,12 +114,6 @@ export function App() {
"default": "{}",
"description": "Object that contains i18n keys to replace the default UI text with custom values. See <a href=\"https://github.com/docusealco/docuseal/blob/master/app/javascript/submission_form/i18n.js\" class=\"link\" target=\"_blank\" rel=\"nofollow\">submission_form/i18n.js</a> for available i18n keys."
},
"preview": {
"type": "boolean",
"required": false,
"default": false,
"description": "Show form in preview mode without ability to submit it."
},
"goToLast": {
"type": "boolean",
"required": false,
@@ -239,6 +263,11 @@ export function App() {
"description": "Pre-assigned values for form fields.",
"example": "{ 'First Name': 'Jon', 'Last Name': 'Doe' }"
},
"externalId": {
"type": "string",
"required": false,
"description": "Your application-specific unique string key to identify signer within your app."
},
"metadata": {
"type": "object",
"required": false,
+37 -8
View File
@@ -54,10 +54,40 @@ export default {
"description": "The role name or title of the signer.",
"example": "First Party"
},
"external-id": {
"token": {
"type": "string",
"doc_type": "object",
"description": "JSON Web Token (JWT HS256) with a payload signed using the API key. <b>JWT can be generated only on the backend.</b>.",
"required": false,
"description": "Your application-specific unique string key to identify signer within your app."
"properties": {
"slug": {
"type": "string",
"required": true,
"description": "Template or Submitter slug. When Submitter slug is used no need to pass additional email param."
},
"email": {
"type": "string",
"required": false,
"description": "Email address of the signer. Additional email form step will be displayed if the email attribute is not specified with Template slug."
},
"external_id": {
"type": "string",
"required": false,
"description": "Your application-specific unique string key to identify signer within your app."
},
"preview": {
"type": "boolean",
"required": false,
"default": false,
"description": "Show form in preview mode without ability to submit it."
}
}
},
"preview": {
"type": "boolean",
"required": false,
"default": false,
"description": "Show form in preview mode without ability to submit it. Completed documents embedded in preview mode require `data-token` authentication."
},
"expand": {
"type": "boolean",
@@ -93,12 +123,6 @@ export default {
"default": "{}",
"description": "Object that contains i18n keys to replace the default UI text with custom values. See <a href=\"https://github.com/docusealco/docuseal/blob/master/app/javascript/submission_form/i18n.js\" class=\"link\" target=\"_blank\" rel=\"nofollow\">submission_form/i18n.js</a> for available i18n keys."
},
"preview": {
"type": "boolean",
"required": false,
"default": false,
"description": "Show form in preview mode without ability to submit it."
},
"go-to-last": {
"type": "boolean",
"required": false,
@@ -249,6 +273,11 @@ export default {
"description": "Pre-assigned values for form fields.",
"example": "{ 'First Name': 'Jon', 'Last Name': 'Doe' }"
},
"external-id": {
"type": "string",
"required": false,
"description": "Your application-specific unique string key to identify signer within your app."
},
"metadata": {
"type": "object",
"required": false,
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff. Show more