Compare commits

...
35 Commits
Author SHA1 Message Date
Alex Turchyn 0c2abf211e Merge from docusealco/wip 2024-11-11 17:44:28 +02:00
Pete Matsyburka 8ecaa4fd7a adjust custom font size 2024-11-11 14:25:07 +02:00
Pete Matsyburka 6759c26d86 fix rubocop 2024-11-10 13:11:25 +02:00
Pete Matsyburka 76f1f140ac add field color 2024-11-10 12:48:53 +02:00
Pete Matsyburka ca7ab5aab7 adjust webhooks controller 2024-11-09 11:15:19 +02:00
Alex Turchyn fd3530ac62 update Webhooks UI 2024-11-09 00:41:46 +02:00
Pete Matsyburka 4ea863592b fix form 2024-11-08 22:12:07 +02:00
Pete Matsyburka 3f87d0336d do not use same reply to as to 2024-11-08 13:02:32 +02:00
Pete Matsyburka e251482481 unique party names 2024-11-08 12:15:13 +02:00
Pete Matsyburka c79fca75f3 add complete button 2024-11-07 23:41:49 +02:00
Alex Turchyn 0b0f0b497e fix acroform select field 2024-11-06 20:30:01 +02:00
Pete Matsyburka e9034d8da7 webhook secret priority 2024-11-06 14:06:33 +02:00
Pete Matsyburka 8fbb8b8f6e do not touch updated_at 2024-11-05 22:55:03 +02:00
Alex Turchyn 4635fdd32b remove replace_timestamps helper 2024-11-05 22:34:14 +02:00
Pete Matsyburka 6dd4382446 optimize webhooks query 2024-11-04 16:19:43 +02:00
Alex Turchyn 50acff2e81 Merge from docusealco/wip 2024-11-04 16:13:29 +02:00
Pete Matsyburka cb6d24dfca add canonical urls 2024-11-04 11:37:58 +02:00
Pete Matsyburka ab4a22d8ba fix test links 2024-11-03 13:43:28 +02:00
Pete Matsyburka bc40a15de1 use com 2024-11-03 10:19:59 +02:00
Pete Matsyburka 2542f26d96 adjust webhooks 2024-11-03 09:49:30 +02:00
Alex Turchyn 84edb6dbda use webhook urls instead of encrypted configs 2024-11-02 22:12:41 +02:00
Pete Matsyburka 88776e7316 Merge from docusealco/wip 2024-11-02 14:48:21 +02:00
Pete Matsyburka f669045362 redirect com 2024-11-02 09:48:47 +02:00
Pete Matsyburka 68d3efbdba use com 2024-11-02 09:02:50 +02:00
Pete Matsyburka 22d4833496 allow to unarchive submissions 2024-11-01 21:37:02 +02:00
Pete Matsyburka ff14caa9f7 fix image 2024-11-01 19:01:51 +02:00
Pete Matsyburka 05647135bc add status to export 2024-10-31 17:22:14 +02:00
Pete Matsyburka ae76c2a4a8 disable draw when type 2024-10-31 16:43:43 +02:00
Pete Matsyburka fb2b3d44fe fix bulk 2024-10-31 00:51:21 +02:00
Pete Matsyburka 3868557efd add define submitters 2024-10-30 23:07:10 +02:00
dependabot[bot] 9bef28c8ba Bump rexml from 3.3.8 to 3.3.9 in the bundler group across 1 directory (#50) 2024-10-30 12:12:36 +02:00
Pete Matsyburka f25fd3b03d do not edit if declined 2024-10-29 17:11:44 +02:00
Pete Matsyburka d1d7571ee4 disable decline email 2024-10-29 16:44:07 +02:00
Pete Matsyburka 6a298a068c fix updated templates 2024-10-29 12:11:21 +02:00
Pete Matsyburka d4aef79b68 use tsa fallback url 2024-10-29 08:29:26 +02:00
96 changed files with 1849 additions and 385 deletions

No files matched your search

+2 -2
View File
@@ -51,10 +51,10 @@ Metrics/AbcSize:
- spec/**/* - spec/**/*
Metrics/ModuleLength: Metrics/ModuleLength:
Max: 500 Max: 1000
Metrics/ClassLength: Metrics/ClassLength:
Max: 500 Max: 1000
RSpec/NestedGroups: RSpec/NestedGroups:
Max: 6 Max: 6
+1 -1
View File
@@ -437,7 +437,7 @@ GEM
actionpack (>= 5.2) actionpack (>= 5.2)
railties (>= 5.2) railties (>= 5.2)
retriable (3.1.2) retriable (3.1.2)
rexml (3.3.8) rexml (3.3.9)
rotp (6.3.0) rotp (6.3.0)
rqrcode (2.2.0) rqrcode (2.2.0)
chunky_png (~> 1.0) chunky_png (~> 1.0)
+10 -10
View File
@@ -1,6 +1,6 @@
<h1 align="center" style="border-bottom: none"> <h1 align="center" style="border-bottom: none">
<div> <div>
<a href="https://www.docuseal.co"> <a href="https://www.docuseal.com">
<img alt="DocuSeal" src="https://github.com/docusealco/docuseal/assets/5418788/c12cd051-81cd-4402-bc3a-92f2cfdc1b06" width="80" /> <img alt="DocuSeal" src="https://github.com/docusealco/docuseal/assets/5418788/c12cd051-81cd-4402-bc3a-92f2cfdc1b06" width="80" />
<br> <br>
</a> </a>
@@ -54,11 +54,11 @@ DocuSeal is an open source platform that provides secure and efficient digital d
- Conditional fields and formulas - Conditional fields and formulas
- Bulk send with CSV, XLSX spreadsheet import - Bulk send with CSV, XLSX spreadsheet import
- SSO / SAML - SSO / SAML
- Template creation with HTML API ([Guide](https://www.docuseal.co/guides/create-pdf-document-fillable-form-with-html-api)) - Template creation with HTML API ([Guide](https://www.docuseal.com/guides/create-pdf-document-fillable-form-with-html-api))
- Template creation with PDF or DOCX and field tags API ([Guide](https://www.docuseal.co/guides/use-embedded-text-field-tags-in-the-pdf-to-create-a-fillable-form)) - Template creation with PDF or DOCX and field tags API ([Guide](https://www.docuseal.com/guides/use-embedded-text-field-tags-in-the-pdf-to-create-a-fillable-form))
- Embedded signing form ([React](https://github.com/docusealco/docuseal-react), [Vue](https://github.com/docusealco/docuseal-vue), [Angular](https://github.com/docusealco/docuseal-angular) or [JavaScript](https://www.docuseal.co/docs/embedded)) - Embedded signing form ([React](https://github.com/docusealco/docuseal-react), [Vue](https://github.com/docusealco/docuseal-vue), [Angular](https://github.com/docusealco/docuseal-angular) or [JavaScript](https://www.docuseal.com/docs/embedded))
- Embedded document form builder ([React](https://github.com/docusealco/docuseal-react), [Vue](https://github.com/docusealco/docuseal-vue), [Angular](https://github.com/docusealco/docuseal-angular) or [JavaScript](https://www.docuseal.co/docs/embedded)) - Embedded document form builder ([React](https://github.com/docusealco/docuseal-react), [Vue](https://github.com/docusealco/docuseal-vue), [Angular](https://github.com/docusealco/docuseal-angular) or [JavaScript](https://www.docuseal.com/docs/embedded))
- [Learn more](https://www.docuseal.co/pricing) - [Learn more](https://www.docuseal.com/pricing)
## Deploy ## Deploy
@@ -93,7 +93,7 @@ sudo HOST=your-domain-name.com docker compose up
At DocuSeal we have expertise and technologies to make documents creation, filling, signing and processing seamlessly integrated with your product. We specialize in working with various industries, including **Banking, Healthcare, Transport, Real Estate, eCommerce, KYC, CRM, and other software products** that require bulk document signing. By leveraging DocuSeal, we can assist in reducing the overall cost of developing and processing electronic documents while ensuring security and compliance with local electronic document laws. At DocuSeal we have expertise and technologies to make documents creation, filling, signing and processing seamlessly integrated with your product. We specialize in working with various industries, including **Banking, Healthcare, Transport, Real Estate, eCommerce, KYC, CRM, and other software products** that require bulk document signing. By leveraging DocuSeal, we can assist in reducing the overall cost of developing and processing electronic documents while ensuring security and compliance with local electronic document laws.
[Book a Meeting](https://www.docuseal.co/contact) [Book a Meeting](https://www.docuseal.com/contact)
## License ## License
@@ -102,6 +102,6 @@ Unless otherwise noted, all files © 2023 DocuSeal LLC.
## Tools ## Tools
- [Signature Maker](https://www.docuseal.co/online-signature) - [Signature Maker](https://www.docuseal.com/online-signature)
- [Sign Document Online](https://www.docuseal.co/sign-documents-online) - [Sign Document Online](https://www.docuseal.com/sign-documents-online)
- [Fill PDF Online](https://www.docuseal.co/fill-pdf) - [Fill PDF Online](https://www.docuseal.com/fill-pdf)
+10 -4
View File
@@ -67,15 +67,18 @@ module Api
submissions = create_submissions(@template, params) submissions = create_submissions(@template, params)
submissions.each do |submission| WebhookUrls.for_account_id(@template.account_id, 'submission.created').each do |webhook_url|
SendSubmissionCreatedWebhookRequestJob.perform_async({ 'submission_id' => submission.id }) submissions.each do |submission|
SendSubmissionCreatedWebhookRequestJob.perform_async('submission_id' => submission.id,
'webhook_url_id' => webhook_url.id)
end
end end
Submissions.send_signature_requests(submissions) Submissions.send_signature_requests(submissions)
submissions.each do |submission| submissions.each do |submission|
submission.submitters.each do |submitter| submission.submitters.each do |submitter|
ProcessSubmitterCompletionJob.perform_async({ 'submitter_id' => submitter.id }) if submitter.completed_at? ProcessSubmitterCompletionJob.perform_async('submitter_id' => submitter.id) if submitter.completed_at?
end end
end end
@@ -93,7 +96,10 @@ module Api
else else
@submission.update!(archived_at: Time.current) @submission.update!(archived_at: Time.current)
SendSubmissionArchivedWebhookRequestJob.perform_async('submission_id' => @submission.id) 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
end end
render json: @submission.as_json(only: %i[id archived_at]) render json: @submission.as_json(only: %i[id archived_at])
@@ -13,7 +13,10 @@ module Api
SubmissionEvents.create_with_tracking_data(submitter, 'view_form', request) SubmissionEvents.create_with_tracking_data(submitter, 'view_form', request)
SendFormViewedWebhookRequestJob.perform_async({ 'submitter_id' => submitter.id }) 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
render json: {} render json: {}
end end
+1 -1
View File
@@ -68,7 +68,7 @@ module Api
end end
if @submitter.completed_at? if @submitter.completed_at?
ProcessSubmitterCompletionJob.perform_async({ 'submitter_id' => @submitter.id }) ProcessSubmitterCompletionJob.perform_async('submitter_id' => @submitter.id)
elsif normalized_params[:send_email] || normalized_params[:send_sms] elsif normalized_params[:send_email] || normalized_params[:send_sms]
Submitters.send_signature_requests([@submitter]) Submitters.send_signature_requests([@submitter])
end end
@@ -25,7 +25,10 @@ module Api
schema_documents = Templates::CloneAttachments.call(template: cloned_template, original_template: @template) schema_documents = Templates::CloneAttachments.call(template: cloned_template, original_template: @template)
SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => cloned_template.id) 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
render json: Templates::SerializeForApi.call(cloned_template, schema_documents) render json: Templates::SerializeForApi.call(cloned_template, schema_documents)
end end
+4 -1
View File
@@ -65,7 +65,10 @@ module Api
@template.update!(template_params) @template.update!(template_params)
SendTemplateUpdatedWebhookRequestJob.perform_async('template_id' => @template.id) 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
render json: @template.as_json(only: %i[id updated_at]) render json: @template.as_json(only: %i[id updated_at])
end end
@@ -104,4 +104,10 @@ class ApplicationController < ActionController::Base
def svg_icon(icon_name, class: '') def svg_icon(icon_name, class: '')
render_to_string(partial: "icons/#{icon_name}", locals: { class: }) render_to_string(partial: "icons/#{icon_name}", locals: { class: })
end end
def maybe_redirect_com
return if request.domain != 'docuseal.co'
redirect_to request.url.gsub('.co/', '.com/'), allow_other_host: true, status: :moved_permanently
end
end end
+1 -1
View File
@@ -2,7 +2,7 @@
class ErrorsController < ActionController::Base class ErrorsController < ActionController::Base
ENTERPRISE_FEATURE_MESSAGE = ENTERPRISE_FEATURE_MESSAGE =
'This feature is available in Pro Edition: https://www.docuseal.co/pricing' 'This feature is available in Pro Edition: https://www.docuseal.com/pricing'
ENTERPRISE_PATHS = [ ENTERPRISE_PATHS = [
'/templates/html', '/templates/html',
+5 -1
View File
@@ -7,6 +7,7 @@ class StartFormController < ApplicationController
skip_authorization_check skip_authorization_check
around_action :with_browser_locale, only: %i[show completed] around_action :with_browser_locale, only: %i[show completed]
before_action :maybe_redirect_com, only: %i[show completed]
before_action :load_template before_action :load_template
def show def show
@@ -37,7 +38,10 @@ class StartFormController < ApplicationController
if @submitter.save if @submitter.save
if is_new_record if is_new_record
SendSubmissionCreatedWebhookRequestJob.perform_async({ 'submission_id' => @submitter.submission.id }) 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 end
redirect_to submit_form_path(@submitter.slug) redirect_to submit_form_path(@submitter.slug)
+14 -4
View File
@@ -50,9 +50,7 @@ class SubmissionsController < ApplicationController
params: params.merge('send_completed_email' => true)) params: params.merge('send_completed_email' => true))
end end
submissions.each do |submission| enqueue_submission_created_webhooks(@template, submissions)
SendSubmissionCreatedWebhookRequestJob.perform_async({ 'submission_id' => submission.id })
end
Submissions.send_signature_requests(submissions) Submissions.send_signature_requests(submissions)
@@ -68,7 +66,10 @@ class SubmissionsController < ApplicationController
else else
@submission.update!(archived_at: Time.current) @submission.update!(archived_at: Time.current)
SendSubmissionArchivedWebhookRequestJob.perform_async('submission_id' => @submission.id) 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
I18n.t('submission_has_been_archived') I18n.t('submission_has_been_archived')
end end
@@ -85,6 +86,15 @@ class SubmissionsController < ApplicationController
template.save! template.save!
end 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 def submissions_params
params.permit(submission: { submitters: [%i[uuid email phone name]] }) params.permit(submission: { submitters: [%i[uuid email phone name]] })
end end
@@ -4,6 +4,8 @@ class SubmissionsPreviewController < ApplicationController
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
skip_authorization_check skip_authorization_check
before_action :maybe_redirect_com, only: %i[show completed]
TTL = 40.minutes TTL = 40.minutes
def show def show
@@ -0,0 +1,11 @@
# frozen_string_literal: true
class SubmissionsUnarchiveController < ApplicationController
load_and_authorize_resource :submission
def create
@submission.update!(archived_at: nil)
redirect_to submission_path(@submission), notice: I18n.t('submission_has_been_unarchived')
end
end
@@ -7,6 +7,8 @@ class SubmitFormController < ApplicationController
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
skip_authorization_check skip_authorization_check
before_action :maybe_redirect_com, only: %i[show completed]
CONFIG_KEYS = [].freeze CONFIG_KEYS = [].freeze
def show def show
@@ -21,8 +21,14 @@ class SubmitFormDeclineController < ApplicationController
user = submitter.submission.created_by_user || submitter.template.author user = submitter.submission.created_by_user || submitter.template.author
SubmitterMailer.declined_email(submitter, user).deliver_later! if user.user_configs.find_by(key: UserConfig::RECEIVE_DECLINED_EMAIL)&.value != false
SendFormDeclinedWebhookRequestJob.perform_async('submitter_id' => submitter.id) 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
redirect_to submit_form_path(submitter.slug) redirect_to submit_form_path(submitter.slug)
end end
+16 -2
View File
@@ -66,7 +66,7 @@ class TemplatesController < ApplicationController
if @template.save if @template.save
Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template
SendTemplateUpdatedWebhookRequestJob.perform_async('template_id' => @template.id) enqueue_template_created_webhooks(@template)
maybe_redirect_to_template(@template) maybe_redirect_to_template(@template)
else else
@@ -77,7 +77,7 @@ class TemplatesController < ApplicationController
def update def update
@template.update!(template_params) @template.update!(template_params)
SendTemplateUpdatedWebhookRequestJob.perform_async('template_id' => @template.id) enqueue_template_updated_webhooks(@template)
head :ok head :ok
end end
@@ -128,6 +128,20 @@ class TemplatesController < ApplicationController
end end
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 def load_base_template
return if params[:base_template_id].blank? return if params[:base_template_id].blank?
@@ -23,7 +23,7 @@ class TemplatesUploadsController < ApplicationController
@template.update!(schema:) @template.update!(schema:)
SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => @template.id) enqueue_template_created_webhooks(@template)
redirect_to edit_template_path(@template) redirect_to edit_template_path(@template)
rescue Templates::CreateAttachments::PdfEncrypted rescue Templates::CreateAttachments::PdfEncrypted
@@ -65,4 +65,11 @@ class TemplatesUploadsController < ApplicationController
{ files: [file] } { files: [file] }
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
end end
@@ -4,9 +4,8 @@ class TestingApiSettingsController < ApplicationController
def index def index
authorize!(:manage, current_user.access_token) authorize!(:manage, current_user.access_token)
@webhook_config = @webhook_url = current_account.webhook_urls.first_or_initialize
current_account.encrypted_configs.find_or_initialize_by(key: EncryptedConfig::WEBHOOK_URL_KEY)
authorize!(:manage, @webhook_config) authorize!(:manage, @webhook_url)
end end
end end
@@ -1,37 +1,22 @@
# frozen_string_literal: true # frozen_string_literal: true
class WebhookPreferencesController < ApplicationController class WebhookPreferencesController < ApplicationController
EVENTS = %w[ load_and_authorize_resource :webhook_url, parent: false
form.viewed
form.started
form.completed
form.declined
template.created
template.updated
submission.created
submission.archived
].freeze
before_action :load_account_config def update
authorize_resource :account_config, parent: false webhook_preferences_params[:events].each do |event, val|
@webhook_url.events.delete(event) if val == '0'
@webhook_url.events.push(event) if val == '1' && @webhook_url.events.exclude?(event)
end
def create @webhook_url.save!
@account_config.value[account_config_params[:event]] = account_config_params[:value] == '1'
@account_config.save!
head :ok head :ok
end end
private private
def load_account_config def webhook_preferences_params
@account_config = params.require(:webhook_url).permit(events: {})
current_account.account_configs.find_or_initialize_by(key: AccountConfig::WEBHOOK_PREFERENCES_KEY)
@account_config.value ||= {}
end
def account_config_params
params.permit(:event, :value)
end end
end end
+9 -16
View File
@@ -1,29 +1,22 @@
# frozen_string_literal: true # frozen_string_literal: true
class WebhookSecretController < ApplicationController class WebhookSecretController < ApplicationController
before_action :load_encrypted_config load_and_authorize_resource :webhook_url, parent: false
authorize_resource :encrypted_config, parent: false
def index; end def show; end
def create def update
@encrypted_config.assign_attributes(value: { @webhook_url.update!(secret: {
encrypted_config_params[:key] => encrypted_config_params[:value] webhook_secret_params[:key] => webhook_secret_params[:value]
}.compact_blank) }.compact_blank)
@encrypted_config.value.present? ? @encrypted_config.save! : @encrypted_config.delete redirect_back(fallback_location: settings_webhook_path(@webhook_url),
notice: I18n.t('webhook_secret_has_been_saved'))
redirect_back(fallback_location: settings_webhooks_path, notice: I18n.t('webhook_secret_has_been_saved'))
end end
private private
def load_encrypted_config def webhook_secret_params
@encrypted_config = params.require(:webhook_url).permit(secret: %i[key value]).fetch(:secret, {})
current_account.encrypted_configs.find_or_initialize_by(key: EncryptedConfig::WEBHOOK_SECRET_KEY)
end
def encrypted_config_params
params.require(:encrypted_config).permit(value: %i[key value]).fetch(:value, {})
end end
end end
+37 -13
View File
@@ -1,36 +1,60 @@
# frozen_string_literal: true # frozen_string_literal: true
class WebhookSettingsController < ApplicationController class WebhookSettingsController < ApplicationController
before_action :load_encrypted_config load_and_authorize_resource :webhook_url, parent: false, only: %i[index show new create update destroy]
authorize_resource :encrypted_config, parent: false load_and_authorize_resource :webhook_url, only: %i[resend], id_param: :webhook_id
def index
@webhook_urls = @webhook_urls.order(id: :desc)
@webhook_url = @webhook_urls.first_or_initialize
render @webhook_urls.size > 1 ? 'index' : 'show'
end
def show; end def show; end
def new; end
def create def create
@encrypted_config.assign_attributes(encrypted_config_params) @webhook_url.save!
@encrypted_config.value.present? ? @encrypted_config.save! : @encrypted_config.delete redirect_to settings_webhooks_path, notice: I18n.t('webhook_url_has_been_saved')
redirect_back(fallback_location: settings_webhooks_path, notice: I18n.t('webhook_url_has_been_saved'))
end end
def update def update
@webhook_url.update!(update_params)
redirect_back(fallback_location: settings_webhook_path(@webhook_url),
notice: I18n.t('webhook_url_has_been_updated'))
end
def destroy
@webhook_url.destroy!
redirect_to settings_webhooks_path, notice: I18n.t('webhook_url_has_been_deleted')
end
def resend
submitter = current_account.submitters.where.not(completed_at: nil).order(:id).last submitter = current_account.submitters.where.not(completed_at: nil).order(:id).last
SendFormCompletedWebhookRequestJob.perform_async({ 'submitter_id' => submitter.id, if submitter.blank? || @webhook_url.blank?
'encrypted_config_id' => @encrypted_config.id }) 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)
redirect_back(fallback_location: settings_webhooks_path, notice: I18n.t('webhook_request_has_been_sent')) redirect_back(fallback_location: settings_webhooks_path, notice: I18n.t('webhook_request_has_been_sent'))
end end
private private
def load_encrypted_config def create_params
@encrypted_config = params.require(:webhook_url).permit(:url, events: []).reverse_merge(events: [])
current_account.encrypted_configs.find_or_initialize_by(key: EncryptedConfig::WEBHOOK_URL_KEY)
end end
def encrypted_config_params def update_params
params.require(:encrypted_config).permit(:value) params.require(:webhook_url).permit(:url)
end end
end end
+4
View File
@@ -12,6 +12,10 @@ window.customElements.define('draw-signature', class extends HTMLElement {
this.pad = new SignaturePad(this.canvas) this.pad = new SignaturePad(this.canvas)
if (this.dataset.color) {
this.pad.penColor = this.dataset.color
}
this.pad.addEventListener('endStroke', () => { this.pad.addEventListener('endStroke', () => {
this.updateSubmitButtonVisibility() this.updateSubmitButtonVisibility()
}) })
+6 -3
View File
@@ -39,7 +39,6 @@
</template> </template>
</div> </div>
<div <div
v-if="isActive"
ref="scrollToElem" ref="scrollToElem"
class="absolute" class="absolute"
:style="{ top: scrollPadding }" :style="{ top: scrollPadding }"
@@ -405,8 +404,12 @@ export default {
} }
if (this.field.preferences?.font_size) { if (this.field.preferences?.font_size) {
style.fontSize = `clamp(4pt, 1.6vw, ${this.field.preferences.font_size}pt)` style.fontSize = `clamp(4pt, 1.6vw, ${parseInt(this.field.preferences.font_size) * 1.23}pt)`
style.lineHeight = `clamp(6pt, 2.0vw, ${parseInt(this.field.preferences.font_size) + 3}pt)` style.lineHeight = `clamp(6pt, 2.0vw, ${parseInt(this.field.preferences.font_size) * 1.23 + 3}pt)`
}
if (this.field.preferences?.color) {
style.color = this.field.preferences.color
} }
return style return style
+1 -1
View File
@@ -91,7 +91,7 @@
> >
{{ t('powered_by') }} {{ t('powered_by') }}
<a <a
href="https://www.docuseal.co/start" href="https://www.docuseal.com/start"
target="_blank" target="_blank"
class="underline" class="underline"
>DocuSeal</a> - {{ t('open_source_documents_software') }} >DocuSeal</a> - {{ t('open_source_documents_software') }}
+64 -4
View File
@@ -24,6 +24,44 @@
:fields="formulaFields" :fields="formulaFields"
:values="values" :values="values"
/> />
<Teleport
v-if="completeButtonToRef"
:to="completeButtonToRef"
>
<span
v-if="(emptyValueRequiredStep && ((stepFields.length - 1) !== currentStep || currentStepFields !== emptyValueRequiredStep)) || isCompleted"
class="tooltip-left"
:class="{ tooltip: !isCompleted }"
:data-tip="t('fill_all_required_fields_to_complete')"
>
<button
class="btn btn-sm btn-neutral text-white px-4 w-full flex justify-center btn-disabled pointer-events-auto"
@click="[isFormVisible = true, !isCompleted && goToStep(stepFields.indexOf(emptyValueRequiredStep), true, false)]"
>
{{ t('complete') }}
</button>
</span>
<button
v-else
id="complete_form_button"
class="btn btn-sm btn-neutral text-white px-4 w-full flex justify-center"
form="steps_form"
type="submit"
name="completed"
value="true"
:disabled="isSubmittingComplete"
>
<span class="flex items-center">
<IconInnerShadowTop
v-if="isSubmittingComplete"
class="mr-1 animate-spin w-5 h-5"
/>
<span>
{{ t('complete') }}
</span>
</span>
</button>
</Teleport>
<button <button
v-if="!isFormVisible" v-if="!isFormVisible"
id="expand_form_button" id="expand_form_button"
@@ -74,6 +112,7 @@
> >
<form <form
v-if="!isCompleted && !isInvite" v-if="!isCompleted && !isInvite"
id="steps_form"
ref="form" ref="form"
:action="submitPath" :action="submitPath"
method="post" method="post"
@@ -592,6 +631,11 @@ export default {
required: false, required: false,
default: false default: false
}, },
completeButtonToRef: {
type: Object,
required: false,
default: null
},
attachments: { attachments: {
type: Array, type: Array,
required: false, required: false,
@@ -772,6 +816,7 @@ export default {
phoneVerifiedValues: {}, phoneVerifiedValues: {},
orientation: screen?.orientation?.type, orientation: screen?.orientation?.type,
isSubmitting: false, isSubmitting: false,
isSubmittingComplete: false,
submittedValues: {}, submittedValues: {},
recalculateButtonDisabledKey: '' recalculateButtonDisabledKey: ''
} }
@@ -780,6 +825,13 @@ export default {
isMobile () { isMobile () {
return /android|iphone|ipad/i.test(navigator.userAgent) return /android|iphone|ipad/i.test(navigator.userAgent)
}, },
emptyValueRequiredStep () {
return this.stepFields.find((fields, index) => {
return fields.some((f) => {
return f.required && isEmpty(this.values[f.uuid])
})
})
},
submitButtonText () { submitButtonText () {
if (this.alwaysMinimize) { if (this.alwaysMinimize) {
return this.t('submit') return this.t('submit')
@@ -1146,9 +1198,15 @@ export default {
scrollIntoArea (area) { scrollIntoArea (area) {
return this.$refs.areas.scrollIntoArea(area) return this.$refs.areas.scrollIntoArea(area)
}, },
async submitStep () { async submitStep (e) {
this.isSubmitting = true this.isSubmitting = true
const forceComplete = e?.submitter?.getAttribute('name') === 'completed'
if (forceComplete) {
this.isSubmittingComplete = true
}
const submitStep = this.currentStep const submitStep = this.currentStep
const stepPromise = ['signature', 'phone', 'initials', 'payment'].includes(this.currentField.type) const stepPromise = ['signature', 'phone', 'initials', 'payment'].includes(this.currentField.type)
@@ -1157,7 +1215,7 @@ export default {
stepPromise().then(async () => { stepPromise().then(async () => {
const emptyRequiredField = this.stepFields.find((fields, index) => { const emptyRequiredField = this.stepFields.find((fields, index) => {
if (index >= submitStep) { if (forceComplete ? index === submitStep : index >= submitStep) {
return false return false
} }
@@ -1167,7 +1225,7 @@ export default {
}) })
const formData = new FormData(this.$refs.form) const formData = new FormData(this.$refs.form)
const isLastStep = submitStep === this.stepFields.length - 1 const isLastStep = (submitStep === this.stepFields.length - 1) || forceComplete
if (isLastStep && !emptyRequiredField && !this.inviteSubmitters.length) { if (isLastStep && !emptyRequiredField && !this.inviteSubmitters.length) {
formData.append('completed', 'true') formData.append('completed', 'true')
@@ -1192,7 +1250,7 @@ export default {
return Promise.reject(new Error(data.error)) return Promise.reject(new Error(data.error))
} }
const nextStep = (isLastStep && emptyRequiredField) || this.stepFields[submitStep + 1] const nextStep = (isLastStep && emptyRequiredField) || (forceComplete ? null : this.stepFields[submitStep + 1])
if (nextStep) { if (nextStep) {
if (this.alwaysMinimize) { if (this.alwaysMinimize) {
@@ -1213,6 +1271,7 @@ export default {
console.error(error) console.error(error)
}).finally(() => { }).finally(() => {
this.isSubmitting = false this.isSubmitting = false
this.isSubmittingComplete = false
}) })
}).catch(error => { }).catch(error => {
if (error?.message === 'Image too small') { if (error?.message === 'Image too small') {
@@ -1222,6 +1281,7 @@ export default {
} }
}).finally(() => { }).finally(() => {
this.isSubmitting = false this.isSubmitting = false
this.isSubmittingComplete = false
}) })
}, },
minimizeForm () { minimizeForm () {
+13
View File
@@ -1,5 +1,6 @@
const en = { const en = {
complete: 'Complete', complete: 'Complete',
fill_all_required_fields_to_complete: 'Fill all required fields to complete',
sign_and_complete: 'Sign and Complete', sign_and_complete: 'Sign and Complete',
text: 'Text', text: 'Text',
by_clicking_you_agree_to_the: 'By clicking "{button}", you agree to the', by_clicking_you_agree_to_the: 'By clicking "{button}", you agree to the',
@@ -93,6 +94,7 @@ const en = {
const es = { const es = {
complete: 'Completar', complete: 'Completar',
fill_all_required_fields_to_complete: 'Complete todos los campos requeridos para finalizar',
sign_and_complete: 'Firmar y Completar', sign_and_complete: 'Firmar y Completar',
invite: 'Invitar', invite: 'Invitar',
email: 'Correo electrónico', email: 'Correo electrónico',
@@ -185,6 +187,7 @@ const es = {
const it = { const it = {
complete: 'Completa', complete: 'Completa',
fill_all_required_fields_to_complete: 'Compila tutti i campi obbligatori per completare',
sign_and_complete: 'Firma e Completa', sign_and_complete: 'Firma e Completa',
invite: 'Invita', invite: 'Invita',
email: 'Email', email: 'Email',
@@ -277,6 +280,7 @@ const it = {
const de = { const de = {
complete: 'Abschließen', complete: 'Abschließen',
fill_all_required_fields_to_complete: 'Alle erforderlichen Felder ausfüllen, um abzuschließen',
sign_and_complete: 'Signieren und Abschließen', sign_and_complete: 'Signieren und Abschließen',
invite: 'Einladen', invite: 'Einladen',
email: 'E-Mail', email: 'E-Mail',
@@ -369,6 +373,7 @@ const de = {
const fr = { const fr = {
complete: 'Terminer', complete: 'Terminer',
fill_all_required_fields_to_complete: 'Veuillez remplir tous les champs obligatoires pour compléter',
sign_and_complete: 'Signer et Terminer', sign_and_complete: 'Signer et Terminer',
invite: 'Inviter', invite: 'Inviter',
email: 'Courriel', email: 'Courriel',
@@ -461,6 +466,7 @@ const fr = {
const pl = { const pl = {
complete: 'Zakończ', complete: 'Zakończ',
fill_all_required_fields_to_complete: 'Uzupełnij wszystkie wymagane pola, aby zakończyć',
sign_and_complete: 'Podpisz i zakończ', sign_and_complete: 'Podpisz i zakończ',
invite: 'Zaproś', invite: 'Zaproś',
email: 'E-mail', email: 'E-mail',
@@ -553,6 +559,7 @@ const pl = {
const uk = { const uk = {
complete: 'Завершити', complete: 'Завершити',
fill_all_required_fields_to_complete: "Заповніть всі обов'язкові поля для завершення",
sign_and_complete: 'Підписати і завершити', sign_and_complete: 'Підписати і завершити',
invite: 'Запросити', invite: 'Запросити',
email: 'Електронна пошта', email: 'Електронна пошта',
@@ -645,6 +652,7 @@ const uk = {
const cs = { const cs = {
complete: 'Dokončit', complete: 'Dokončit',
fill_all_required_fields_to_complete: 'Please complete all mandatory fields',
sign_and_complete: 'Podepsat a dokončit', sign_and_complete: 'Podepsat a dokončit',
invite: 'Pozvat', invite: 'Pozvat',
email: 'E-mail', email: 'E-mail',
@@ -737,6 +745,7 @@ const cs = {
const pt = { const pt = {
complete: 'Completar', complete: 'Completar',
preencher_todos_os_campos_obrigatórios_para_concluir: 'Preencher todos os campos obrigatórios para concluir',
sign_and_complete: 'Assinar e Completar', sign_and_complete: 'Assinar e Completar',
invite: 'Convidar', invite: 'Convidar',
email: 'E-mail', email: 'E-mail',
@@ -829,6 +838,7 @@ const pt = {
const he = { const he = {
complete: 'השלם', complete: 'השלם',
fill_all_required_fields_to_complete: 'נא למלא את כל השדות הנדרשים להשלמה',
sign_and_complete: 'חתום והשלם', sign_and_complete: 'חתום והשלם',
invite: 'הזמן', invite: 'הזמן',
email: 'דוא"ל', email: 'דוא"ל',
@@ -922,6 +932,7 @@ const he = {
const nl = { const nl = {
complete: 'Voltooien', complete: 'Voltooien',
vul_alle_verplichte_velden_in_om_te_voltooien: 'Vul alle verplichte velden in om te voltooien',
sign_and_complete: 'Ondertekenen en voltooien', sign_and_complete: 'Ondertekenen en voltooien',
invite: 'Uitnodigen', invite: 'Uitnodigen',
email: 'E-mail', email: 'E-mail',
@@ -1015,6 +1026,7 @@ const nl = {
const ar = { const ar = {
complete: 'اكتمال', complete: 'اكتمال',
fill_all_required_fields_to_complete: 'يرجى ملء جميع الحقول المطلوبة لإكمال',
sign_and_complete: 'التوقيع والاكتمال', sign_and_complete: 'التوقيع والاكتمال',
invite: 'دعوة', invite: 'دعوة',
email: 'البريد الإلكتروني', email: 'البريد الإلكتروني',
@@ -1107,6 +1119,7 @@ const ar = {
const ko = { const ko = {
complete: '완료', complete: '완료',
fill_all_required_fields_to_complete: '모든 필수 필드를 작성하여 완료하세요',
sign_and_complete: '서명하고 완료하기', sign_and_complete: '서명하고 완료하기',
invite: '초대하기', invite: '초대하기',
email: '이메일', email: '이메일',
@@ -226,6 +226,10 @@ export default {
if (this.$refs.canvas) { if (this.$refs.canvas) {
this.pad = new SignaturePad(this.$refs.canvas) this.pad = new SignaturePad(this.$refs.canvas)
if (this.field.preferences?.color) {
this.pad.penColor = this.field.preferences.color
}
this.pad.addEventListener('beginStroke', () => { this.pad.addEventListener('beginStroke', () => {
this.isInitialsStarted = true this.isInitialsStarted = true
@@ -145,6 +145,10 @@
{{ t('clear') }} {{ t('clear') }}
</a> </a>
</div> </div>
<div
v-if="isTextSignature"
class="absolute top-0 right-0 left-0 bottom-0"
/>
<canvas <canvas
v-show="!modelValue && !computedPreviousValue" v-show="!modelValue && !computedPreviousValue"
ref="canvas" ref="canvas"
@@ -255,7 +259,7 @@
class="text-base-content/60 text-xs text-center w-full mt-1" class="text-base-content/60 text-xs text-center w-full mt-1"
> >
{{ t('by_clicking_you_agree_to_the').replace('{button}', buttonText.charAt(0).toUpperCase() + buttonText.slice(1)) }} <a {{ t('by_clicking_you_agree_to_the').replace('{button}', buttonText.charAt(0).toUpperCase() + buttonText.slice(1)) }} <a
href="https://www.docuseal.co/esign-disclosure" href="https://www.docuseal.com/esign-disclosure"
target="_blank" target="_blank"
> >
<span class="inline md:hidden"> <span class="inline md:hidden">
@@ -419,6 +423,10 @@ export default {
if (this.$refs.canvas) { if (this.$refs.canvas) {
this.pad = new SignaturePad(this.$refs.canvas) this.pad = new SignaturePad(this.$refs.canvas)
if (this.field.preferences?.color) {
this.pad.penColor = this.field.preferences.color
}
this.pad.addEventListener('endStroke', () => { this.pad.addEventListener('endStroke', () => {
this.isSignatureStarted = true this.isSignatureStarted = true
+1
View File
@@ -175,6 +175,7 @@
<div <div
ref="textContainer" ref="textContainer"
class="flex items-center px-0.5" class="flex items-center px-0.5"
:style="{ color: field.preferences?.color }"
:class="{ 'w-full h-full': ['cells', 'checkbox'].includes(field.type) }" :class="{ 'w-full h-full': ['cells', 'checkbox'].includes(field.type) }"
> >
<IconCheck <IconCheck
@@ -415,6 +415,7 @@ import Contenteditable from './contenteditable'
import DocumentPreview from './preview' import DocumentPreview from './preview'
import DocumentControls from './controls' import DocumentControls from './controls'
import MobileFields from './mobile_fields' import MobileFields from './mobile_fields'
import FieldSubmitter from './field_submitter'
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments } from '@tabler/icons-vue' import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments } from '@tabler/icons-vue'
import { v4 } from 'uuid' import { v4 } from 'uuid'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
@@ -558,6 +559,11 @@ export default {
required: false, required: false,
default: () => [] default: () => []
}, },
defineSubmitters: {
type: Array,
required: false,
default: () => []
},
acceptFileTypes: { acceptFileTypes: {
type: String, type: String,
required: false, required: false,
@@ -677,6 +683,7 @@ export default {
} }
}, },
computed: { computed: {
submitterDefaultNames: FieldSubmitter.computed.names,
selectedAreaRef: () => ref(), selectedAreaRef: () => ref(),
fieldsDragFieldRef: () => ref(), fieldsDragFieldRef: () => ref(),
language () { language () {
@@ -749,6 +756,18 @@ export default {
} }
}) })
this.defineSubmitters.forEach((name, index) => {
const submitter = (this.template.submitters[index] ||= {})
submitter.name = name || this.submitterDefaultNames[index]
if (existingSubmittersUuids.filter(Boolean).length) {
submitter.uuid = existingSubmittersUuids[index] || submitter.uuid || v4()
} else {
submitter.uuid ||= v4()
}
})
this.selectedSubmitter = this.template.submitters[0] this.selectedSubmitter = this.template.submitters[0]
}, },
mounted () { mounted () {
@@ -23,7 +23,7 @@
class="bg-base-300 rounded-xl py-2 px-3 text-center" class="bg-base-300 rounded-xl py-2 px-3 text-center"
> >
<a <a
href="https://www.docuseal.co/pricing" href="https://www.docuseal.com/pricing"
target="_blank" target="_blank"
class="link" class="link"
>{{ t('available_in_pro') }}</a> >{{ t('available_in_pro') }}</a>
@@ -79,7 +79,7 @@
:stroke-width="1.6" :stroke-width="1.6"
/> />
<span class="py-1"> <span class="py-1">
{{ t('add') }} {{ names[submitters.length] }} {{ t('add') }} {{ names[lastPartyIndex] }}
</span> </span>
</a> </a>
</li> </li>
@@ -202,7 +202,7 @@
:stroke-width="1.6" :stroke-width="1.6"
/> />
<span class="py-1"> <span class="py-1">
{{ t('add') }} {{ names[submitters.length] }} {{ t('add') }} {{ names[lastPartyIndex] }}
</span> </span>
</a> </a>
</li> </li>
@@ -321,6 +321,15 @@ export default {
this.t('twentieth_party') this.t('twentieth_party')
] ]
}, },
lastPartyIndex () {
const index = Math.max(...this.submitters.map((s) => this.names.indexOf(s.name)))
if (index !== -1) {
return index + 1
} else {
return this.submitters.length
}
},
selectedSubmitter () { selectedSubmitter () {
return this.submitters.find((e) => e.uuid === this.modelValue) return this.submitters.find((e) => e.uuid === this.modelValue)
} }
@@ -353,7 +362,7 @@ export default {
}, },
addSubmitter () { addSubmitter () {
const newSubmitter = { const newSubmitter = {
name: this.names[this.submitters.length], name: this.names[this.lastPartyIndex],
uuid: v4() uuid: v4()
} }
+1 -1
View File
@@ -140,7 +140,7 @@
:data-tip="t('unlock_sms_verified_phone_number_field_with_paid_plan_use_text_field_for_phone_numbers_without_verification')" :data-tip="t('unlock_sms_verified_phone_number_field_with_paid_plan_use_text_field_for_phone_numbers_without_verification')"
> >
<a <a
href="https://www.docuseal.co/pricing" href="https://www.docuseal.com/pricing"
target="_blank" target="_blank"
class="opacity-50 flex items-center justify-center border border-dashed border-base-300 w-full rounded relative" class="opacity-50 flex items-center justify-center border border-dashed border-base-300 w-full rounded relative"
:style="{ backgroundColor }" :style="{ backgroundColor }"
@@ -23,7 +23,7 @@
class="bg-base-300 rounded-xl py-2 px-3 text-center" class="bg-base-300 rounded-xl py-2 px-3 text-center"
> >
<a <a
href="https://www.docuseal.co/pricing" href="https://www.docuseal.com/pricing"
target="_blank" target="_blank"
class="link" class="link"
>{{ t('available_in_pro') }}</a> >{{ t('available_in_pro') }}</a>
@@ -77,7 +77,7 @@
<div class="w-full relative"> <div class="w-full relative">
<select <select
class="base-select !select-sm !h-10" class="base-select !select-sm !h-10"
:class="{ '!text-gray-300': !mapping.column_index }" :class="{ '!text-gray-300': !mapping.column_index && mapping.column_index != 0 }"
required required
@change="mapping.column_index = parseInt($event.target.value)" @change="mapping.column_index = parseInt($event.target.value)"
> >
@@ -285,7 +285,7 @@ export default {
submittersIndex[mapping.submitter_uuid][mapping.field_name.toLowerCase()] = row[mapping.column_index] submittersIndex[mapping.submitter_uuid][mapping.field_name.toLowerCase()] = row[mapping.column_index]
} }
const fieldType = this.fieldTypesIndex[mapping.submitter_uuid][mapping.field_name] const fieldType = this.fieldTypesIndex[mapping.submitter_uuid]?.[mapping.field_name]
if (fieldType && fieldType !== 'phone') { if (fieldType && fieldType !== 'phone') {
submittersIndex[mapping.submitter_uuid].fields.push({ submittersIndex[mapping.submitter_uuid].fields.push({
@@ -178,7 +178,7 @@
<a <a
v-if="!isConnected" v-if="!isConnected"
class="block link text-center mt-1" class="block link text-center mt-1"
href="https://www.docuseal.co/blog/accept-payments-and-request-signatures-with-ease" href="https://www.docuseal.com/blog/accept-payments-and-request-signatures-with-ease"
target="_blank" target="_blank"
data-turbo="false" data-turbo="false"
>{{ t('learn_more') }}</a> >{{ t('learn_more') }}</a>
+5 -22
View File
@@ -63,32 +63,15 @@ class ProcessSubmitterCompletionJob
end end
def enqueue_completed_webhooks(submitter, is_all_completed: false) def enqueue_completed_webhooks(submitter, is_all_completed: false)
webhook_config = Accounts.load_webhook_config(submitter.account) WebhookUrls.for_account_id(submitter.account_id, %w[form.completed submission.completed]).each do |webhook|
if webhook_config
SendFormCompletedWebhookRequestJob.perform_async({ 'submitter_id' => submitter.id,
'encrypted_config_id' => webhook_config.id })
end
webhook_urls = submitter.account.webhook_urls
webhook_urls = webhook_urls.where(
Arel::Table.new(:webhook_urls)[:events].matches('%"form.completed"%')
).or(
webhook_urls.where(
Arel::Table.new(:webhook_urls)[:events].matches('%"submission.completed"%')
)
)
webhook_urls.each do |webhook|
if webhook.events.include?('form.completed') if webhook.events.include?('form.completed')
SendFormCompletedWebhookRequestJob.perform_async({ 'submitter_id' => submitter.id, SendFormCompletedWebhookRequestJob.perform_async('submitter_id' => submitter.id,
'webhook_url_id' => webhook.id }) 'webhook_url_id' => webhook.id)
end end
if webhook.events.include?('submission.completed') && is_all_completed if webhook.events.include?('submission.completed') && is_all_completed
SendSubmissionCompletedWebhookRequestJob.perform_async({ 'submission_id' => submitter.submission_id, SendSubmissionCompletedWebhookRequestJob.perform_async('submission_id' => submitter.submission_id,
'webhook_url_id' => webhook.id }) 'webhook_url_id' => webhook.id)
end end
end end
end end
@@ -5,33 +5,32 @@ class SendFormCompletedWebhookRequestJob
sidekiq_options queue: :webhooks sidekiq_options queue: :webhooks
USER_AGENT = 'DocuSeal.co Webhook' USER_AGENT = 'DocuSeal.com Webhook'
MAX_ATTEMPTS = 10 MAX_ATTEMPTS = 10
def perform(params = {}) def perform(params = {})
submitter = Submitter.find(params['submitter_id']) submitter = Submitter.find(params['submitter_id'])
webhook_url = WebhookUrl.find(params['webhook_url_id'])
attempt = params['attempt'].to_i attempt = params['attempt'].to_i
url, secret = load_url_and_secret(submitter, params) return if webhook_url.url.blank? || webhook_url.events.exclude?('form.completed')
return if url.blank?
Submissions::EnsureResultGenerated.call(submitter) Submissions::EnsureResultGenerated.call(submitter)
ActiveStorage::Current.url_options = Docuseal.default_url_options ActiveStorage::Current.url_options = Docuseal.default_url_options
resp = begin resp = begin
Faraday.post(url, Faraday.post(webhook_url.url,
{ {
event_type: 'form.completed', event_type: 'form.completed',
timestamp: Time.current, timestamp: Time.current,
data: Submitters::SerializeForWebhook.call(submitter) data: Submitters::SerializeForWebhook.call(submitter)
}.to_json, }.to_json,
**secret.to_h,
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT) 'User-Agent' => USER_AGENT,
**webhook_url.secret.to_h)
rescue Faraday::Error rescue Faraday::Error
nil nil
end end
@@ -45,27 +44,4 @@ class SendFormCompletedWebhookRequestJob
}) })
end end
end end
def load_url_and_secret(submitter, params)
if params['encrypted_config_id']
config = EncryptedConfig.find(params['encrypted_config_id'])
url = config.value
return if url.blank?
preferences = Accounts.load_webhook_preferences(submitter.submission.account)
return if preferences['form.completed'] == false
secret = EncryptedConfig.find_or_initialize_by(account_id: config.account_id,
key: EncryptedConfig::WEBHOOK_SECRET_KEY)&.value.to_h
[url, secret]
elsif params['webhook_url_id']
webhook_url = submitter.account.webhook_urls.find(params['webhook_url_id'])
webhook_url.url if webhook_url.events.include?('form.completed')
end
end
end end
@@ -5,34 +5,28 @@ class SendFormDeclinedWebhookRequestJob
sidekiq_options queue: :webhooks sidekiq_options queue: :webhooks
USER_AGENT = 'DocuSeal.co Webhook' USER_AGENT = 'DocuSeal.com Webhook'
MAX_ATTEMPTS = 10 MAX_ATTEMPTS = 10
def perform(params = {}) def perform(params = {})
submitter = Submitter.find(params['submitter_id']) submitter = Submitter.find(params['submitter_id'])
webhook_url = WebhookUrl.find(params['webhook_url_id'])
attempt = params['attempt'].to_i attempt = params['attempt'].to_i
config = Accounts.load_webhook_config(submitter.submission.account)
url = config&.value.presence
return if url.blank? return if webhook_url.url.blank? || webhook_url.events.exclude?('form.declined')
preferences = Accounts.load_webhook_preferences(submitter.submission.account)
return if preferences['form.declined'] == false
ActiveStorage::Current.url_options = Docuseal.default_url_options ActiveStorage::Current.url_options = Docuseal.default_url_options
resp = begin resp = begin
Faraday.post(url, Faraday.post(webhook_url.url,
{ {
event_type: 'form.declined', event_type: 'form.declined',
timestamp: Time.current, timestamp: Time.current,
data: Submitters::SerializeForWebhook.call(submitter) data: Submitters::SerializeForWebhook.call(submitter)
}.to_json, }.to_json,
**EncryptedConfig.find_or_initialize_by(account_id: config.account_id, **webhook_url.secret.to_h,
key: EncryptedConfig::WEBHOOK_SECRET_KEY)&.value.to_h,
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT) 'User-Agent' => USER_AGENT)
rescue Faraday::Error rescue Faraday::Error
@@ -43,6 +37,7 @@ class SendFormDeclinedWebhookRequestJob
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormDeclinedWebhookRequestJob.perform_in((2**attempt).minutes, { SendFormDeclinedWebhookRequestJob.perform_in((2**attempt).minutes, {
'submitter_id' => submitter.id, 'submitter_id' => submitter.id,
'webhook_url_id' => webhook_url.id,
'attempt' => attempt + 1, 'attempt' => attempt + 1,
'last_status' => resp&.status.to_i 'last_status' => resp&.status.to_i
}) })
@@ -5,34 +5,28 @@ class SendFormStartedWebhookRequestJob
sidekiq_options queue: :webhooks sidekiq_options queue: :webhooks
USER_AGENT = 'DocuSeal.co Webhook' USER_AGENT = 'DocuSeal.com Webhook'
MAX_ATTEMPTS = 10 MAX_ATTEMPTS = 10
def perform(params = {}) def perform(params = {})
submitter = Submitter.find(params['submitter_id']) submitter = Submitter.find(params['submitter_id'])
webhook_url = WebhookUrl.find(params['webhook_url_id'])
attempt = params['attempt'].to_i attempt = params['attempt'].to_i
config = Accounts.load_webhook_config(submitter.submission.account)
url = config&.value.presence
return if url.blank? return if webhook_url.url.blank? || webhook_url.events.exclude?('form.started')
preferences = Accounts.load_webhook_preferences(submitter.submission.account)
return if preferences['form.started'] == false
ActiveStorage::Current.url_options = Docuseal.default_url_options ActiveStorage::Current.url_options = Docuseal.default_url_options
resp = begin resp = begin
Faraday.post(url, Faraday.post(webhook_url.url,
{ {
event_type: 'form.started', event_type: 'form.started',
timestamp: Time.current, timestamp: Time.current,
data: Submitters::SerializeForWebhook.call(submitter) data: Submitters::SerializeForWebhook.call(submitter)
}.to_json, }.to_json,
**EncryptedConfig.find_or_initialize_by(account_id: config.account_id, **webhook_url.secret.to_h,
key: EncryptedConfig::WEBHOOK_SECRET_KEY)&.value.to_h,
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT) 'User-Agent' => USER_AGENT)
rescue Faraday::Error rescue Faraday::Error
@@ -43,6 +37,7 @@ class SendFormStartedWebhookRequestJob
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormStartedWebhookRequestJob.perform_in((2**attempt).minutes, { SendFormStartedWebhookRequestJob.perform_in((2**attempt).minutes, {
'submitter_id' => submitter.id, 'submitter_id' => submitter.id,
'webhook_url_id' => webhook_url.id,
'attempt' => attempt + 1, 'attempt' => attempt + 1,
'last_status' => resp&.status.to_i 'last_status' => resp&.status.to_i
}) })
@@ -5,34 +5,28 @@ class SendFormViewedWebhookRequestJob
sidekiq_options queue: :webhooks sidekiq_options queue: :webhooks
USER_AGENT = 'DocuSeal.co Webhook' USER_AGENT = 'DocuSeal.com Webhook'
MAX_ATTEMPTS = 10 MAX_ATTEMPTS = 10
def perform(params = {}) def perform(params = {})
submitter = Submitter.find(params['submitter_id']) submitter = Submitter.find(params['submitter_id'])
webhook_url = WebhookUrl.find(params['webhook_url_id'])
attempt = params['attempt'].to_i attempt = params['attempt'].to_i
config = Accounts.load_webhook_config(submitter.submission.account)
url = config&.value.presence
return if url.blank? return if webhook_url.url.blank? || webhook_url.events.exclude?('form.viewed')
preferences = Accounts.load_webhook_preferences(submitter.submission.account)
return if preferences['form.viewed'] == false
ActiveStorage::Current.url_options = Docuseal.default_url_options ActiveStorage::Current.url_options = Docuseal.default_url_options
resp = begin resp = begin
Faraday.post(url, Faraday.post(webhook_url.url,
{ {
event_type: 'form.viewed', event_type: 'form.viewed',
timestamp: Time.current, timestamp: Time.current,
data: Submitters::SerializeForWebhook.call(submitter) data: Submitters::SerializeForWebhook.call(submitter)
}.to_json, }.to_json,
**EncryptedConfig.find_or_initialize_by(account_id: config.account_id, **webhook_url.secret.to_h,
key: EncryptedConfig::WEBHOOK_SECRET_KEY)&.value.to_h,
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT) 'User-Agent' => USER_AGENT)
rescue Faraday::Error rescue Faraday::Error
@@ -43,6 +37,7 @@ class SendFormViewedWebhookRequestJob
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormViewedWebhookRequestJob.perform_in((2**attempt).minutes, { SendFormViewedWebhookRequestJob.perform_in((2**attempt).minutes, {
'submitter_id' => submitter.id, 'submitter_id' => submitter.id,
'webhook_url_id' => webhook_url.id,
'attempt' => attempt + 1, 'attempt' => attempt + 1,
'last_status' => resp&.status.to_i 'last_status' => resp&.status.to_i
}) })
@@ -5,33 +5,26 @@ class SendSubmissionArchivedWebhookRequestJob
sidekiq_options queue: :webhooks sidekiq_options queue: :webhooks
USER_AGENT = 'DocuSeal.co Webhook' USER_AGENT = 'DocuSeal.com Webhook'
MAX_ATTEMPTS = 10 MAX_ATTEMPTS = 10
def perform(params = {}) def perform(params = {})
submission = Submission.find(params['submission_id']) submission = Submission.find(params['submission_id'])
webhook_url = WebhookUrl.find(params['webhook_url_id'])
attempt = params['attempt'].to_i attempt = params['attempt'].to_i
config = Accounts.load_webhook_config(submission.account) return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.archived')
url = config&.value.presence
return if url.blank?
preferences = Accounts.load_webhook_preferences(submission.account)
return if preferences['submission.archived'].blank?
resp = begin resp = begin
Faraday.post(url, Faraday.post(webhook_url.url,
{ {
event_type: 'submission.archived', event_type: 'submission.archived',
timestamp: Time.current, timestamp: Time.current,
data: submission.as_json(only: %i[id archived_at]) data: submission.as_json(only: %i[id archived_at])
}.to_json, }.to_json,
**EncryptedConfig.find_or_initialize_by(account_id: config.account_id, **webhook_url.secret.to_h,
key: EncryptedConfig::WEBHOOK_SECRET_KEY)&.value.to_h,
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT) 'User-Agent' => USER_AGENT)
rescue Faraday::Error rescue Faraday::Error
@@ -42,6 +35,7 @@ class SendSubmissionArchivedWebhookRequestJob
(!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan))
SendSubmissionArchivedWebhookRequestJob.perform_in((2**attempt).minutes, { SendSubmissionArchivedWebhookRequestJob.perform_in((2**attempt).minutes, {
'submission_id' => submission.id, 'submission_id' => submission.id,
'webhook_url_id' => webhook_url.id,
'attempt' => attempt + 1, 'attempt' => attempt + 1,
'last_status' => resp&.status.to_i 'last_status' => resp&.status.to_i
}) })
@@ -5,32 +5,28 @@ class SendSubmissionCompletedWebhookRequestJob
sidekiq_options queue: :webhooks sidekiq_options queue: :webhooks
USER_AGENT = 'DocuSeal.co Webhook' USER_AGENT = 'DocuSeal.com Webhook'
MAX_ATTEMPTS = 10 MAX_ATTEMPTS = 10
def perform(params = {}) def perform(params = {})
submission = Submission.find(params['submission_id']) submission = Submission.find(params['submission_id'])
webhook_url = WebhookUrl.find(params['webhook_url_id'])
attempt = params['attempt'].to_i attempt = params['attempt'].to_i
webhook_url = submission.account.webhook_urls.find(params['webhook_url_id']) return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.completed')
url = webhook_url.url if webhook_url.events.include?('submission.completed')
return if url.blank?
resp = begin resp = begin
Faraday.post(url, Faraday.post(webhook_url.url,
{ {
event_type: 'submission.completed', event_type: 'submission.completed',
timestamp: Time.current, timestamp: Time.current,
data: Submissions::SerializeForApi.call(submission) data: Submissions::SerializeForApi.call(submission)
}.to_json, }.to_json,
**EncryptedConfig.find_or_initialize_by(account_id: submission.account_id,
key: EncryptedConfig::WEBHOOK_SECRET_KEY)&.value.to_h,
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT) 'User-Agent' => USER_AGENT,
**webhook_url.secret.to_h)
rescue Faraday::Error rescue Faraday::Error
nil nil
end end
@@ -5,33 +5,26 @@ class SendSubmissionCreatedWebhookRequestJob
sidekiq_options queue: :webhooks sidekiq_options queue: :webhooks
USER_AGENT = 'DocuSeal.co Webhook' USER_AGENT = 'DocuSeal.com Webhook'
MAX_ATTEMPTS = 10 MAX_ATTEMPTS = 10
def perform(params = {}) def perform(params = {})
submission = Submission.find(params['submission_id']) submission = Submission.find(params['submission_id'])
webhook_url = WebhookUrl.find(params['webhook_url_id'])
attempt = params['attempt'].to_i attempt = params['attempt'].to_i
config = Accounts.load_webhook_config(submission.account) return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.created')
url = config&.value.presence
return if url.blank?
preferences = Accounts.load_webhook_preferences(submission.account)
return if preferences['submission.created'].blank?
resp = begin resp = begin
Faraday.post(url, Faraday.post(webhook_url.url,
{ {
event_type: 'submission.created', event_type: 'submission.created',
timestamp: Time.current, timestamp: Time.current,
data: Submissions::SerializeForApi.call(submission) data: Submissions::SerializeForApi.call(submission)
}.to_json, }.to_json,
**EncryptedConfig.find_or_initialize_by(account_id: config.account_id, **webhook_url.secret.to_h,
key: EncryptedConfig::WEBHOOK_SECRET_KEY)&.value.to_h,
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT) 'User-Agent' => USER_AGENT)
rescue Faraday::Error rescue Faraday::Error
@@ -42,6 +35,7 @@ class SendSubmissionCreatedWebhookRequestJob
(!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan))
SendSubmissionCreatedWebhookRequestJob.perform_in((2**attempt).minutes, { SendSubmissionCreatedWebhookRequestJob.perform_in((2**attempt).minutes, {
'submission_id' => submission.id, 'submission_id' => submission.id,
'webhook_url_id' => webhook_url.id,
'attempt' => attempt + 1, 'attempt' => attempt + 1,
'last_status' => resp&.status.to_i 'last_status' => resp&.status.to_i
}) })
@@ -5,33 +5,26 @@ class SendTemplateCreatedWebhookRequestJob
sidekiq_options queue: :webhooks sidekiq_options queue: :webhooks
USER_AGENT = 'DocuSeal.co Webhook' USER_AGENT = 'DocuSeal.com Webhook'
MAX_ATTEMPTS = 10 MAX_ATTEMPTS = 10
def perform(params = {}) def perform(params = {})
template = Template.find(params['template_id']) template = Template.find(params['template_id'])
webhook_url = WebhookUrl.find(params['webhook_url_id'])
attempt = params['attempt'].to_i attempt = params['attempt'].to_i
config = Accounts.load_webhook_config(template.account) return if webhook_url.url.blank? || webhook_url.events.exclude?('template.created')
url = config&.value.presence
return if url.blank?
preferences = Accounts.load_webhook_preferences(template.account)
return if preferences['template.created'].blank?
resp = begin resp = begin
Faraday.post(url, Faraday.post(webhook_url.url,
{ {
event_type: 'template.created', event_type: 'template.created',
timestamp: Time.current, timestamp: Time.current,
data: Templates::SerializeForApi.call(template) data: Templates::SerializeForApi.call(template)
}.to_json, }.to_json,
**EncryptedConfig.find_or_initialize_by(account_id: config.account_id, **webhook_url.secret.to_h,
key: EncryptedConfig::WEBHOOK_SECRET_KEY)&.value.to_h,
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT) 'User-Agent' => USER_AGENT)
rescue Faraday::Error rescue Faraday::Error
@@ -42,6 +35,7 @@ class SendTemplateCreatedWebhookRequestJob
(!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan))
SendTemplateCreatedWebhookRequestJob.perform_in((2**attempt).minutes, { SendTemplateCreatedWebhookRequestJob.perform_in((2**attempt).minutes, {
'template_id' => template.id, 'template_id' => template.id,
'webhook_url_id' => webhook_url.id,
'attempt' => attempt + 1, 'attempt' => attempt + 1,
'last_status' => resp&.status.to_i 'last_status' => resp&.status.to_i
}) })
@@ -5,33 +5,26 @@ class SendTemplateUpdatedWebhookRequestJob
sidekiq_options queue: :webhooks sidekiq_options queue: :webhooks
USER_AGENT = 'DocuSeal.co Webhook' USER_AGENT = 'DocuSeal.com Webhook'
MAX_ATTEMPTS = 10 MAX_ATTEMPTS = 10
def perform(params = {}) def perform(params = {})
template = Template.find(params['template_id']) template = Template.find(params['template_id'])
webhook_url = WebhookUrl.find(params['webhook_url_id'])
attempt = params['attempt'].to_i attempt = params['attempt'].to_i
config = Accounts.load_webhook_config(template.account) return if webhook_url.url.blank? || webhook_url.events.exclude?('template.updated')
url = config&.value.presence
return if url.blank?
preferences = Accounts.load_webhook_preferences(template.account)
return if preferences['template.updated'].blank?
resp = begin resp = begin
Faraday.post(url, Faraday.post(webhook_url.url,
{ {
event_type: 'template.updated', event_type: 'template.updated',
timestamp: Time.current, timestamp: Time.current,
data: Templates::SerializeForApi.call(template) data: Templates::SerializeForApi.call(template)
}.to_json, }.to_json,
**EncryptedConfig.find_or_initialize_by(account_id: config.account_id, **webhook_url.secret.to_h,
key: EncryptedConfig::WEBHOOK_SECRET_KEY)&.value.to_h,
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT) 'User-Agent' => USER_AGENT)
rescue Faraday::Error rescue Faraday::Error
@@ -42,6 +35,7 @@ class SendTemplateUpdatedWebhookRequestJob
(!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan))
SendTemplateUpdatedWebhookRequestJob.perform_in((2**attempt).minutes, { SendTemplateUpdatedWebhookRequestJob.perform_in((2**attempt).minutes, {
'template_id' => template.id, 'template_id' => template.id,
'webhook_url_id' => webhook_url.id,
'attempt' => attempt + 1, 'attempt' => attempt + 1,
'last_status' => resp&.status.to_i 'last_status' => resp&.status.to_i
}) })
+5 -3
View File
@@ -138,9 +138,11 @@ class SubmitterMailer < ApplicationMailer
private private
def build_submitter_reply_to(submitter) def build_submitter_reply_to(submitter)
reply_to = reply_to = submitter.preferences['reply_to'].presence
submitter.preferences['reply_to'].presence ||
(submitter.submission.created_by_user || submitter.template.author)&.friendly_name&.sub(/\+\w+@/, '@') if reply_to.blank? && (submitter.submission.created_by_user || submitter.template.author)&.email != submitter.email
reply_to = (submitter.submission.created_by_user || submitter.template.author)&.friendly_name&.sub(/\+\w+@/, '@')
end
return nil if reply_to.to_s.match?(NO_REPLY_REGEXP) return nil if reply_to.to_s.match?(NO_REPLY_REGEXP)
-1
View File
@@ -35,7 +35,6 @@ class AccountConfig < ApplicationRecord
FORM_WITH_CONFETTI_KEY = 'form_with_confetti' FORM_WITH_CONFETTI_KEY = 'form_with_confetti'
FORM_PREFILL_SIGNATURE_KEY = 'form_prefill_signature' FORM_PREFILL_SIGNATURE_KEY = 'form_prefill_signature'
ESIGNING_PREFERENCE_KEY = 'esigning_preference' ESIGNING_PREFERENCE_KEY = 'esigning_preference'
WEBHOOK_PREFERENCES_KEY = 'webhook_preferences'
DOWNLOAD_LINKS_AUTH_KEY = 'download_links_auth' DOWNLOAD_LINKS_AUTH_KEY = 'download_links_auth'
FORCE_SSO_AUTH_KEY = 'force_sso_auth' FORCE_SSO_AUTH_KEY = 'force_sso_auth'
FLATTEN_RESULT_PDF_KEY = 'flatten_result_pdf' FLATTEN_RESULT_PDF_KEY = 'flatten_result_pdf'
+1 -3
View File
@@ -26,9 +26,7 @@ class EncryptedConfig < ApplicationRecord
EMAIL_SMTP_KEY = 'action_mailer_smtp', EMAIL_SMTP_KEY = 'action_mailer_smtp',
ESIGN_CERTS_KEY = 'esign_certs', ESIGN_CERTS_KEY = 'esign_certs',
TIMESTAMP_SERVER_URL_KEY = 'timestamp_server_url', TIMESTAMP_SERVER_URL_KEY = 'timestamp_server_url',
APP_URL_KEY = 'app_url', APP_URL_KEY = 'app_url'
WEBHOOK_URL_KEY = 'webhook_url',
WEBHOOK_SECRET_KEY = 'webhook_secret'
].freeze ].freeze
belongs_to :account belongs_to :account
+1
View File
@@ -24,6 +24,7 @@ class UserConfig < ApplicationRecord
SIGNATURE_KEY = 'signature' SIGNATURE_KEY = 'signature'
INITIALS_KEY = 'initials' INITIALS_KEY = 'initials'
RECEIVE_COMPLETED_EMAIL = 'receive_completed_email' RECEIVE_COMPLETED_EMAIL = 'receive_completed_email'
RECEIVE_DECLINED_EMAIL = 'receive_declined_email'
belongs_to :user belongs_to :user
+16 -2
View File
@@ -6,6 +6,7 @@
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# events :text not null # events :text not null
# secret :text not null
# sha1 :string not null # sha1 :string not null
# url :text not null # url :text not null
# created_at :datetime not null # created_at :datetime not null
@@ -22,15 +23,28 @@
# fk_rails_... (account_id => accounts.id) # fk_rails_... (account_id => accounts.id)
# #
class WebhookUrl < ApplicationRecord class WebhookUrl < ApplicationRecord
EVENTS = %w[
form.viewed
form.started
form.completed
form.declined
template.created
template.updated
submission.created
submission.archived
].freeze
belongs_to :account belongs_to :account
attribute :events, :string, default: -> { [] } attribute :events, :string, default: -> { %w[form.viewed form.started form.completed form.declined] }
attribute :secret, :string, default: -> { {} }
serialize :events, coder: JSON serialize :events, coder: JSON
serialize :secret, coder: JSON
before_validation :set_sha1 before_validation :set_sha1
encrypts :url encrypts :url, :secret
def set_sha1 def set_sha1
self.sha1 = Digest::SHA1.hexdigest(url) self.sha1 = Digest::SHA1.hexdigest(url)
+1
View File
@@ -1,3 +1,4 @@
<%= content_for(:canonical_url, new_user_session_url) %>
<div class="max-w-lg mx-auto px-2"> <div class="max-w-lg mx-auto px-2">
<%= render 'devise/shared/select_server' if Docuseal.multitenant? %> <%= render 'devise/shared/select_server' if Docuseal.multitenant? %>
<h1 class="text-4xl font-bold text-center mt-8"><%= t('sign_in') %></h1> <h1 class="text-4xl font-bold text-center mt-8"><%= t('sign_in') %></h1>
+5
View File
@@ -0,0 +1,5 @@
<svg class="<%= local_assigns[:class] %>" xmlns="http://www.w3.org/2000/svg" x-bind:width="size" x-bind:height="size" viewBox="0 0 24 24" fill="none" stroke="currentColor" x-bind:stroke-width="stroke" stroke-linecap="round" stroke-linejoin="round" width="24" height="24" stroke-width="2">
<path d="M5 13a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v6a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-6z"></path>
<path d="M11 16a1 1 0 1 0 2 0a1 1 0 0 0 -2 0"></path>
<path d="M8 11v-4a4 4 0 1 1 8 0v4"></path>
</svg>

After

Width:  |  Height:  |  Size: 496 B

+3
View File
@@ -11,6 +11,9 @@
<% else %> <% else %>
<%= javascript_pack_tag 'application', defer: true %> <%= javascript_pack_tag 'application', defer: true %>
<% end %> <% end %>
<% if canonical_url = content_for(:canonical_url) %>
<link href="<%= canonical_url %>" rel="canonical">
<% end %>
<%= stylesheet_pack_tag 'application', media: 'all' %> <%= stylesheet_pack_tag 'application', media: 'all' %>
<%= render 'shared/posthog' if ENV['POSTHOG_TOKEN'] %> <%= render 'shared/posthog' if ENV['POSTHOG_TOKEN'] %>
<%= render 'shared/plausible' if !signed_in? && ENV['PLAUSIBLE_DOMAIN'] %> <%= render 'shared/plausible' if !signed_in? && ENV['PLAUSIBLE_DOMAIN'] %>
+1 -1
View File
@@ -27,7 +27,7 @@
</div> </div>
<h3 class="mb-4 text-2xl font-semibold">Easy to Start</h3> <h3 class="mb-4 text-2xl font-semibold">Easy to Start</h3>
<p class="text-base text-gray-500"> <p class="text-base text-gray-500">
Run on your own host using Docker container, or deploy on your favorite managed PaaS with a single <a href="https://www.docuseal.co/install" class="link link-neutral font-bold">click</a>. Run on your own host using Docker container, or deploy on your favorite managed PaaS with a single <a href="https://www.docuseal.com/install" class="link link-neutral font-bold">click</a>.
</p> </p>
</div> </div>
</div> </div>
+1 -1
View File
@@ -1,3 +1,3 @@
<a href="<%= Docuseal::GITHUB_URL %>" target="_blank" class="inline"> <a href="<%= Docuseal::GITHUB_URL %>" target="_blank" class="inline">
<img alt="GitHub Repo stars" src="https://www.docuseal.co/github-badge.svg" style="height: 22px"> <img alt="GitHub Repo stars" src="https://www.docuseal.com/github-badge.svg" style="height: 22px">
</a> </a>
+3 -3
View File
@@ -56,7 +56,7 @@
<%= link_to 'API', settings_api_index_path, class: 'text-base hover:bg-base-300' %> <%= link_to 'API', settings_api_index_path, class: 'text-base hover:bg-base-300' %>
</li> </li>
<% end %> <% end %>
<% if can?(:read, EncryptedConfig.new(key: EncryptedConfig::WEBHOOK_URL_KEY, account: current_account)) %> <% if can?(:read, WebhookUrl) %>
<li> <li>
<%= link_to 'Webhooks', settings_webhooks_path, class: 'text-base hover:bg-base-300' %> <%= link_to 'Webhooks', settings_webhooks_path, class: 'text-base hover:bg-base-300' %>
</li> </li>
@@ -72,13 +72,13 @@
<% end %> <% end %>
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) && (current_user != true_user || !current_account.testing?) %> <% if !Docuseal.demo? && can?(:manage, EncryptedConfig) && (current_user != true_user || !current_account.testing?) %>
<li> <li>
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/api") : "#{Docuseal::CONSOLE_URL}/on_premise", class: 'text-base hover:bg-base-300', data: { prefetch: false } do %> <%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}#{'/test' if current_account.testing?}/api") : "#{Docuseal::CONSOLE_URL}/on_premise", class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
<% if Docuseal.multitenant? %> API <% else %> <%= t('console') %> <% end %> <% if Docuseal.multitenant? %> API <% else %> <%= t('console') %> <% end %>
<% end %> <% end %>
</li> </li>
<% if Docuseal.multitenant? %> <% if Docuseal.multitenant? %>
<li> <li>
<%= link_to console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/embedding/form"), class: 'text-base hover:bg-base-300', data: { prefetch: false } do %> <%= link_to console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}#{'/test' if current_account.testing?}/embedding/form"), class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
<%= t('embedding') %> <%= t('embedding') %>
<% end %> <% end %>
</li> </li>
+2 -1
View File
@@ -1,5 +1,6 @@
<% align = field.dig('preferences', 'align') %> <% align = field.dig('preferences', 'align') %>
<field-value dir="auto" class="flex absolute text-[1.6vw] lg:text-base <%= 'font-serif' if field.dig('preferences', 'font') == 'Times' %> <%= align == 'right' ? 'justify-end' : (align == 'center' ? 'justify-center' : '') %>" style="width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%; <%= "font-size: clamp(4pt, 1.6vw, #{field['preferences']['font_size']}pt); line-height: `clamp(6pt, 2.0vw, #{field['preferences']['font_size'].to_i + 3}pt)`" if field.dig('preferences', 'font_size') %>"> <% color = field.dig('preferences', 'color') %>
<field-value dir="auto" class="flex absolute text-[1.6vw] lg:text-base <%= 'font-serif' if field.dig('preferences', 'font') == 'Times' %> <%= align == 'right' ? 'justify-end' : (align == 'center' ? 'justify-center' : '') %>" style="<%= "color: #{color}; " if color.present? %>width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%; <%= "font-size: clamp(4pt, 1.6vw, #{field['preferences']['font_size'].to_i * 1.23}pt); line-height: `clamp(6pt, 2.0vw, #{(field['preferences']['font_size'].to_i * 1.23) + 3}pt)`" if field.dig('preferences', 'font_size') %>">
<% if field['type'] == 'signature' %> <% if field['type'] == 'signature' %>
<div class="flex flex-col justify-between h-full overflow-hidden"> <div class="flex flex-col justify-between h-full overflow-hidden">
<div class="flex-grow flex overflow-hidden" style="min-height: 50%"> <div class="flex-grow flex overflow-hidden" style="min-height: 50%">
+7 -3
View File
@@ -9,14 +9,18 @@
<span class="text-xl md:text-3xl font-semibold focus:text-clip" style="overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;"><% @submission.template.name.split(/(_)/).each do |item| %><%= item %><wbr><% end %></span> <span class="text-xl md:text-3xl font-semibold focus:text-clip" style="overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;"><% @submission.template.name.split(/(_)/).each do |item| %><%= item %><wbr><% end %></span>
</a> </a>
<div class="space-x-3 flex items-center"> <div class="space-x-3 flex items-center">
<% last_submitter = @submission.submitters.to_a.select(&:completed_at?).max_by(&:completed_at) %>
<% is_all_completed = @submission.submitters.to_a.all?(&:completed_at?) %>
<% if signed_in? && can?(:create, @submission) && @submission.archived_at? && !is_all_completed %>
<%= button_to button_title(title: t('unarchive'), disabled_with: t('unarchive')[0..-2], icon: svg_icon('rotate', class: 'w-6 h-6')), submission_unarchive_index_path(@submission), class: 'btn btn-primary btn-ghost text-base hidden md:flex' %>
<% end %>
<% if @submission.audit_trail.present? %> <% if @submission.audit_trail.present? %>
<a href="<%= ActiveStorage::Blob.proxy_url(@submission.audit_trail.blob, expires_at: 4.hours.from_now) %>" class="white-button" target="_blank"> <a href="<%= ActiveStorage::Blob.proxy_url(@submission.audit_trail.blob, expires_at: 4.hours.from_now) %>" class="white-button" target="_blank">
<%= svg_icon('external_link', class: 'w-6 h-6') %> <%= svg_icon('external_link', class: 'w-6 h-6') %>
<span class="hidden md:inline"><%= t('audit_log') %></span> <span class="hidden md:inline"><%= t('audit_log') %></span>
</a> </a>
<% end %> <% end %>
<% if last_submitter = @submission.submitters.to_a.select(&:completed_at?).max_by(&:completed_at) %> <% if last_submitter %>
<% is_all_completed = @submission.submitters.to_a.all?(&:completed_at?) %>
<% if is_all_completed || !is_combined_enabled %> <% if is_all_completed || !is_combined_enabled %>
<div class="join relative"> <div class="join relative">
<download-button data-src="<%= submitter_download_index_path(last_submitter.slug, { sig: params[:sig], combined: is_combined_enabled }.compact) %>" class="base-button <%= '!rounded-r-none !pr-2' if is_all_completed && !is_combined_enabled %>"> <download-button data-src="<%= submitter_download_index_path(last_submitter.slug, { sig: params[:sig], combined: is_combined_enabled }.compact) %>" class="base-button <%= '!rounded-r-none !pr-2' if is_all_completed && !is_combined_enabled %>">
@@ -54,7 +58,7 @@
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
<% elsif @submission.submitters.to_a.size == 1 && !@submission.expired? && !@submission.submitters.to_a.first.declined_at? %> <% elsif @submission.submitters.to_a.size == 1 && !@submission.expired? && !@submission.submitters.to_a.first.declined_at? && !@submission.archived_at? %>
<%= render 'shared/clipboard_copy', text: submit_form_url(slug: @submission.submitters.to_a.first.slug), class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy_share_link'), copied_title: t('copied_to_clipboard') %> <%= render 'shared/clipboard_copy', text: submit_form_url(slug: @submission.submitters.to_a.first.slug), class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy_share_link'), copied_title: t('copied_to_clipboard') %>
<% end %> <% end %>
</div> </div>
@@ -15,9 +15,10 @@
<%= render 'shared/posthog' if ENV['POSTHOG_TOKEN'] %> <%= render 'shared/posthog' if ENV['POSTHOG_TOKEN'] %>
</head> </head>
<body> <body>
<draw-signature data-slug="<%= params[:slug] %>" class="flex items-center h-screen p-2 justify-center"> <% field = (@submitter.submission.template_fields || @submitter.template.fields).find { |f| f['type'] == 'signature' && f['uuid'].starts_with?(params[:f]) } %>
<draw-signature data-slug="<%= params[:slug] %>" data-color="<%= field.dig('preferences', 'color') %>" class="flex items-center h-screen p-2 justify-center">
<%= form_for '', url: submit_form_path(params[:slug]), html: { style: 'max-width: 900px; width: 100%; margin-bottom: 120px' }, method: :put do |f| %> <%= form_for '', url: submit_form_path(params[:slug]), html: { style: 'max-width: 900px; width: 100%; margin-bottom: 120px' }, method: :put do |f| %>
<input value="" type="hidden" name="values[<%= (@submitter.submission.template_fields || @submitter.template.fields).find { |f| f['type'] == 'signature' && f['uuid'].starts_with?(params[:f]) }['uuid'] %>]"> <input value="" type="hidden" name="values[<%= field['uuid'] %>]">
<div class="font-semibold text-4xl text-center w-full mb-2"> <div class="font-semibold text-4xl text-center w-full mb-2">
<%= t('draw_signature') %> <%= t('draw_signature') %>
</div> </div>
+2 -2
View File
@@ -51,7 +51,7 @@
<a href="<%= submission_path(submission) %>" class="text-lg break-all peer"> <a href="<%= submission_path(submission) %>" class="text-lg break-all peer">
<%= submitter.name || submitter.email || submitter.phone %> <%= submitter.name || submitter.email || submitter.phone %>
</a> </a>
<% if can?(:update, submitter) && !submitter.start_form_submission_events.any? && !submission.archived_at? && !submission.expired? %> <% if can?(:update, submitter) && !submitter.start_form_submission_events.any? && !submission.archived_at? && !submission.expired? && !submitter.declined_at? %>
<span class="pl-0.5 tooltip tooltip-top md:opacity-0 md:hover:opacity-100 md:peer-hover:opacity-100" data-tip="<%= t('edit') %>"> <span class="pl-0.5 tooltip tooltip-top md:opacity-0 md:hover:opacity-100 md:peer-hover:opacity-100" data-tip="<%= t('edit') %>">
<%= link_to edit_submitter_path(submitter), class: 'shrink-0', data: { turbo_frame: 'modal' } do %> <%= link_to edit_submitter_path(submitter), class: 'shrink-0', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('pencil', class: 'w-5 h-5') %> <%= svg_icon('pencil', class: 'w-5 h-5') %>
@@ -144,7 +144,7 @@
<a href="<%= submission_path(submission) %>" class="text-lg break-all peer"> <a href="<%= submission_path(submission) %>" class="text-lg break-all peer">
<%= submitter.name || submitter.email || submitter.phone %> <%= submitter.name || submitter.email || submitter.phone %>
</a> </a>
<% if can?(:update, submitter) && !submitter.start_form_submission_events.any? && !submission.archived_at? && !submission.expired? %> <% if can?(:update, submitter) && !submitter.start_form_submission_events.any? && !submission.archived_at? && !submission.expired? && !submitter.declined_at? %>
<span class="pl-0.5 tooltip tooltip-top md:opacity-0 md:hover:opacity-100 md:peer-hover:opacity-100" data-tip="<%= t('edit') %>"> <span class="pl-0.5 tooltip tooltip-top md:opacity-0 md:hover:opacity-100 md:peer-hover:opacity-100" data-tip="<%= t('edit') %>">
<%= link_to edit_submitter_path(submitter), class: 'shrink-0', data: { turbo_frame: 'modal' } do %> <%= link_to edit_submitter_path(submitter), class: 'shrink-0', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('pencil', class: 'w-5 h-5') %> <%= svg_icon('pencil', class: 'w-5 h-5') %>
@@ -8,10 +8,10 @@
<%= render 'shared/clipboard_copy', icon: 'copy', text: current_user.access_token.token, class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %> <%= render 'shared/clipboard_copy', icon: 'copy', text: current_user.access_token.token, class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
</div> </div>
</div> </div>
<%= form_for @webhook_config, url: settings_webhooks_path, method: :post, html: { autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %> <%= form_for @webhook_url, url: settings_webhooks_path, method: :post, html: { autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
<%= f.label :value, 'Webhook URL', class: 'text-sm font-semibold' %> <%= f.label :url, 'Webhook URL', class: 'text-sm font-semibold' %>
<div class="space-y-2 md:flex-nowrap mt-2"> <div class="space-y-2 md:flex-nowrap mt-2">
<%= f.url_field :value, class: 'base-input w-full', placeholder: 'https://example.com/hook' %> <%= f.url_field :url, class: 'base-input w-full', placeholder: 'https://example.com/hook' %>
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button w-full' %> <%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button w-full' %>
</div> </div>
<% end %> <% end %>
@@ -1,13 +1,13 @@
<%= render 'shared/turbo_modal', title: 'Webhook Secret' do %> <%= render 'shared/turbo_modal', title: t('webhook_secret') do %>
<%= form_for @encrypted_config, url: webhook_secret_index_path, method: :post, html: { class: 'space-y-4' }, data: { turbo_frame: :_top } do |f| %> <%= form_for @webhook_url, url: webhook_secret_path, method: :patch, html: { class: 'space-y-4' }, data: { turbo_frame: :_top } do |f| %>
<div class="space-y-2"> <div class="space-y-2">
<%= f.fields_for :value, Struct.new(:key, :value).new(*@encrypted_config.value.to_a.first) do |ff| %> <%= f.fields_for :secret, Struct.new(:key, :value).new(*@webhook_url.secret.to_a.first) do |ff| %>
<div class="form-control"> <div class="form-control">
<%= ff.label :key, class: 'label' %> <%= ff.label :key, t('key'), class: 'label' %>
<%= ff.text_field :key, class: 'base-input', placeholder: 'X-Example-Header' %> <%= ff.text_field :key, class: 'base-input', placeholder: 'X-Example-Header' %>
</div> </div>
<div class="form-control"> <div class="form-control">
<%= ff.label :value, class: 'label' %> <%= ff.label :value, t('value'), class: 'label' %>
<%= ff.text_field :value, class: 'base-input' %> <%= ff.text_field :value, class: 'base-input' %>
</div> </div>
<% end %> <% end %>
+34
View File
@@ -0,0 +1,34 @@
<div class="flex flex-wrap space-y-4 md:flex-nowrap md:space-y-0 md:space-x-10">
<%= render 'shared/settings_nav' %>
<div class="flex-grow min-w-0">
<div class="flex flex-col gap-2 md:flex-row md:justify-between md:items-center mb-4">
<h1 class="text-4xl font-bold">Webhooks</h1>
<div class="flex flex-col gap-2 md:flex-row md:justify-between md:items-center">
<%= render 'shared/test_mode_toggle' %>
<% if @webhook_url.persisted? %>
<%= link_to new_settings_webhook_path, class: 'md:ml-3 btn bg-white btn-outline btn-md gap-2 w-full md:w-fit', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('plus', class: 'w-6 h-6') %>
<span><%= t('new_webhook') %></span>
<% end %>
<% end %>
</div>
</div>
<div class="space-y-4">
<% @webhook_urls.each do |webhook_url| %>
<%= link_to settings_webhook_path(webhook_url), class: 'card bg-base-200' do %>
<div class="card-body p-6 min-w-0">
<p class="flex items-center space-x-1">
<%= svg_icon('world', class: 'w-6 h-6 shrink-0') %>
<span class="text-xl font-semibold truncate"><%= webhook_url.url %></span>
</p>
<div class="flex flex-wrap gap-2 mt-2">
<% webhook_url.events.each do |event| %>
<span class="badge badge-outline"><%= event %></span>
<% end %>
</div>
</div>
<% end %>
<% end %>
</div>
</div>
</div>
+29
View File
@@ -0,0 +1,29 @@
<%= render 'shared/turbo_modal', title: t('new_webhook') do %>
<%= form_for @webhook_url, url: settings_webhooks_path, html: { class: 'space-y-4' }, data: { turbo_frame: :_top } do |f| %>
<div class="space-y-4">
<div class="form-control">
<%= f.label :url, 'Webhook URL', class: 'label' %>
<%= f.url_field :url, class: 'base-input', placeholder: 'https://example.com/hook', required: true %>
</div>
<div class="space-y-4">
<% WebhookUrl::EVENTS.group_by { |e| e.include?('form') }.each do |_, events| %>
<div class="grid grid-cols-1 md:grid-cols-2 gap-y-2">
<%= f.collection_check_boxes(:events, events, :to_s, :to_s, include_hidden: false) do |b| %>
<div class="flex">
<label class="flex items-center space-x-2">
<%= b.check_box class: 'base-checkbox', checked: @webhook_url.events.include?(b.value) %>
<span>
<%= b.label %>
</span>
</label>
</div>
<% end %>
</div>
<% end %>
</div>
</div>
<div class="form-control pt-2">
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %>
</div>
<% end %>
<% end %>
+71 -30
View File
@@ -1,40 +1,81 @@
<div class="flex flex-wrap space-y-4 md:flex-nowrap md:space-y-0 md:space-x-10"> <div class="flex flex-wrap space-y-4 md:flex-nowrap md:space-y-0 md:space-x-10">
<%= render 'shared/settings_nav' %> <%= render 'shared/settings_nav' %>
<div class="flex-grow"> <div class="flex-grow">
<div class="flex flex-col gap-2 md:flex-row md:justify-between md:items-end mb-4"> <div class="flex flex-col gap-2 md:flex-row md:justify-between md:items-center mb-4">
<h1 class="text-4xl font-bold">Webhooks</h1> <h1 class="text-4xl font-bold">Webhook</h1>
<%= render 'shared/test_mode_toggle' %> <div class="flex flex-col gap-2 md:flex-row md:justify-between md:items-center">
<% if params[:action] == 'index' %>
<%= render 'shared/test_mode_toggle' %>
<% end %>
<% if @webhook_url.persisted? && params[:action] == 'index' %>
<%= link_to new_settings_webhook_path, class: 'md:ml-3 btn bg-white btn-outline btn-md gap-2 w-full md:w-fit', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('plus', class: 'w-6 h-6') %>
<span><%= t('new_webhook') %></span>
<% end %>
<% end %>
</div>
</div> </div>
<div class="card bg-base-200"> <div class="card bg-base-200">
<div class="card-body p-6"> <div class="card-body p-6">
<%= form_for @encrypted_config, url: settings_webhooks_path, method: :post, html: { autocomplete: 'off' } do |f| %> <div class="flex flex-col gap-2 md:flex-row md:justify-between md:items-end md:relative">
<%= f.label :value, 'Webhook URL', class: 'text-sm font-semibold' %> <%= label_tag :url, 'Webhook URL', class: 'text-sm font-semibold' %>
<div class="flex flex-row flex-wrap space-y-2 md:space-y-0 md:flex-nowrap md:space-x-2 mt-2"> <% if @webhook_url.persisted? %>
<%= f.url_field :value, class: 'input font-mono input-bordered w-full', placeholder: 'https://example.com/hook' %> <div class="flex items-center space-x-2 md:absolute md:right-0">
<%= link_to webhook_secret_path(@webhook_url), class: 'btn btn-outline btn-sm bg-white', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('lock', class: 'w-4 h-4') %>
<span><%= @webhook_url.secret.present? ? t('edit_secret') : t('add_secret') %></span>
<% end %>
<div class="tooltip tooltip-left md:tooltip-top" data-tip="<%= t('delete_webhook') %>">
<%= button_to settings_webhook_path(@webhook_url), class: 'btn btn-warning btn-sm', method: :delete, data: { turbo_confirm: t('are_you_sure_') } do %>
<span><%= t('delete') %></span>
<% end %>
</div>
</div>
<% end %>
</div>
<%= form_for @webhook_url, url: @webhook_url.persisted? ? settings_webhook_path(@webhook_url) : settings_webhooks_path, html: { autocomplete: 'off' } do |f| %>
<div class="flex flex-row flex-wrap space-y-2 md:space-y-0 md:flex-nowrap md:space-x-2">
<%= f.url_field :url, class: 'input font-mono input-bordered w-full', placeholder: 'https://example.com/hook', required: true %>
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button w-full md:w-32' %> <%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button w-full md:w-32' %>
<a href="<%= webhook_secret_index_path %>" data-turbo-frame="modal" class="white-button w-full md:w-auto">
<%= t('add_secret') %>
</a>
</div> </div>
<% end %> <% unless @webhook_url.persisted? %>
<% preference = current_account.account_configs.find_by(key: AccountConfig::WEBHOOK_PREFERENCES_KEY)&.value || {} %> <div class="space-y-4 mt-4">
<% WebhookPreferencesController::EVENTS.group_by { |e| e.include?('form') }.each do |_, events| %> <% WebhookUrl::EVENTS.group_by { |e| e.include?('form') }.each do |_, events| %>
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 mt-2 gap-y-2"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-y-2">
<% events.each do |event| %> <%= f.collection_check_boxes(:events, events, :to_s, :to_s, include_hidden: false) do |b| %>
<%= form_for '', url: webhook_preferences_path, method: :post do |f| %> <div class="flex">
<%= f.hidden_field :event, value: event %> <label class="flex items-center space-x-2">
<% uuid = SecureRandom.uuid %> <%= b.check_box class: 'base-checkbox', checked: @webhook_url.events.include?(b.value) %>
<div class="flex"> <span>
<label for="<%= uuid %>" class="flex items-center space-x-2"> <%= b.label %>
<%= f.check_box :value, class: 'base-checkbox', checked: preference[event] || (!preference.key?(event) && event.starts_with?('form.')), onchange: 'this.form.requestSubmit()', id: uuid %> </span>
<span> </label>
<%= event %> </div>
</span> <% end %>
</label>
</div> </div>
<% end %> <% end %>
<% end %> </div>
</div> <% end %>
<% end %>
<% if @webhook_url.persisted? %>
<%= form_for @webhook_url, url: webhook_preference_path(@webhook_url), method: :put, html: { autocomplete: 'off', class: 'mt-2' } do |f| %>
<div class="space-y-4">
<% WebhookUrl::EVENTS.group_by { |e| e.include?('form') }.each do |_, events| %>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-y-4">
<% events.each do |event| %>
<%= f.fields_for :events do |ff| %>
<div class="flex">
<label class="flex items-center cursor-pointer">
<%= ff.check_box event, class: 'base-checkbox', checked: @webhook_url.events.include?(event), onchange: 'this.form.requestSubmit()' %>
<span class="ml-2"><%= event %></span>
</label>
</div>
<% end %>
<% end %>
</div>
<% end %>
</div>
<% end %>
<% end %> <% end %>
</div> </div>
</div> </div>
@@ -43,12 +84,12 @@
<div class="space-y-4 mt-4"> <div class="space-y-4 mt-4">
<div class="collapse collapse-open bg-base-200 px-1"> <div class="collapse collapse-open bg-base-200 px-1">
<div class="p-4 text-xl font-medium"> <div class="p-4 text-xl font-medium">
<div class="flex items-center justify-between gap-2"> <div class="flex flex-col gap-2 md:flex-row md:justify-between md:items-center md:h-8">
<span> <span>
<%= t('submission_example_payload') %> <%= t('submission_example_payload') %>
</span> </span>
<% if @encrypted_config.value.present? %> <% if @webhook_url.url.present? && @webhook_url.events.include?('form.completed') %>
<%= button_to button_title(title: 'Test Webhook', disabled_with: t('sending'), icon_disabled: svg_icon('loader', class: 'w-4 h-4 animate-spin')), settings_webhooks_path, class: 'btn btn-neutral btn-outline btn-sm', method: :put %> <%= button_to button_title(title: 'Test Webhook', disabled_with: t('sending'), icon_disabled: svg_icon('loader', class: 'w-4 h-4 animate-spin')), settings_webhook_resend_path(@webhook_url), class: 'btn btn-neutral btn-outline btn-sm', method: :post %>
<% end %> <% end %>
</div> </div>
</div> </div>
+80 -21
View File
@@ -20,6 +20,7 @@ en: &en
language_ko: 한국어 language_ko: 한국어
hi_there: Hi there hi_there: Hi there
thanks: Thanks thanks: Thanks
unarchive: Unarchive
first_party: 'First Party' first_party: 'First Party'
docuseal_trusted_signature: DocuSeal Trusted Signature docuseal_trusted_signature: DocuSeal Trusted Signature
hello_name: Hello %{name} hello_name: Hello %{name}
@@ -119,7 +120,7 @@ en: &en
profile_details: Profile Details profile_details: Profile Details
sign_up_with_google: Sign up with Google sign_up_with_google: Sign up with Google
sign_up_with_microsoft: Sign up with Microsoft sign_up_with_microsoft: Sign up with Microsoft
by_creating_an_account_you_agree_to_our_html: 'By creating an account, you agree to our <a target="_blank" href="https://www.docuseal.co/privacy">Privacy Policy</a> and <a target="_blank" href="https://www.docuseal.co/terms">Terms of Service</a>.' by_creating_an_account_you_agree_to_our_html: 'By creating an account, you agree to our <a target="_blank" href="https://www.docuseal.com/privacy">Privacy Policy</a> and <a target="_blank" href="https://www.docuseal.com/terms">Terms of Service</a>.'
enter_email_to_continue: Enter email to continue enter_email_to_continue: Enter email to continue
account: Account account: Account
preferences: Preferences preferences: Preferences
@@ -473,6 +474,7 @@ en: &en
logo: Logo logo: Logo
back: Back back: Back
add_secret: Add Secret add_secret: Add Secret
edit_secret: Edit Secret
submission_example_payload: Submission example payload submission_example_payload: Submission example payload
there_are_no_signatures: There are no signatures there_are_no_signatures: There are no signatures
signed_with_trusted_certificate: Signed with trusted certificate signed_with_trusted_certificate: Signed with trusted certificate
@@ -498,6 +500,7 @@ en: &en
document_template_has_been_moved: Document template has been moved. document_template_has_been_moved: Document template has been moved.
unable_to_move_template_into_folder: Unable to move template into folder. unable_to_move_template_into_folder: Unable to move template into folder.
template_has_been_unarchived: Template has been unarchived. template_has_been_unarchived: Template has been unarchived.
submission_has_been_unarchived: Submission has been unarchived.
unable_to_update_file: Unable to upload file. unable_to_update_file: Unable to upload file.
user_has_been_invited: User has been invited. user_has_been_invited: User has been invited.
unable_to_update_user: Unable to update user. unable_to_update_user: Unable to update user.
@@ -508,13 +511,17 @@ en: &en
webhook_secret_has_been_saved: Webhook Secret has been saved. webhook_secret_has_been_saved: Webhook Secret has been saved.
webhook_url_has_been_saved: Webhook URL has been saved. webhook_url_has_been_saved: Webhook URL has been saved.
webhook_request_has_been_sent: Webhook request has been sent. webhook_request_has_been_sent: Webhook request has been sent.
webhook_url_has_been_updated: Webhook URL has been updated.
webhook_url_has_been_deleted: Webhook URL has been deleted.
unable_to_resend_webhook_request: Unable to resend webhook request.
new_webhook: New Webhook
delete_webhook: Delete webhook
count_submissions_have_been_created: '%{count} submissions have been created.' count_submissions_have_been_created: '%{count} submissions have been created.'
gmail_has_been_connected: Gmail has been connected gmail_has_been_connected: Gmail has been connected
microsoft_account_has_been_connected: Microsoft Account has been connected microsoft_account_has_been_connected: Microsoft Account has been connected
sms_length_cant_be_longer_than_120_bytes: SMS length can't be longer than 120 bytes sms_length_cant_be_longer_than_120_bytes: SMS length can't be longer than 120 bytes
connected_successfully: Connected successfully. connected_successfully: Connected successfully.
user_nameid_not_found: 'User %{nameid} not found.' user_nameid_not_found: 'User %{nameid} not found.'
webhook_request_has_been_sent: Webhook request has been sent.
sso_settings_have_been_updated: SSO settings have been updated. sso_settings_have_been_updated: SSO settings have been updated.
sms_has_been_sent: SMS has been sent. sms_has_been_sent: SMS has been sent.
account_has_been_created: Account has been created. account_has_been_created: Account has been created.
@@ -621,7 +628,9 @@ en: &en
archived_users: Archived Users archived_users: Archived Users
embedding_users: Embedding Users embedding_users: Embedding Users
view_embedding_users: View Embedding Users view_embedding_users: View Embedding Users
view_users: View Users key: Key
value: Value
webhook_secret: Webhook Secret
submission_event_names: submission_event_names:
send_email_to_html: '<b>Email sent</b> to %{submitter_name}' send_email_to_html: '<b>Email sent</b> to %{submitter_name}'
send_reminder_email_to_html: '<b>Reminder email sent</b> to %{submitter_name}' send_reminder_email_to_html: '<b>Reminder email sent</b> to %{submitter_name}'
@@ -658,6 +667,7 @@ en: &en
read: Read your data read: Read your data
es: &es es: &es
unarchive: Desarchivar
awaiting_completion_by_the_other_party: "Esperando la finalización por la otra parte" awaiting_completion_by_the_other_party: "Esperando la finalización por la otra parte"
enforce_recipients_order: 'Hacer cumplir el orden de los destinatarios' enforce_recipients_order: 'Hacer cumplir el orden de los destinatarios'
first_party: 'Primera Parte' first_party: 'Primera Parte'
@@ -759,7 +769,7 @@ es: &es
profile_details: Detalles del perfil profile_details: Detalles del perfil
sign_up_with_google: Registrarse con Google sign_up_with_google: Registrarse con Google
sign_up_with_microsoft: Registrarse con Microsoft sign_up_with_microsoft: Registrarse con Microsoft
by_creating_an_account_you_agree_to_our_html: 'Al crear una cuenta, aceptas nuestra <a target="_blank" href="https://www.docuseal.co/privacy">Política de Privacidad</a> y <a target="_blank" href="https://www.docuseal.co/terms">Términos de Servicio</a>.' by_creating_an_account_you_agree_to_our_html: 'Al crear una cuenta, aceptas nuestra <a target="_blank" href="https://www.docuseal.com/privacy">Política de Privacidad</a> y <a target="_blank" href="https://www.docuseal.com/terms">Términos de Servicio</a>.'
enter_email_to_continue: Ingresa tu correo electrónico para continuar enter_email_to_continue: Ingresa tu correo electrónico para continuar
account: Cuenta account: Cuenta
preferences: Preferencias preferences: Preferencias
@@ -1113,6 +1123,7 @@ es: &es
logo: Logotipo logo: Logotipo
back: Atrás back: Atrás
add_secret: Agregar secreto add_secret: Agregar secreto
edit_secret: Editar secreto
submission_example_payload: Ejemplo de payload de envío submission_example_payload: Ejemplo de payload de envío
there_are_no_signatures: No hay firmas there_are_no_signatures: No hay firmas
signed_with_trusted_certificate: Firmado con certificado de confianza signed_with_trusted_certificate: Firmado con certificado de confianza
@@ -1138,6 +1149,7 @@ es: &es
document_template_has_been_moved: La plantilla de documento ha sido movida. document_template_has_been_moved: La plantilla de documento ha sido movida.
unable_to_move_template_into_folder: No se pudo mover la plantilla a la carpeta. unable_to_move_template_into_folder: No se pudo mover la plantilla a la carpeta.
template_has_been_unarchived: La plantilla ha sido desarchivada. template_has_been_unarchived: La plantilla ha sido desarchivada.
submission_has_been_unarchived: La presentación ha sido desarchivada.
unable_to_update_file: No se pudo subir el archivo. unable_to_update_file: No se pudo subir el archivo.
user_has_been_invited: El usuario ha sido invitado. user_has_been_invited: El usuario ha sido invitado.
unable_to_update_user: No se pudo actualizar el usuario. unable_to_update_user: No se pudo actualizar el usuario.
@@ -1148,13 +1160,17 @@ es: &es
webhook_secret_has_been_saved: El secreto del Webhook ha sido guardado. webhook_secret_has_been_saved: El secreto del Webhook ha sido guardado.
webhook_url_has_been_saved: La URL del Webhook ha sido guardada. webhook_url_has_been_saved: La URL del Webhook ha sido guardada.
webhook_request_has_been_sent: La solicitud del Webhook ha sido enviada. webhook_request_has_been_sent: La solicitud del Webhook ha sido enviada.
webhook_url_has_been_updated: La URL del Webhook ha sido actualizada.
webhook_url_has_been_deleted: La URL del Webhook ha sido eliminada.
unable_to_resend_webhook_request: No se pudo reenviar la solicitud del webhook.
new_webhook: Nuevo Webhook
delete_webhook: Eliminar webhook
count_submissions_have_been_created: '%{count} envíos han sido creados.' count_submissions_have_been_created: '%{count} envíos han sido creados.'
gmail_has_been_connected: Gmail ha sido conectado. gmail_has_been_connected: Gmail ha sido conectado.
microsoft_account_has_been_connected: La cuenta de Microsoft ha sido conectada. microsoft_account_has_been_connected: La cuenta de Microsoft ha sido conectada.
sms_length_cant_be_longer_than_120_bytes: La longitud del SMS no puede ser mayor a 120 bytes. sms_length_cant_be_longer_than_120_bytes: La longitud del SMS no puede ser mayor a 120 bytes.
connected_successfully: Conectado con éxito. connected_successfully: Conectado con éxito.
user_nameid_not_found: 'Usuario %{nameid} no encontrado.' user_nameid_not_found: 'Usuario %{nameid} no encontrado.'
webhook_request_has_been_sent: La solicitud del Webhook ha sido enviada.
sso_settings_have_been_updated: La configuración de SSO ha sido actualizada. sso_settings_have_been_updated: La configuración de SSO ha sido actualizada.
sms_has_been_sent: El SMS ha sido enviado. sms_has_been_sent: El SMS ha sido enviado.
account_has_been_created: La cuenta ha sido creada. account_has_been_created: La cuenta ha sido creada.
@@ -1261,6 +1277,9 @@ es: &es
archived_users: Usuarios Archivados archived_users: Usuarios Archivados
embedding_users: Usuarios Integrados embedding_users: Usuarios Integrados
view_embedding_users: Ver Usuarios Integrado view_embedding_users: Ver Usuarios Integrado
key: Clave
value: Valor
webhook_secret: Secreto del Webhook
submission_event_names: submission_event_names:
send_email_to_html: '<b>Correo electrónico enviado</b> a %{submitter_name}' send_email_to_html: '<b>Correo electrónico enviado</b> a %{submitter_name}'
send_reminder_email_to_html: '<b>Correo de recordatorio enviado</b> a %{submitter_name}' send_reminder_email_to_html: '<b>Correo de recordatorio enviado</b> a %{submitter_name}'
@@ -1297,6 +1316,7 @@ es: &es
read: Leer tus datos read: Leer tus datos
it: &it it: &it
unarchive: Ripristina
awaiting_completion_by_the_other_party: "In attesa di completamento da parte dell'altra parte" awaiting_completion_by_the_other_party: "In attesa di completamento da parte dell'altra parte"
enforce_recipients_order: Aplicar el orden de los destinatarios enforce_recipients_order: Aplicar el orden de los destinatarios
first_party: 'Prima parte' first_party: 'Prima parte'
@@ -1398,7 +1418,7 @@ it: &it
profile_details: Dettagli del profilo profile_details: Dettagli del profilo
sign_up_with_google: Registrati con Google sign_up_with_google: Registrati con Google
sign_up_with_microsoft: Registrati con Microsoft sign_up_with_microsoft: Registrati con Microsoft
by_creating_an_account_you_agree_to_our_html: 'Creando un account, accetti la nostra <a target="_blank" href="https://www.docuseal.co/privacy">Privacy Policy</a> e i nostri <a target="_blank" href="https://www.docuseal.co/terms">Termini di servizio</a>.' by_creating_an_account_you_agree_to_our_html: 'Creando un account, accetti la nostra <a target="_blank" href="https://www.docuseal.com/privacy">Privacy Policy</a> e i nostri <a target="_blank" href="https://www.docuseal.com/terms">Termini di servizio</a>.'
enter_email_to_continue: "Inserisci l'email per continuare" enter_email_to_continue: "Inserisci l'email per continuare"
account: Account account: Account
preferences: Preferenze preferences: Preferenze
@@ -1752,6 +1772,7 @@ it: &it
logo: Logo logo: Logo
back: Indietro back: Indietro
add_secret: Aggiungi segreto add_secret: Aggiungi segreto
edit_secret: Modifica segreto
submission_example_payload: Esempio di payload di invio submission_example_payload: Esempio di payload di invio
there_are_no_signatures: Non ci sono firme there_are_no_signatures: Non ci sono firme
signed_with_trusted_certificate: Firmato con certificato affidabile signed_with_trusted_certificate: Firmato con certificato affidabile
@@ -1777,6 +1798,7 @@ it: &it
document_template_has_been_moved: Il modello di documento è stato spostato. document_template_has_been_moved: Il modello di documento è stato spostato.
unable_to_move_template_into_folder: Impossibile spostare il modello nella cartella. unable_to_move_template_into_folder: Impossibile spostare il modello nella cartella.
template_has_been_unarchived: Il modello è stato disarchiviato. template_has_been_unarchived: Il modello è stato disarchiviato.
submission_has_been_unarchived: La sottomissione è stata dearchiviata.
unable_to_update_file: Impossibile caricare il file. unable_to_update_file: Impossibile caricare il file.
user_has_been_invited: "L'utente è stato invitato." user_has_been_invited: "L'utente è stato invitato."
unable_to_update_user: Impossibile aggiornare l'utente. unable_to_update_user: Impossibile aggiornare l'utente.
@@ -1787,13 +1809,17 @@ it: &it
webhook_secret_has_been_saved: Il segreto del Webhook è stato salvato. webhook_secret_has_been_saved: Il segreto del Webhook è stato salvato.
webhook_url_has_been_saved: "L'URL del Webhook è stato salvato." webhook_url_has_been_saved: "L'URL del Webhook è stato salvato."
webhook_request_has_been_sent: La richiesta del Webhook è stata inviata. webhook_request_has_been_sent: La richiesta del Webhook è stata inviata.
webhook_url_has_been_updated: "L'URL del Webhook è stata aggiornata."
webhook_url_has_been_deleted: "L'URL del Webhook è stata eliminata."
unable_to_resend_webhook_request: Impossibile reinviare la richiesta del webhook.
new_webhook: Nuovo Webhook
delete_webhook: Elimina webhook
count_submissions_have_been_created: '%{count} invii sono stati creati.' count_submissions_have_been_created: '%{count} invii sono stati creati.'
gmail_has_been_connected: Gmail è stato connesso. gmail_has_been_connected: Gmail è stato connesso.
microsoft_account_has_been_connected: "L'account Microsoft è stato connesso." microsoft_account_has_been_connected: "L'account Microsoft è stato connesso."
sms_length_cant_be_longer_than_120_bytes: "La lunghezza dell'SMS non può superare i 120 byte." sms_length_cant_be_longer_than_120_bytes: "La lunghezza dell'SMS non può superare i 120 byte."
connected_successfully: Collegamento avvenuto con successo. connected_successfully: Collegamento avvenuto con successo.
user_nameid_not_found: 'Utente %{nameid} non trovato.' user_nameid_not_found: 'Utente %{nameid} non trovato.'
webhook_request_has_been_sent: La richiesta del Webhook è stata inviata.
sso_settings_have_been_updated: Le impostazioni SSO sono state aggiornate. sso_settings_have_been_updated: Le impostazioni SSO sono state aggiornate.
sms_has_been_sent: "L'SMS è stato inviato." sms_has_been_sent: "L'SMS è stato inviato."
account_has_been_created: "L'account è stato creato." account_has_been_created: "L'account è stato creato."
@@ -1900,6 +1926,9 @@ it: &it
archived_users: Utenti Archiviati archived_users: Utenti Archiviati
embedding_users: Utenti Incorporati embedding_users: Utenti Incorporati
view_embedding_users: Visualizza Utenti Incorporati view_embedding_users: Visualizza Utenti Incorporati
key: Chiave
value: Valore
webhook_secret: Segreto del Webhook
submission_event_names: submission_event_names:
send_email_to_html: '<b>E-mail inviato</b> a %{submitter_name}' send_email_to_html: '<b>E-mail inviato</b> a %{submitter_name}'
send_reminder_email_to_html: '<b>E-mail di promemoria inviato</b> a %{submitter_name}' send_reminder_email_to_html: '<b>E-mail di promemoria inviato</b> a %{submitter_name}'
@@ -1936,6 +1965,7 @@ it: &it
read: Leggi i tuoi dati read: Leggi i tuoi dati
fr: &fr fr: &fr
unarchive: Désarchiver
awaiting_completion_by_the_other_party: "En attente de la complétion par l'autre partie" awaiting_completion_by_the_other_party: "En attente de la complétion par l'autre partie"
enforce_recipients_order: "Respecter l'ordre des destinataires" enforce_recipients_order: "Respecter l'ordre des destinataires"
first_party: 'Première partie' first_party: 'Première partie'
@@ -2038,7 +2068,7 @@ fr: &fr
sign_up_with_google: S'inscrire avec Google sign_up_with_google: S'inscrire avec Google
sign_up_with_microsoft: S'inscrire avec Microsoft sign_up_with_microsoft: S'inscrire avec Microsoft
by_creating_an_account_you_agree_to_our_html: | by_creating_an_account_you_agree_to_our_html: |
En créant un compte, vous acceptez notre <a target="_blank" href="https://www.docuseal.co/privacy">Politique de Confidentialité</a> et nos <a target="_blank" href="https://www.docuseal.co/terms">Conditions d'Utilisation</a>. En créant un compte, vous acceptez notre <a target="_blank" href="https://www.docuseal.com/privacy">Politique de Confidentialité</a> et nos <a target="_blank" href="https://www.docuseal.com/terms">Conditions d'Utilisation</a>.
enter_email_to_continue: Entrez votre e-mail pour continuer enter_email_to_continue: Entrez votre e-mail pour continuer
account: Compte account: Compte
preferences: Préférences preferences: Préférences
@@ -2392,6 +2422,7 @@ fr: &fr
logo: Logo logo: Logo
back: Retour back: Retour
add_secret: Ajouter un secret add_secret: Ajouter un secret
edit_secret: Modifier le Secret
submission_example_payload: Exemple de payload de soumission submission_example_payload: Exemple de payload de soumission
there_are_no_signatures: "Il n'y a pas de signatures" there_are_no_signatures: "Il n'y a pas de signatures"
signed_with_trusted_certificate: Signé avec un certificat de confiance signed_with_trusted_certificate: Signé avec un certificat de confiance
@@ -2417,6 +2448,7 @@ fr: &fr
document_template_has_been_moved: Le modèle de document a été déplacé. document_template_has_been_moved: Le modèle de document a été déplacé.
unable_to_move_template_into_folder: Impossible de déplacer le modèle dans le dossier. unable_to_move_template_into_folder: Impossible de déplacer le modèle dans le dossier.
template_has_been_unarchived: Le modèle a été désarchivé. template_has_been_unarchived: Le modèle a été désarchivé.
submission_has_been_unarchived: La soumission a été désarchivée.
unable_to_update_file: Impossible de télécharger le fichier. unable_to_update_file: Impossible de télécharger le fichier.
user_has_been_updated: "L'utilisateur a été mis à jour." user_has_been_updated: "L'utilisateur a été mis à jour."
unable_to_remove_user: Impossible de supprimer l'utilisateur. unable_to_remove_user: Impossible de supprimer l'utilisateur.
@@ -2427,13 +2459,17 @@ fr: &fr
webhook_secret_has_been_saved: Le secret du Webhook a été enregistré. webhook_secret_has_been_saved: Le secret du Webhook a été enregistré.
webhook_url_has_been_saved: "L'URL du Webhook a été enregistrée." webhook_url_has_been_saved: "L'URL du Webhook a été enregistrée."
webhook_request_has_been_sent: La demande du Webhook a été envoyée. webhook_request_has_been_sent: La demande du Webhook a été envoyée.
webhook_url_has_been_updated: "L'URL du Webhook a été mise à jour."
webhook_url_has_been_deleted: "L'URL du Webhook a été supprimée."
unable_to_resend_webhook_request: Impossible de renvoyer la requête du webhook.
new_webhook: Nouveau Webhook
delete_webhook: Supprimer le webhook
count_submissions_have_been_created: '%{count} soumissions ont été créées.' count_submissions_have_been_created: '%{count} soumissions ont été créées.'
gmail_has_been_connected: Gmail a été connecté. gmail_has_been_connected: Gmail a été connecté.
microsoft_account_has_been_connected: Le compte Microsoft a été connecté. microsoft_account_has_been_connected: Le compte Microsoft a été connecté.
sms_length_cant_be_longer_than_120_bytes: La longueur du SMS ne peut pas dépasser 120 octets. sms_length_cant_be_longer_than_120_bytes: La longueur du SMS ne peut pas dépasser 120 octets.
connected_successfully: Connecté avec succès. connected_successfully: Connecté avec succès.
user_nameid_not_found: 'Utilisateur %{nameid} introuvable.' user_nameid_not_found: 'Utilisateur %{nameid} introuvable.'
webhook_request_has_been_sent: La demande du Webhook a été envoyée.
sso_settings_have_been_updated: Les paramètres de SSO ont été mis à jour. sso_settings_have_been_updated: Les paramètres de SSO ont été mis à jour.
sms_has_been_sent: Le SMS a été envoyé. sms_has_been_sent: Le SMS a été envoyé.
account_has_been_created: Le compte a été créé. account_has_been_created: Le compte a été créé.
@@ -2540,6 +2576,9 @@ fr: &fr
archived_users: Utilisateurs Archivés archived_users: Utilisateurs Archivés
embedding_users: Utilisateurs Intégrés embedding_users: Utilisateurs Intégrés
view_embedding_users: Voir les Utilisateurs Intégrés view_embedding_users: Voir les Utilisateurs Intégrés
key: Clé
value: Valeur
webhook_secret: Secret du Webhook
submission_event_names: submission_event_names:
send_email_to_html: '<b>E-mail envoyé</b> à %{submitter_name}' send_email_to_html: '<b>E-mail envoyé</b> à %{submitter_name}'
send_reminder_email_to_html: '<b>E-mail de rappel envoyé</b> à %{submitter_name}' send_reminder_email_to_html: '<b>E-mail de rappel envoyé</b> à %{submitter_name}'
@@ -2576,6 +2615,7 @@ fr: &fr
read: Lire vos données read: Lire vos données
pt: &pt pt: &pt
unarchive: Desarquivar
awaiting_completion_by_the_other_party: "Aguardando a conclusão pela outra parte" awaiting_completion_by_the_other_party: "Aguardando a conclusão pela outra parte"
enforce_recipients_order: 'Forçar a ordem dos recipientes' enforce_recipients_order: 'Forçar a ordem dos recipientes'
first_party: 'Primeira Parte' first_party: 'Primeira Parte'
@@ -2677,7 +2717,7 @@ pt: &pt
profile_details: Detalhes do perfil profile_details: Detalhes do perfil
sign_up_with_google: Inscrever-se com Google sign_up_with_google: Inscrever-se com Google
sign_up_with_microsoft: Inscrever-se com Microsoft sign_up_with_microsoft: Inscrever-se com Microsoft
by_creating_an_account_you_agree_to_our_html: 'Ao criar uma conta, você concorda com nossa <a target="_blank" href="https://www.docuseal.co/privacy">Política de Privacidade</a> e <a target="_blank" href="https://www.docuseal.co/terms">Termos de Serviço</a>.' by_creating_an_account_you_agree_to_our_html: 'Ao criar uma conta, você concorda com nossa <a target="_blank" href="https://www.docuseal.com/privacy">Política de Privacidade</a> e <a target="_blank" href="https://www.docuseal.com/terms">Termos de Serviço</a>.'
enter_email_to_continue: Insira o e-mail para continuar enter_email_to_continue: Insira o e-mail para continuar
account: Conta account: Conta
preferences: Preferências preferences: Preferências
@@ -3031,6 +3071,7 @@ pt: &pt
logo: Logotipo logo: Logotipo
back: Voltar back: Voltar
add_secret: Adicionar segredo add_secret: Adicionar segredo
edit_secret: Editar Segredo
submission_example_payload: Exemplo de payload de submissão submission_example_payload: Exemplo de payload de submissão
there_are_no_signatures: Não há assinaturas there_are_no_signatures: Não há assinaturas
signed_with_trusted_certificate: Assinado com certificado confiável signed_with_trusted_certificate: Assinado com certificado confiável
@@ -3056,6 +3097,7 @@ pt: &pt
document_template_has_been_moved: O modelo de documento foi movido. document_template_has_been_moved: O modelo de documento foi movido.
unable_to_move_template_into_folder: Não foi possível mover o modelo para a pasta. unable_to_move_template_into_folder: Não foi possível mover o modelo para a pasta.
template_has_been_unarchived: O modelo foi desarquivado. template_has_been_unarchived: O modelo foi desarquivado.
submission_has_been_unarchived: A submissao foi desarquivada.
unable_to_update_file: Não foi possível atualizar o arquivo. unable_to_update_file: Não foi possível atualizar o arquivo.
user_has_been_updated: O usuário foi atualizado. user_has_been_updated: O usuário foi atualizado.
unable_to_remove_user: Não foi possível remover o usuário. unable_to_remove_user: Não foi possível remover o usuário.
@@ -3066,13 +3108,17 @@ pt: &pt
webhook_secret_has_been_saved: O segredo do Webhook foi salvo. webhook_secret_has_been_saved: O segredo do Webhook foi salvo.
webhook_url_has_been_saved: A URL do Webhook foi salva. webhook_url_has_been_saved: A URL do Webhook foi salva.
webhook_request_has_been_sent: A solicitação do Webhook foi enviada. webhook_request_has_been_sent: A solicitação do Webhook foi enviada.
webhook_url_has_been_updated: URL do Webhook foi atualizada.
webhook_url_has_been_deleted: URL do Webhook foi excluída.
unable_to_resend_webhook_request: Não foi possível reenviar a solicitação do webhook.
new_webhook: Novo Webhook
delete_webhook: Excluir webhook
count_submissions_have_been_created: '%{count} submissões foram criadas.' count_submissions_have_been_created: '%{count} submissões foram criadas.'
gmail_has_been_connected: O Gmail foi conectado gmail_has_been_connected: O Gmail foi conectado
microsoft_account_has_been_connected: A conta da Microsoft foi conectada microsoft_account_has_been_connected: A conta da Microsoft foi conectada
sms_length_cant_be_longer_than_120_bytes: O comprimento do SMS não pode ultrapassar 120 bytes sms_length_cant_be_longer_than_120_bytes: O comprimento do SMS não pode ultrapassar 120 bytes
connected_successfully: Conectado com sucesso. connected_successfully: Conectado com sucesso.
user_nameid_not_found: 'Usuário %{nameid} não encontrado.' user_nameid_not_found: 'Usuário %{nameid} não encontrado.'
webhook_request_has_been_sent: A solicitação do Webhook foi enviada.
sso_settings_have_been_updated: As configurações de SSO foram atualizadas. sso_settings_have_been_updated: As configurações de SSO foram atualizadas.
sms_has_been_sent: O SMS foi enviado. sms_has_been_sent: O SMS foi enviado.
account_has_been_created: A conta foi criada. account_has_been_created: A conta foi criada.
@@ -3179,6 +3225,9 @@ pt: &pt
archived_users: Usuários Arquivados archived_users: Usuários Arquivados
embedding_users: Usuários Incorporados embedding_users: Usuários Incorporados
view_embedding_users: Ver Usuários Incorporados view_embedding_users: Ver Usuários Incorporados
key: Chave
value: Valor
webhook_secret: Segredo do Webhook
submission_event_names: submission_event_names:
send_email_to_html: '<b>E-mail enviado</b> para %{submitter_name}' send_email_to_html: '<b>E-mail enviado</b> para %{submitter_name}'
send_reminder_email_to_html: '<b>E-mail de lembrete enviado</b> para %{submitter_name}' send_reminder_email_to_html: '<b>E-mail de lembrete enviado</b> para %{submitter_name}'
@@ -3215,6 +3264,7 @@ pt: &pt
read: Ler seus dados read: Ler seus dados
de: &de de: &de
unarchive: Wiederherstellen
awaiting_completion_by_the_other_party: "Warten auf die Fertigstellung durch die andere Partei" awaiting_completion_by_the_other_party: "Warten auf die Fertigstellung durch die andere Partei"
enforce_recipients_order: 'Empfängerreihenfolge durchsetzen' enforce_recipients_order: 'Empfängerreihenfolge durchsetzen'
first_party: 'Erste Partei' first_party: 'Erste Partei'
@@ -3316,7 +3366,7 @@ de: &de
profile_details: Profildetails profile_details: Profildetails
sign_up_with_google: Mit Google registrieren sign_up_with_google: Mit Google registrieren
sign_up_with_microsoft: Mit Microsoft registrieren sign_up_with_microsoft: Mit Microsoft registrieren
by_creating_an_account_you_agree_to_our_html: 'Durch die Erstellung eines Kontos stimmst du unseren <a target="_blank" href="https://www.docuseal.co/privacy">Datenschutzrichtlinien</a> und <a target="_blank" href="https://www.docuseal.co/terms">Nutzungsbedingungen</a> zu.' by_creating_an_account_you_agree_to_our_html: 'Durch die Erstellung eines Kontos stimmst du unseren <a target="_blank" href="https://www.docuseal.com/privacy">Datenschutzrichtlinien</a> und <a target="_blank" href="https://www.docuseal.com/terms">Nutzungsbedingungen</a> zu.'
enter_email_to_continue: E-Mail eingeben, um fortzufahren enter_email_to_continue: E-Mail eingeben, um fortzufahren
account: Konto account: Konto
preferences: Einstellungen preferences: Einstellungen
@@ -3669,7 +3719,8 @@ de: &de
api_key: API-Schlüssel api_key: API-Schlüssel
logo: Logo logo: Logo
back: Zurück back: Zurück
add_secret: Geheimnis hinzufügen add_secret: Geheimnis hinzuf
edit_secret: Geheimnis bearb
submission_example_payload: Beispiel-Payload für Einreichung submission_example_payload: Beispiel-Payload für Einreichung
there_are_no_signatures: Es gibt keine Unterschriften there_are_no_signatures: Es gibt keine Unterschriften
signed_with_trusted_certificate: Signiert mit vertrauenswürdigem Zertifikat signed_with_trusted_certificate: Signiert mit vertrauenswürdigem Zertifikat
@@ -3695,6 +3746,7 @@ de: &de
document_template_has_been_moved: Die Dokumentvorlage wurde verschoben. document_template_has_been_moved: Die Dokumentvorlage wurde verschoben.
unable_to_move_template_into_folder: Vorlage konnte nicht in den Ordner verschoben werden. unable_to_move_template_into_folder: Vorlage konnte nicht in den Ordner verschoben werden.
template_has_been_unarchived: Die Vorlage wurde entarchiviert. template_has_been_unarchived: Die Vorlage wurde entarchiviert.
submission_has_been_unarchived: Die Einreichung wurde wiederhergestellt.
unable_to_update_file: Datei konnte nicht hochgeladen werden. unable_to_update_file: Datei konnte nicht hochgeladen werden.
user_has_been_updated: Der Benutzer wurde aktualisiert. user_has_been_updated: Der Benutzer wurde aktualisiert.
unable_to_remove_user: Benutzer konnte nicht entfernt werden. unable_to_remove_user: Benutzer konnte nicht entfernt werden.
@@ -3705,13 +3757,17 @@ de: &de
webhook_secret_has_been_saved: Das Webhook-Geheimnis wurde gespeichert. webhook_secret_has_been_saved: Das Webhook-Geheimnis wurde gespeichert.
webhook_url_has_been_saved: Die Webhook-URL wurde gespeichert. webhook_url_has_been_saved: Die Webhook-URL wurde gespeichert.
webhook_request_has_been_sent: Die Webhook-Anfrage wurde gesendet. webhook_request_has_been_sent: Die Webhook-Anfrage wurde gesendet.
webhook_url_has_been_updated: Webhook-URL wurde aktualisiert.
webhook_url_has_been_deleted: Webhook-URL wurde gelöscht.
unable_to_resend_webhook_request: Webhook-Anfrage konnte nicht erneut gesendet werden.
new_webhook: Neuer Webhook
delete_webhook: Webhook löschen
count_submissions_have_been_created: '%{count} Einreichungen wurden erstellt.' count_submissions_have_been_created: '%{count} Einreichungen wurden erstellt.'
gmail_has_been_connected: Gmail wurde verbunden. gmail_has_been_connected: Gmail wurde verbunden.
microsoft_account_has_been_connected: Microsoft-Konto wurde verbunden. microsoft_account_has_been_connected: Microsoft-Konto wurde verbunden.
sms_length_cant_be_longer_than_120_bytes: Die SMS-Länge darf 120 Bytes nicht überschreiten. sms_length_cant_be_longer_than_120_bytes: Die SMS-Länge darf 120 Bytes nicht überschreiten.
connected_successfully: Erfolgreich verbunden. connected_successfully: Erfolgreich verbunden.
user_nameid_not_found: 'Benutzer %{nameid} nicht gefunden.' user_nameid_not_found: 'Benutzer %{nameid} nicht gefunden.'
webhook_request_has_been_sent: Die Webhook-Anfrage wurde gesendet.
sso_settings_have_been_updated: Die SSO-Einstellungen wurden aktualisiert. sso_settings_have_been_updated: Die SSO-Einstellungen wurden aktualisiert.
sms_has_been_sent: Die SMS wurde gesendet. sms_has_been_sent: Die SMS wurde gesendet.
account_has_been_created: Das Konto wurde erstellt. account_has_been_created: Das Konto wurde erstellt.
@@ -3818,6 +3874,9 @@ de: &de
archived_users: Archivierte Benutzer archived_users: Archivierte Benutzer
embedding_users: Einbettende Benutzer embedding_users: Einbettende Benutzer
view_embedding_users: Einbettende Benutzer anzeigen view_embedding_users: Einbettende Benutzer anzeigen
key: Schlüssel
value: Wert
webhook_secret: Webhook-Geheimnis
submission_event_names: submission_event_names:
send_email_to_html: '<b>E-Mail gesendet</b> an %{submitter_name}' send_email_to_html: '<b>E-Mail gesendet</b> an %{submitter_name}'
send_reminder_email_to_html: '<b>Erinnerungs-E-Mail gesendet</b> an %{submitter_name}' send_reminder_email_to_html: '<b>Erinnerungs-E-Mail gesendet</b> an %{submitter_name}'
@@ -3904,7 +3963,7 @@ pl:
profile_details: Szczegóły profilu profile_details: Szczegóły profilu
sign_up_with_google: Zarejestruj się przez Google sign_up_with_google: Zarejestruj się przez Google
sign_up_with_microsoft: Zarejestruj się przez Microsoft sign_up_with_microsoft: Zarejestruj się przez Microsoft
by_creating_an_account_you_agree_to_our_html: 'Tworząc konto, akceptujesz naszą <a target="_blank" href="https://www.docuseal.co/privacy">Politykę Prywatności</a> i <a target="_blank" href="https://www.docuseal.co/terms">Regulamin</a>.' by_creating_an_account_you_agree_to_our_html: 'Tworząc konto, akceptujesz naszą <a target="_blank" href="https://www.docuseal.com/privacy">Politykę Prywatności</a> i <a target="_blank" href="https://www.docuseal.com/terms">Regulamin</a>.'
enter_email_to_continue: Wprowadź e-mail, aby kontynuować enter_email_to_continue: Wprowadź e-mail, aby kontynuować
uk: uk:
@@ -3958,7 +4017,7 @@ uk:
profile_details: Дані профілю profile_details: Дані профілю
sign_up_with_google: Зареєструватися через Google sign_up_with_google: Зареєструватися через Google
sign_up_with_microsoft: Зареєструватися через Microsoft sign_up_with_microsoft: Зареєструватися через Microsoft
by_creating_an_account_you_agree_to_our_html: 'Створюючи акаунт, ви погоджуєтесь з нашою <a target="_blank" href="https://www.docuseal.co/privacy">Політикою конфіденційності</a> і <a target="_blank" href="https://www.docuseal.co/terms">Умовами надання послуг</a>.' by_creating_an_account_you_agree_to_our_html: 'Створюючи акаунт, ви погоджуєтесь з нашою <a target="_blank" href="https://www.docuseal.com/privacy">Політикою конфіденційності</a> і <a target="_blank" href="https://www.docuseal.com/terms">Умовами надання послуг</a>.'
enter_email_to_continue: Введіть електронну пошту, щоб продовжити enter_email_to_continue: Введіть електронну пошту, щоб продовжити
cs: cs:
@@ -4012,7 +4071,7 @@ cs:
profile_details: Detaily profilu profile_details: Detaily profilu
sign_up_with_google: Zaregistrovat se pomocí Googlu sign_up_with_google: Zaregistrovat se pomocí Googlu
sign_up_with_microsoft: Zaregistrovat se pomocí Microsoftu sign_up_with_microsoft: Zaregistrovat se pomocí Microsoftu
by_creating_an_account_you_agree_to_our_html: 'Vytvořením účtu souhlasíte s našimi <a target="_blank" href="https://www.docuseal.co/privacy">Zásadami ochrany osobních údajů</a> a <a target="_blank" href="https://www.docuseal.co/terms">Podmínkami služby</a>.' by_creating_an_account_you_agree_to_our_html: 'Vytvořením účtu souhlasíte s našimi <a target="_blank" href="https://www.docuseal.com/privacy">Zásadami ochrany osobních údajů</a> a <a target="_blank" href="https://www.docuseal.com/terms">Podmínkami služby</a>.'
enter_email_to_continue: Zadejte e-mail pro pokračování enter_email_to_continue: Zadejte e-mail pro pokračování
he: he:
@@ -4066,7 +4125,7 @@ he:
profile_details: פרטי הפרופיל profile_details: פרטי הפרופיל
sign_up_with_google: הירשם עם גוגל sign_up_with_google: הירשם עם גוגל
sign_up_with_microsoft: הירשם עם מיקרוסופט sign_up_with_microsoft: הירשם עם מיקרוסופט
by_creating_an_account_you_agree_to_our_html: 'על ידי יצירת חשבון, אתה מסכים ל<a target="_blank" href="https://www.docuseal.co/privacy">מדיניות הפרטיות</a> ול<a target="_blank" href="https://www.docuseal.co/terms">תנאי השירות</a> שלנו.' by_creating_an_account_you_agree_to_our_html: 'על ידי יצירת חשבון, אתה מסכים ל<a target="_blank" href="https://www.docuseal.com/privacy">מדיניות הפרטיות</a> ול<a target="_blank" href="https://www.docuseal.com/terms">תנאי השירות</a> שלנו.'
enter_email_to_continue: הכנס דוא"ל כדי להמשיך enter_email_to_continue: הכנס דוא"ל כדי להמשיך
nl: nl:
@@ -4120,7 +4179,7 @@ nl:
profile_details: Profielgegevens profile_details: Profielgegevens
sign_up_with_google: Aanmelden met Google sign_up_with_google: Aanmelden met Google
sign_up_with_microsoft: Aanmelden met Microsoft sign_up_with_microsoft: Aanmelden met Microsoft
by_creating_an_account_you_agree_to_our_html: 'Door een account aan te maken, ga je akkoord met ons <a target="_blank" href="https://www.docuseal.co/privacy">Privacybeleid</a> en onze <a target="_blank" href="https://www.docuseal.co/terms">Gebruiksvoorwaarden</a>.' by_creating_an_account_you_agree_to_our_html: 'Door een account aan te maken, ga je akkoord met ons <a target="_blank" href="https://www.docuseal.com/privacy">Privacybeleid</a> en onze <a target="_blank" href="https://www.docuseal.com/terms">Gebruiksvoorwaarden</a>.'
enter_email_to_continue: Voer e-mail in om door te gaan enter_email_to_continue: Voer e-mail in om door te gaan
ar: ar:
@@ -4174,7 +4233,7 @@ ar:
profile_details: تفاصيل الملف الشخصي profile_details: تفاصيل الملف الشخصي
sign_up_with_google: الاشتراك باستخدام جوجل sign_up_with_google: الاشتراك باستخدام جوجل
sign_up_with_microsoft: الاشتراك باستخدام مايكروسوفت sign_up_with_microsoft: الاشتراك باستخدام مايكروسوفت
by_creating_an_account_you_agree_to_our_html: 'من خلال إنشاء حساب، فإنك توافق على <a target="_blank" href="https://www.docuseal.co/privacy">سياسة الخصوصية</a> و<a target="_blank" href="https://www.docuseal.co/terms">شروط الخدمة</a> الخاصة بنا.' by_creating_an_account_you_agree_to_our_html: 'من خلال إنشاء حساب، فإنك توافق على <a target="_blank" href="https://www.docuseal.com/privacy">سياسة الخصوصية</a> و<a target="_blank" href="https://www.docuseal.com/terms">شروط الخدمة</a> الخاصة بنا.'
enter_email_to_continue: أدخل البريد الإلكتروني للمتابعة enter_email_to_continue: أدخل البريد الإلكتروني للمتابعة
ko: ko:
@@ -4228,7 +4287,7 @@ ko:
profile_details: 프로필 세부 정보 profile_details: 프로필 세부 정보
sign_up_with_google: Google로 가입 sign_up_with_google: Google로 가입
sign_up_with_microsoft: Microsoft로 가입 sign_up_with_microsoft: Microsoft로 가입
by_creating_an_account_you_agree_to_our_html: '계정을 생성함으로써, 귀하는 우리의 <a target="_blank" href="https://www.docuseal.co/privacy">개인정보 보호정책</a> 및 <a target="_blank" href="https://www.docuseal.co/terms">서비스 약관</a>에 동의하는 것입니다.' by_creating_an_account_you_agree_to_our_html: '계정을 생성함으로써, 귀하는 우리의 <a target="_blank" href="https://www.docuseal.com/privacy">개인정보 보호정책</a> 및 <a target="_blank" href="https://www.docuseal.com/terms">서비스 약관</a>에 동의하는 것입니다.'
enter_email_to_continue: 계속하려면 이메일을 입력하세요 enter_email_to_continue: 계속하려면 이메일을 입력하세요
en-US: en-US:
+8 -4
View File
@@ -69,7 +69,9 @@ Rails.application.routes.draw do
resource :user_initials, only: %i[edit update destroy] resource :user_initials, only: %i[edit update destroy]
resources :submissions_archived, only: %i[index], path: 'submissions/archived' resources :submissions_archived, only: %i[index], path: 'submissions/archived'
resources :submissions, only: %i[index], controller: 'submissions_dashboard' resources :submissions, only: %i[index], controller: 'submissions_dashboard'
resources :submissions, only: %i[show destroy] resources :submissions, only: %i[show destroy] do
resources :unarchive, only: %i[create], controller: 'submissions_unarchive'
end
resources :submitters, only: %i[edit update] resources :submitters, only: %i[edit update]
resources :console_redirect, only: %i[index] resources :console_redirect, only: %i[index]
resources :upgrade, only: %i[index], controller: 'console_redirect' resources :upgrade, only: %i[index], controller: 'console_redirect'
@@ -78,8 +80,8 @@ Rails.application.routes.draw do
resources :testing_api_settings, only: %i[index] resources :testing_api_settings, only: %i[index]
resources :submitters_autocomplete, only: %i[index] resources :submitters_autocomplete, only: %i[index]
resources :template_folders_autocomplete, only: %i[index] resources :template_folders_autocomplete, only: %i[index]
resources :webhook_preferences, only: %i[create] resources :webhook_secret, only: %i[show update]
resources :webhook_secret, only: %i[index create] resources :webhook_preferences, only: %i[update]
resource :templates_upload, only: %i[create] resource :templates_upload, only: %i[create]
authenticated do authenticated do
resource :templates_upload, only: %i[show], path: 'new' resource :templates_upload, only: %i[show], path: 'new'
@@ -166,7 +168,9 @@ Rails.application.routes.draw do
defaults: { status: :integration } defaults: { status: :integration }
resource :personalization, only: %i[show create], controller: 'personalization_settings' resource :personalization, only: %i[show create], controller: 'personalization_settings'
resources :api, only: %i[index create], controller: 'api_settings' resources :api, only: %i[index create], controller: 'api_settings'
resource :webhooks, only: %i[show create update], controller: 'webhook_settings' resources :webhooks, only: %i[index show new create update destroy], controller: 'webhook_settings' do
post :resend
end
resource :account, only: %i[show update destroy] resource :account, only: %i[show update destroy]
resources :profile, only: %i[index] do resources :profile, only: %i[index] do
collection do collection do
@@ -0,0 +1,21 @@
# frozen_string_literal: true
class AddSecretToWebhookUrls < ActiveRecord::Migration[7.2]
class MigrationWebhookUrl < ApplicationRecord
self.table_name = 'webhook_urls'
serialize :secret, coder: JSON
encrypts :url, :secret
end
def change
add_column :webhook_urls, :secret, :text
MigrationWebhookUrl.all.each do |url|
url.update_columns(secret: {})
end
change_column_null :webhook_urls, :secret, false
end
end
@@ -0,0 +1,53 @@
# frozen_string_literal: true
class PopulateWebhookUrls < ActiveRecord::Migration[7.2]
disable_ddl_transaction
class MigrationWebhookUrl < ActiveRecord::Base
self.table_name = 'webhook_urls'
serialize :events, coder: JSON
serialize :secret, coder: JSON
encrypts :url, :secret
end
class MigrationEncryptedConfig < ActiveRecord::Base
self.table_name = 'encrypted_configs'
encrypts :value
serialize :value, coder: JSON
end
class MigrationAccountConfig < ActiveRecord::Base
self.table_name = 'account_configs'
serialize :value, coder: JSON
end
def up
MigrationEncryptedConfig.where(key: 'webhook_url').find_each do |config|
webhook_url = MigrationWebhookUrl.find_or_initialize_by(account_id: config.account_id,
sha1: Digest::SHA1.hexdigest(config.value))
webhook_url.secret =
MigrationEncryptedConfig.find_by(account_id: config.account_id, key: 'webhook_secret')&.value.to_h
preferences =
MigrationAccountConfig.find_by(account_id: config.account_id, key: 'webhook_preferences')&.value.to_h
events = %w[form.viewed form.started form.completed form.declined].reject { |event| preferences[event] == false }
events += preferences.compact_blank.keys
webhook_url.events = events.uniq
webhook_url.url = config.value
webhook_url.save!
end
end
def down
nil
end
end
+2 -1
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2024_10_26_161207) do ActiveRecord::Schema[7.2].define(version: 2024_10_29_192232) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@@ -367,6 +367,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_26_161207) do
t.string "sha1", null: false t.string "sha1", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.text "secret", null: false
t.index ["account_id"], name: "index_webhook_urls_on_account_id" t.index ["account_id"], name: "index_webhook_urls_on_account_id"
t.index ["sha1"], name: "index_webhook_urls_on_sha1" t.index ["sha1"], name: "index_webhook_urls_on_sha1"
end end
+1
View File
@@ -22,5 +22,6 @@ class Ability
can :manage, UserConfig, user_id: user.id can :manage, UserConfig, user_id: user.id
can :manage, Account, id: user.account_id can :manage, Account, id: user.account_id
can :manage, AccessToken, user_id: user.id can :manage, AccessToken, user_id: user.id
can :manage, WebhookUrl, account_id: user.account_id
end end
end end
-24
View File
@@ -78,30 +78,6 @@ module Accounts
new_template new_template
end end
def load_webhook_url(account)
load_webhook_config(account)&.value.presence
end
def load_webhook_config(account)
configs = account.encrypted_configs.find_by(key: EncryptedConfig::WEBHOOK_URL_KEY)
if !configs && !Docuseal.multitenant? && !account.testing?
configs = Account.order(:id).first.encrypted_configs.find_by(key: EncryptedConfig::WEBHOOK_URL_KEY)
end
configs
end
def load_webhook_preferences(account)
configs = account.account_configs.find_by(key: AccountConfig::WEBHOOK_PREFERENCES_KEY)
unless Docuseal.multitenant?
configs ||= Account.order(:id).first.account_configs.find_by(key: AccountConfig::WEBHOOK_PREFERENCES_KEY)
end
configs&.value.presence || {}
end
def load_signing_pkcs(account) def load_signing_pkcs(account)
cert_data = cert_data =
if Docuseal.multitenant? if Docuseal.multitenant?
+1 -1
View File
@@ -2,7 +2,7 @@
module Docuseal module Docuseal
URL_CACHE = ActiveSupport::Cache::MemoryStore.new URL_CACHE = ActiveSupport::Cache::MemoryStore.new
PRODUCT_URL = 'https://www.docuseal.co' PRODUCT_URL = 'https://www.docuseal.com'
NEWSLETTER_URL = "#{PRODUCT_URL}/newsletters".freeze NEWSLETTER_URL = "#{PRODUCT_URL}/newsletters".freeze
ENQUIRIES_URL = "#{PRODUCT_URL}/enquiries".freeze ENQUIRIES_URL = "#{PRODUCT_URL}/enquiries".freeze
PRODUCT_NAME = 'DocuSeal' PRODUCT_NAME = 'DocuSeal'
+10 -1
View File
@@ -291,7 +291,16 @@ module Submissions
), ),
if field['type'].in?(%w[image signature initials stamp]) if field['type'].in?(%w[image signature initials stamp])
attachment = submitter.attachments.find { |a| a.uuid == value } attachment = submitter.attachments.find { |a| a.uuid == value }
image = Vips::Image.new_from_buffer(attachment.download, '').autorot
image =
begin
Vips::Image.new_from_buffer(attachment.download, '').autorot
rescue Vips::Error
next unless attachment.content_type.starts_with?('image/')
next if attachment.byte_size.zero?
raise
end
scale = [600.0 / image.width, 600.0 / image.height].min scale = [600.0 / image.width, 600.0 / image.height].min
+4
View File
@@ -103,6 +103,10 @@ module Submissions
name: column_name(I18n.t('phone'), submitter_name, submitters_count), name: column_name(I18n.t('phone'), submitter_name, submitters_count),
value: submitter.phone value: submitter.phone
}, },
{
name: column_name(I18n.t('status'), submitter_name, submitters_count),
value: submitter.status
},
{ {
name: column_name(I18n.t('completed_at'), submitter_name, submitters_count), name: column_name(I18n.t('completed_at'), submitter_name, submitters_count),
value: submitter.completed_at.to_s value: submitter.completed_at.to_s
+22 -5
View File
@@ -10,7 +10,7 @@ module Submissions
'Helvetica' 'Helvetica'
end end
SIGN_REASON = 'Signed by %<name>s with DocuSeal.co' SIGN_REASON = 'Signed by %<name>s with DocuSeal.com'
RTL_REGEXP = TextUtils::RTL_REGEXP RTL_REGEXP = TextUtils::RTL_REGEXP
@@ -19,7 +19,6 @@ module Submissions
MAX_PAGE_ROTATE = 20 MAX_PAGE_ROTATE = 20
A4_SIZE = [595, 842].freeze A4_SIZE = [595, 842].freeze
SUPPORTED_IMAGE_TYPES = ['image/png', 'image/jpeg'].freeze
TESTING_FOOTER = 'Testing Document - NOT LEGALLY BINDING' TESTING_FOOTER = 'Testing Document - NOT LEGALLY BINDING'
@@ -69,7 +68,7 @@ module Submissions
name: item['name']) name: item['name'])
end end
return result_attachments.map { |e| e.tap(&:save!) } if image_pdfs.size < 2 return ApplicationRecord.no_touching { result_attachments.map { |e| e.tap(&:save!) } } if image_pdfs.size < 2
images_pdf = images_pdf =
image_pdfs.each_with_object(HexaPDF::Document.new) do |pdf, doc| image_pdfs.each_with_object(HexaPDF::Document.new) do |pdf, doc|
@@ -88,7 +87,9 @@ module Submissions
name: template.name name: template.name
) )
(result_attachments + [images_pdf_attachment]).map { |e| e.tap(&:save!) } ApplicationRecord.no_touching do
(result_attachments + [images_pdf_attachment]).map { |e| e.tap(&:save!) }
end
end end
def generate_pdfs(submitter) def generate_pdfs(submitter)
@@ -178,6 +179,8 @@ module Submissions
font_size = preferences_font_size font_size = preferences_font_size
font_size ||= (([page.box.width, page.box.height].min / A4_SIZE[0].to_f) * FONT_SIZE).to_i font_size ||= (([page.box.width, page.box.height].min / A4_SIZE[0].to_f) * FONT_SIZE).to_i
fill_color = field.dig('preferences', 'color').presence
font = pdf.fonts.add(field.dig('preferences', 'font').presence || FONT_NAME) font = pdf.fonts.add(field.dig('preferences', 'font').presence || FONT_NAME)
value = submitter.values[field['uuid']] value = submitter.values[field['uuid']]
@@ -273,7 +276,15 @@ module Submissions
attachments_data_cache[attachment.uuid] ||= attachment.download attachments_data_cache[attachment.uuid] ||= attachment.download
image = Vips::Image.new_from_buffer(attachments_data_cache[attachment.uuid], '').autorot image =
begin
Vips::Image.new_from_buffer(attachments_data_cache[attachment.uuid], '').autorot
rescue Vips::Error
next unless attachment.content_type.starts_with?('image/')
next if attachment.byte_size.zero?
raise
end
scale = [(area['w'] * width) / image.width, scale = [(area['w'] * width) / image.width,
(area['h'] * height) / image.height].min (area['h'] * height) / image.height].min
@@ -371,6 +382,7 @@ module Submissions
next if char.blank? next if char.blank?
text = HexaPDF::Layout::TextFragment.create(char, font:, text = HexaPDF::Layout::TextFragment.create(char, font:,
fill_color:,
font_size:) font_size:)
line_height = layouter.fit([text], cell_width, height).lines.first.height line_height = layouter.fit([text], cell_width, height).lines.first.height
@@ -378,6 +390,7 @@ module Submissions
if preferences_font_size.blank? && line_height > (area['h'] * height) if preferences_font_size.blank? && line_height > (area['h'] * height)
text = HexaPDF::Layout::TextFragment.create(char, text = HexaPDF::Layout::TextFragment.create(char,
font:, font:,
fill_color:,
font_size: (font_size / 1.4).to_i) font_size: (font_size / 1.4).to_i)
line_height = layouter.fit([text], cell_width, height).lines.first.height line_height = layouter.fit([text], cell_width, height).lines.first.height
@@ -386,6 +399,7 @@ module Submissions
if preferences_font_size.blank? && line_height > (area['h'] * height) if preferences_font_size.blank? && line_height > (area['h'] * height)
text = HexaPDF::Layout::TextFragment.create(char, text = HexaPDF::Layout::TextFragment.create(char,
font:, font:,
fill_color:,
font_size: (font_size / 1.9).to_i) font_size: (font_size / 1.9).to_i)
line_height = layouter.fit([text], cell_width, height).lines.first.height line_height = layouter.fit([text], cell_width, height).lines.first.height
@@ -405,6 +419,7 @@ module Submissions
value = TextUtils.maybe_rtl_reverse(Array.wrap(value).join(', ')) value = TextUtils.maybe_rtl_reverse(Array.wrap(value).join(', '))
text = HexaPDF::Layout::TextFragment.create(value, font:, text = HexaPDF::Layout::TextFragment.create(value, font:,
fill_color:,
font_size:) font_size:)
lines = layouter.fit([text], area['w'] * width, height).lines lines = layouter.fit([text], area['w'] * width, height).lines
@@ -413,6 +428,7 @@ module Submissions
if preferences_font_size.blank? && box_height > (area['h'] * height) + 1 if preferences_font_size.blank? && box_height > (area['h'] * height) + 1
text = HexaPDF::Layout::TextFragment.create(value, text = HexaPDF::Layout::TextFragment.create(value,
font:, font:,
fill_color:,
font_size: (font_size / 1.4).to_i) font_size: (font_size / 1.4).to_i)
lines = layouter.fit([text], field['type'].in?(%w[date number]) ? width : area['w'] * width, height).lines lines = layouter.fit([text], field['type'].in?(%w[date number]) ? width : area['w'] * width, height).lines
@@ -423,6 +439,7 @@ module Submissions
if preferences_font_size.blank? && box_height > (area['h'] * height) + 1 if preferences_font_size.blank? && box_height > (area['h'] * height) + 1
text = HexaPDF::Layout::TextFragment.create(value, text = HexaPDF::Layout::TextFragment.create(value,
font:, font:,
fill_color:,
font_size: (font_size / 1.9).to_i) font_size: (font_size / 1.9).to_i)
lines = layouter.fit([text], field['type'].in?(%w[date number]) ? width : area['w'] * width, height).lines lines = layouter.fit([text], field['type'].in?(%w[date number]) ? width : area['w'] * width, height).lines
+12 -3
View File
@@ -6,10 +6,10 @@ module Submissions
TimestampError = Class.new(StandardError) TimestampError = Class.new(StandardError)
attr_reader :tsa_url attr_reader :tsa_url, :tsa_fallback_url
def initialize(tsa_url:) def initialize(tsa_url:)
@tsa_url = tsa_url @tsa_url, @tsa_fallback_url = tsa_url.split(',')
end end
def finalize_objects(_signature_field, signature) def finalize_objects(_signature_field, signature)
@@ -37,7 +37,16 @@ module Submissions
response = conn.post(uri.path, build_payload(digest.digest), response = conn.post(uri.path, build_payload(digest.digest),
'content-type' => 'application/timestamp-query') 'content-type' => 'application/timestamp-query')
raise TimestampError if response.status != 200 || response.body.blank? if response.status != 200 || response.body.blank?
raise TimestampError if tsa_fallback_url.blank?
Rollbar.error('TimestampError: use fallback URL') if defined?(Rollbar)
response = Faraday.post(tsa_fallback_url, build_payload(digest.digest),
'content-type' => 'application/timestamp-query')
raise TimestampError if response.status != 200 || response.body.blank?
end
OpenSSL::Timestamp::Response.new(response.body).token.to_der OpenSSL::Timestamp::Response.new(response.body).token.to_der
end end
+5 -2
View File
@@ -14,14 +14,17 @@ module Submitters
unless submitter.submission_events.exists?(event_type: 'start_form') unless submitter.submission_events.exists?(event_type: 'start_form')
SubmissionEvents.create_with_tracking_data(submitter, 'start_form', request) SubmissionEvents.create_with_tracking_data(submitter, 'start_form', request)
SendFormStartedWebhookRequestJob.perform_async({ 'submitter_id' => submitter.id }) WebhookUrls.for_account_id(submitter.account_id, 'form.started').each do |webhook_url|
SendFormStartedWebhookRequestJob.perform_async('submitter_id' => submitter.id,
'webhook_url_id' => webhook_url.id)
end
end end
update_submitter!(submitter, params, request) update_submitter!(submitter, params, request)
submitter.submission.save! submitter.submission.save!
ProcessSubmitterCompletionJob.perform_async({ 'submitter_id' => submitter.id }) if submitter.completed_at? ProcessSubmitterCompletionJob.perform_async('submitter_id' => submitter.id) if submitter.completed_at?
submitter submitter
end end
+20 -3
View File
@@ -6,6 +6,20 @@ module Templates
FIELD_NAME_REGEXP = /\A(?=.*\p{L})[\p{L}\d\s-]+\z/ FIELD_NAME_REGEXP = /\A(?=.*\p{L})[\p{L}\d\s-]+\z/
SKIP_FIELD_DESCRIPTION = %w[undefined].freeze SKIP_FIELD_DESCRIPTION = %w[undefined].freeze
SELECT_PLACEHOLDER_REGEXP = /\b(
Select |
Choose |
Wählen |
Auswählen |
Sélectionner|
Choisir |
Seleccionar |
Elegir |
Seleziona |
Scegliere |
Selecionar |
Escolher
)\b/ix
module_function module_function
@@ -143,8 +157,8 @@ module Templates
{ {
**attrs, **attrs,
type: 'select', type: 'select',
options: build_options(field[:Opt]), options: build_options(field[:Opt], 'select'),
default_value: field.field_value default_value: field.field_value.to_s.match?(SELECT_PLACEHOLDER_REGEXP) ? nil : field.field_value
} }
elsif field.field_type == :Ch && field.concrete_field_type == :multi_select && field[:Opt].present? elsif field.field_type == :Ch && field.concrete_field_type == :multi_select && field[:Opt].present?
{ {
@@ -178,11 +192,14 @@ module Templates
def build_options(values, type = nil) def build_options(values, type = nil)
is_skip_single_value = type.in?(%w[radio multiple]) && values.uniq.size == 1 is_skip_single_value = type.in?(%w[radio multiple]) && values.uniq.size == 1
values.map do |option| values.filter_map do |option|
is_option_number = option.is_a?(Symbol) && option.to_s.match?(/\A\d+\z/) is_option_number = option.is_a?(Symbol) && option.to_s.match?(/\A\d+\z/)
option = option[1] if option.is_a?(Array) && option.size == 2
option = option.encode('utf-8', invalid: :replace, undef: :replace, replace: '') if option.is_a?(String) option = option.encode('utf-8', invalid: :replace, undef: :replace, replace: '') if option.is_a?(String)
next if type == 'select' && option.to_s.match?(SELECT_PLACEHOLDER_REGEXP)
{ {
uuid: SecureRandom.uuid, uuid: SecureRandom.uuid,
value: is_option_number || is_skip_single_value ? '' : option value: is_option_number || is_skip_single_value ? '' : option
+9
View File
@@ -122,6 +122,15 @@ module Templates
io = StringIO.new io = StringIO.new
pdf.acro_form.each_field do |field|
next if field.field_type != :Ch ||
field[:Opt].blank? ||
%i[combo_box editable_combo_box].exclude?(field.concrete_field_type) ||
!field.field_value.to_s.match?(FindAcroFields::SELECT_PLACEHOLDER_REGEXP)
field[:V] = ''
end
pdf.acro_form.create_appearances(force: true) if pdf.acro_form[:NeedAppearances] pdf.acro_form.create_appearances(force: true) if pdf.acro_form[:NeedAppearances]
pdf.acro_form.flatten pdf.acro_form.flatten
+7 -1
View File
@@ -25,9 +25,15 @@ module Templates
name: :preview_images) name: :preview_images)
.preload(:blob) .preload(:blob)
json[:documents] = template.schema.map do |item| json[:documents] = template.schema.filter_map do |item|
attachment = schema_documents.find { |e| e.uuid == item['attachment_uuid'] } attachment = schema_documents.find { |e| e.uuid == item['attachment_uuid'] }
unless attachment
Rollbar.error("Documents missing: #{template.id}") if defined?(Rollbar)
next
end
first_page_blob = preview_image_attachments.find { |e| e.record_id == attachment.id }&.blob first_page_blob = preview_image_attachments.find { |e| e.record_id == attachment.id }&.blob
first_page_blob ||= attachment.preview_images.joins(:blob).find_by(blob: { filename: ['0.jpg', '0.png'] })&.blob first_page_blob ||= attachment.preview_images.joins(:blob).find_by(blob: { filename: ['0.jpg', '0.png'] })&.blob
+27
View File
@@ -0,0 +1,27 @@
# frozen_string_literal: true
module WebhookUrls
module_function
def for_account_id(account_id, events)
events = Array.wrap(events)
rel = WebhookUrl.where(account_id:)
event_arel = events.map { |event| Arel::Table.new(:webhook_urls)[:events].matches("%\"#{event}\"%") }.reduce(:or)
if Docuseal.multitenant? || account_id == 1
rel.where(event_arel)
else
linked_account_rel =
AccountLinkedAccount.where(linked_account_id: account_id).where.not(account_type: :testing).select(:account_id)
webhook_urls = rel.or(WebhookUrl.where(account_id: linked_account_rel).where(event_arel))
account_urls, linked_urls = webhook_urls.partition { |w| w.account_id == account_id }
account_urls.select { |w| w.events.intersect?(events) }.presence ||
(account_urls.present? ? WebhookUrl.none : linked_urls)
end
end
end
+8
View File
@@ -0,0 +1,8 @@
# frozen_string_literal: true
FactoryBot.define do
factory :webhook_url do
account
url { Faker::Internet.url }
end
end
@@ -0,0 +1,94 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe SendFormCompletedWebhookRequestJob do
let(:account) { create(:account) }
let(:user) { create(:user, account:) }
let(:template) { create(:template, account:, author: user) }
let(:submission) { create(:submission, template:, created_by_user: user) }
let(:submitter) do
create(:submitter, submission:, uuid: template.submitters.first['uuid'], completed_at: Time.current)
end
let(:webhook_url) { create(:webhook_url, account:, events: ['form.completed']) }
before do
create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY,
value: GenerateCertificate.call.transform_values(&:to_pem))
end
describe '#perform' do
before do
stub_request(:post, webhook_url.url).to_return(status: 200)
end
it 'sends a webhook request' do
described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id)
expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: {
'event_type' => 'form.completed',
'timestamp' => /.*/,
'data' => JSON.parse(Submitters::SerializeForWebhook.call(submitter.reload).to_json)
},
headers: {
'Content-Type' => 'application/json',
'User-Agent' => 'DocuSeal.com Webhook'
}
).once
end
it 'sends a webhook request with the secret' do
webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' })
described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id)
expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: {
'event_type' => 'form.completed',
'timestamp' => /.*/,
'data' => JSON.parse(Submitters::SerializeForWebhook.call(submitter.reload).to_json)
},
headers: {
'Content-Type' => 'application/json',
'User-Agent' => 'DocuSeal.com Webhook',
'X-Secret-Header' => 'secret_value'
}
).once
end
it "doesn't send a webhook request if the event is not in the webhook's events" do
webhook_url.update!(events: ['form.declined'])
described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id)
expect(WebMock).not_to have_requested(:post, webhook_url.url)
end
it 'sends again if the response status is 400 or higher' do
stub_request(:post, webhook_url.url).to_return(status: 401)
expect do
described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id)
end.to change(described_class.jobs, :size).by(1)
expect(WebMock).to have_requested(:post, webhook_url.url).once
args = described_class.jobs.last['args'].first
expect(args['attempt']).to eq(1)
expect(args['last_status']).to eq(401)
expect(args['webhook_url_id']).to eq(webhook_url.id)
expect(args['submitter_id']).to eq(submitter.id)
end
it "doesn't send again if the max attempts is reached" do
stub_request(:post, webhook_url.url).to_return(status: 401)
expect do
described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, 'attempt' => 11)
end.not_to change(described_class.jobs, :size)
expect(WebMock).to have_requested(:post, webhook_url.url).once
end
end
end
@@ -0,0 +1,94 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe SendFormDeclinedWebhookRequestJob do
let(:account) { create(:account) }
let(:user) { create(:user, account:) }
let(:template) { create(:template, account:, author: user) }
let(:submission) { create(:submission, template:, created_by_user: user) }
let(:submitter) do
create(:submitter, submission:, uuid: template.submitters.first['uuid'], completed_at: Time.current)
end
let(:webhook_url) { create(:webhook_url, account:, events: ['form.declined']) }
before do
create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY,
value: GenerateCertificate.call.transform_values(&:to_pem))
end
describe '#perform' do
before do
stub_request(:post, webhook_url.url).to_return(status: 200)
end
it 'sends a webhook request' do
described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id)
expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: {
'event_type' => 'form.declined',
'timestamp' => /.*/,
'data' => JSON.parse(Submitters::SerializeForWebhook.call(submitter.reload).to_json)
},
headers: {
'Content-Type' => 'application/json',
'User-Agent' => 'DocuSeal.com Webhook'
}
).once
end
it 'sends a webhook request with the secret' do
webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' })
described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id)
expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: {
'event_type' => 'form.declined',
'timestamp' => /.*/,
'data' => JSON.parse(Submitters::SerializeForWebhook.call(submitter.reload).to_json)
},
headers: {
'Content-Type' => 'application/json',
'User-Agent' => 'DocuSeal.com Webhook',
'X-Secret-Header' => 'secret_value'
}
).once
end
it "doesn't send a webhook request if the event is not in the webhook's events" do
webhook_url.update!(events: ['form.completed'])
described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id)
expect(WebMock).not_to have_requested(:post, webhook_url.url)
end
it 'sends again if the response status is 400 or higher' do
stub_request(:post, webhook_url.url).to_return(status: 401)
expect do
described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id)
end.to change(described_class.jobs, :size).by(1)
expect(WebMock).to have_requested(:post, webhook_url.url).once
args = described_class.jobs.last['args'].first
expect(args['attempt']).to eq(1)
expect(args['last_status']).to eq(401)
expect(args['webhook_url_id']).to eq(webhook_url.id)
expect(args['submitter_id']).to eq(submitter.id)
end
it "doesn't send again if the max attempts is reached" do
stub_request(:post, webhook_url.url).to_return(status: 401)
expect do
described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, 'attempt' => 11)
end.not_to change(described_class.jobs, :size)
expect(WebMock).to have_requested(:post, webhook_url.url).once
end
end
end
@@ -0,0 +1,94 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe SendFormStartedWebhookRequestJob do
let(:account) { create(:account) }
let(:user) { create(:user, account:) }
let(:template) { create(:template, account:, author: user) }
let(:submission) { create(:submission, template:, created_by_user: user) }
let(:submitter) do
create(:submitter, submission:, uuid: template.submitters.first['uuid'], completed_at: Time.current)
end
let(:webhook_url) { create(:webhook_url, account:, events: ['form.started']) }
before do
create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY,
value: GenerateCertificate.call.transform_values(&:to_pem))
end
describe '#perform' do
before do
stub_request(:post, webhook_url.url).to_return(status: 200)
end
it 'sends a webhook request' do
described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id)
expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: {
'event_type' => 'form.started',
'timestamp' => /.*/,
'data' => JSON.parse(Submitters::SerializeForWebhook.call(submitter.reload).to_json)
},
headers: {
'Content-Type' => 'application/json',
'User-Agent' => 'DocuSeal.com Webhook'
}
).once
end
it 'sends a webhook request with the secret' do
webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' })
described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id)
expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: {
'event_type' => 'form.started',
'timestamp' => /.*/,
'data' => JSON.parse(Submitters::SerializeForWebhook.call(submitter.reload).to_json)
},
headers: {
'Content-Type' => 'application/json',
'User-Agent' => 'DocuSeal.com Webhook',
'X-Secret-Header' => 'secret_value'
}
).once
end
it "doesn't send a webhook request if the event is not in the webhook's events" do
webhook_url.update!(events: ['form.declined'])
described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id)
expect(WebMock).not_to have_requested(:post, webhook_url.url)
end
it 'sends again if the response status is 400 or higher' do
stub_request(:post, webhook_url.url).to_return(status: 401)
expect do
described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id)
end.to change(described_class.jobs, :size).by(1)
expect(WebMock).to have_requested(:post, webhook_url.url).once
args = described_class.jobs.last['args'].first
expect(args['attempt']).to eq(1)
expect(args['last_status']).to eq(401)
expect(args['webhook_url_id']).to eq(webhook_url.id)
expect(args['submitter_id']).to eq(submitter.id)
end
it "doesn't send again if the max attempts is reached" do
stub_request(:post, webhook_url.url).to_return(status: 401)
expect do
described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, 'attempt' => 11)
end.not_to change(described_class.jobs, :size)
expect(WebMock).to have_requested(:post, webhook_url.url).once
end
end
end
@@ -0,0 +1,94 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe SendFormViewedWebhookRequestJob do
let(:account) { create(:account) }
let(:user) { create(:user, account:) }
let(:template) { create(:template, account:, author: user) }
let(:submission) { create(:submission, template:, created_by_user: user) }
let(:submitter) do
create(:submitter, submission:, uuid: template.submitters.first['uuid'], completed_at: Time.current)
end
let(:webhook_url) { create(:webhook_url, account:, events: ['form.viewed']) }
before do
create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY,
value: GenerateCertificate.call.transform_values(&:to_pem))
end
describe '#perform' do
before do
stub_request(:post, webhook_url.url).to_return(status: 200)
end
it 'sends a webhook request' do
described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id)
expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: {
'event_type' => 'form.viewed',
'timestamp' => /.*/,
'data' => JSON.parse(Submitters::SerializeForWebhook.call(submitter.reload).to_json)
},
headers: {
'Content-Type' => 'application/json',
'User-Agent' => 'DocuSeal.com Webhook'
}
).once
end
it 'sends a webhook request with the secret' do
webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' })
described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id)
expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: {
'event_type' => 'form.viewed',
'timestamp' => /.*/,
'data' => JSON.parse(Submitters::SerializeForWebhook.call(submitter.reload).to_json)
},
headers: {
'Content-Type' => 'application/json',
'User-Agent' => 'DocuSeal.com Webhook',
'X-Secret-Header' => 'secret_value'
}
).once
end
it "doesn't send a webhook request if the event is not in the webhook's events" do
webhook_url.update!(events: ['form.started'])
described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id)
expect(WebMock).not_to have_requested(:post, webhook_url.url)
end
it 'sends again if the response status is 400 or higher' do
stub_request(:post, webhook_url.url).to_return(status: 401)
expect do
described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id)
end.to change(described_class.jobs, :size).by(1)
expect(WebMock).to have_requested(:post, webhook_url.url).once
args = described_class.jobs.last['args'].first
expect(args['attempt']).to eq(1)
expect(args['last_status']).to eq(401)
expect(args['webhook_url_id']).to eq(webhook_url.id)
expect(args['submitter_id']).to eq(submitter.id)
end
it "doesn't send again if the max attempts is reached" do
stub_request(:post, webhook_url.url).to_return(status: 401)
expect do
described_class.new.perform('submitter_id' => submitter.id, 'webhook_url_id' => webhook_url.id, 'attempt' => 11)
end.not_to change(described_class.jobs, :size)
expect(WebMock).to have_requested(:post, webhook_url.url).once
end
end
end
@@ -0,0 +1,92 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe SendSubmissionArchivedWebhookRequestJob do
let(:account) { create(:account) }
let(:user) { create(:user, account:) }
let(:template) { create(:template, account:, author: user) }
let(:submission) { create(:submission, template:, created_by_user: user) }
let(:webhook_url) { create(:webhook_url, account:, events: ['submission.archived']) }
before do
create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY,
value: GenerateCertificate.call.transform_values(&:to_pem))
end
describe '#perform' do
before do
stub_request(:post, webhook_url.url).to_return(status: 200)
end
it 'sends a webhook request' do
described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id)
expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: {
'event_type' => 'submission.archived',
'timestamp' => /.*/,
'data' => JSON.parse(submission.reload.as_json(only: %i[id archived_at]).to_json)
},
headers: {
'Content-Type' => 'application/json',
'User-Agent' => 'DocuSeal.com Webhook'
}
).once
end
it 'sends a webhook request with the secret' do
webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' })
described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id)
expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: {
'event_type' => 'submission.archived',
'timestamp' => /.*/,
'data' => JSON.parse(submission.reload.as_json(only: %i[id archived_at]).to_json)
},
headers: {
'Content-Type' => 'application/json',
'User-Agent' => 'DocuSeal.com Webhook',
'X-Secret-Header' => 'secret_value'
}
).once
end
it "doesn't send a webhook request if the event is not in the webhook's events" do
webhook_url.update!(events: ['submission.created'])
described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id)
expect(WebMock).not_to have_requested(:post, webhook_url.url)
end
it 'sends again if the response status is 400 or higher' do
stub_request(:post, webhook_url.url).to_return(status: 401)
expect do
described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id)
end.to change(described_class.jobs, :size).by(1)
expect(WebMock).to have_requested(:post, webhook_url.url).once
args = described_class.jobs.last['args'].first
expect(args['attempt']).to eq(1)
expect(args['last_status']).to eq(401)
expect(args['webhook_url_id']).to eq(webhook_url.id)
expect(args['submission_id']).to eq(submission.id)
end
it "doesn't send again if the max attempts is reached" do
stub_request(:post, webhook_url.url).to_return(status: 401)
expect do
described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id,
'attempt' => 11)
end.not_to change(described_class.jobs, :size)
expect(WebMock).to have_requested(:post, webhook_url.url).once
end
end
end
@@ -0,0 +1,92 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe SendSubmissionCompletedWebhookRequestJob do
let(:account) { create(:account) }
let(:user) { create(:user, account:) }
let(:template) { create(:template, account:, author: user) }
let(:submission) { create(:submission, :with_submitters, template:, created_by_user: user) }
let(:webhook_url) { create(:webhook_url, account:, events: ['submission.completed']) }
before do
create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY,
value: GenerateCertificate.call.transform_values(&:to_pem))
end
describe '#perform' do
before do
stub_request(:post, webhook_url.url).to_return(status: 200)
end
it 'sends a webhook request' do
described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id)
expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: {
'event_type' => 'submission.completed',
'timestamp' => /.*/,
'data' => JSON.parse(Submissions::SerializeForApi.call(submission.reload).to_json)
},
headers: {
'Content-Type' => 'application/json',
'User-Agent' => 'DocuSeal.com Webhook'
}
).once
end
it 'sends a webhook request with the secret' do
webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' })
described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id)
expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: {
'event_type' => 'submission.completed',
'timestamp' => /.*/,
'data' => JSON.parse(Submissions::SerializeForApi.call(submission.reload).to_json)
},
headers: {
'Content-Type' => 'application/json',
'User-Agent' => 'DocuSeal.com Webhook',
'X-Secret-Header' => 'secret_value'
}
).once
end
it "doesn't send a webhook request if the event is not in the webhook's events" do
webhook_url.update!(events: ['submission.archived'])
described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id)
expect(WebMock).not_to have_requested(:post, webhook_url.url)
end
it 'sends again if the response status is 400 or higher' do
stub_request(:post, webhook_url.url).to_return(status: 401)
expect do
described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id)
end.to change(described_class.jobs, :size).by(1)
expect(WebMock).to have_requested(:post, webhook_url.url).once
args = described_class.jobs.last['args'].first
expect(args['attempt']).to eq(1)
expect(args['last_status']).to eq(401)
expect(args['webhook_url_id']).to eq(webhook_url.id)
expect(args['submission_id']).to eq(submission.id)
end
it "doesn't send again if the max attempts is reached" do
stub_request(:post, webhook_url.url).to_return(status: 401)
expect do
described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id,
'attempt' => 11)
end.not_to change(described_class.jobs, :size)
expect(WebMock).to have_requested(:post, webhook_url.url).once
end
end
end
@@ -0,0 +1,92 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe SendSubmissionCreatedWebhookRequestJob do
let(:account) { create(:account) }
let(:user) { create(:user, account:) }
let(:template) { create(:template, account:, author: user) }
let(:submission) { create(:submission, :with_submitters, template:, created_by_user: user) }
let(:webhook_url) { create(:webhook_url, account:, events: ['submission.created']) }
before do
create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY,
value: GenerateCertificate.call.transform_values(&:to_pem))
end
describe '#perform' do
before do
stub_request(:post, webhook_url.url).to_return(status: 200)
end
it 'sends a webhook request' do
described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id)
expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: {
'event_type' => 'submission.created',
'timestamp' => /.*/,
'data' => JSON.parse(Submissions::SerializeForApi.call(submission.reload).to_json)
},
headers: {
'Content-Type' => 'application/json',
'User-Agent' => 'DocuSeal.com Webhook'
}
).once
end
it 'sends a webhook request with the secret' do
webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' })
described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id)
expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: {
'event_type' => 'submission.created',
'timestamp' => /.*/,
'data' => JSON.parse(Submissions::SerializeForApi.call(submission.reload).to_json)
},
headers: {
'Content-Type' => 'application/json',
'User-Agent' => 'DocuSeal.com Webhook',
'X-Secret-Header' => 'secret_value'
}
).once
end
it "doesn't send a webhook request if the event is not in the webhook's events" do
webhook_url.update!(events: ['submission.completed'])
described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id)
expect(WebMock).not_to have_requested(:post, webhook_url.url)
end
it 'sends again if the response status is 400 or higher' do
stub_request(:post, webhook_url.url).to_return(status: 401)
expect do
described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id)
end.to change(described_class.jobs, :size).by(1)
expect(WebMock).to have_requested(:post, webhook_url.url).once
args = described_class.jobs.last['args'].first
expect(args['attempt']).to eq(1)
expect(args['last_status']).to eq(401)
expect(args['webhook_url_id']).to eq(webhook_url.id)
expect(args['submission_id']).to eq(submission.id)
end
it "doesn't send again if the max attempts is reached" do
stub_request(:post, webhook_url.url).to_return(status: 401)
expect do
described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id,
'attempt' => 11)
end.not_to change(described_class.jobs, :size)
expect(WebMock).to have_requested(:post, webhook_url.url).once
end
end
end
@@ -0,0 +1,90 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe SendTemplateCreatedWebhookRequestJob do
let(:account) { create(:account) }
let(:user) { create(:user, account:) }
let(:template) { create(:template, account:, author: user) }
let(:webhook_url) { create(:webhook_url, account:, events: ['template.created']) }
before do
create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY,
value: GenerateCertificate.call.transform_values(&:to_pem))
end
describe '#perform' do
before do
stub_request(:post, webhook_url.url).to_return(status: 200)
end
it 'sends a webhook request' do
described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id)
expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: {
'event_type' => 'template.created',
'timestamp' => /.*/,
'data' => JSON.parse(Templates::SerializeForApi.call(template.reload).to_json)
},
headers: {
'Content-Type' => 'application/json',
'User-Agent' => 'DocuSeal.com Webhook'
}
).once
end
it 'sends a webhook request with the secret' do
webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' })
described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id)
expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: {
'event_type' => 'template.created',
'timestamp' => /.*/,
'data' => JSON.parse(Templates::SerializeForApi.call(template.reload).to_json)
},
headers: {
'Content-Type' => 'application/json',
'User-Agent' => 'DocuSeal.com Webhook',
'X-Secret-Header' => 'secret_value'
}
).once
end
it "doesn't send a webhook request if the event is not in the webhook's events" do
webhook_url.update!(events: ['template.updated'])
described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id)
expect(WebMock).not_to have_requested(:post, webhook_url.url)
end
it 'sends again if the response status is 400 or higher' do
stub_request(:post, webhook_url.url).to_return(status: 401)
expect do
described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id)
end.to change(described_class.jobs, :size).by(1)
expect(WebMock).to have_requested(:post, webhook_url.url).once
args = described_class.jobs.last['args'].first
expect(args['attempt']).to eq(1)
expect(args['last_status']).to eq(401)
expect(args['webhook_url_id']).to eq(webhook_url.id)
expect(args['template_id']).to eq(template.id)
end
it "doesn't send again if the max attempts is reached" do
stub_request(:post, webhook_url.url).to_return(status: 401)
expect do
described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id, 'attempt' => 11)
end.not_to change(described_class.jobs, :size)
expect(WebMock).to have_requested(:post, webhook_url.url).once
end
end
end
@@ -0,0 +1,90 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe SendTemplateUpdatedWebhookRequestJob do
let(:account) { create(:account) }
let(:user) { create(:user, account:) }
let(:template) { create(:template, account:, author: user) }
let(:webhook_url) { create(:webhook_url, account:, events: ['template.updated']) }
before do
create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY,
value: GenerateCertificate.call.transform_values(&:to_pem))
end
describe '#perform' do
before do
stub_request(:post, webhook_url.url).to_return(status: 200)
end
it 'sends a webhook request' do
described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id)
expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: {
'event_type' => 'template.updated',
'timestamp' => /.*/,
'data' => JSON.parse(Templates::SerializeForApi.call(template.reload).to_json)
},
headers: {
'Content-Type' => 'application/json',
'User-Agent' => 'DocuSeal.com Webhook'
}
).once
end
it 'sends a webhook request with the secret' do
webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' })
described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id)
expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: {
'event_type' => 'template.updated',
'timestamp' => /.*/,
'data' => JSON.parse(Templates::SerializeForApi.call(template.reload).to_json)
},
headers: {
'Content-Type' => 'application/json',
'User-Agent' => 'DocuSeal.com Webhook',
'X-Secret-Header' => 'secret_value'
}
).once
end
it "doesn't send a webhook request if the event is not in the webhook's events" do
webhook_url.update!(events: ['template.created'])
described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id)
expect(WebMock).not_to have_requested(:post, webhook_url.url)
end
it 'sends again if the response status is 400 or higher' do
stub_request(:post, webhook_url.url).to_return(status: 401)
expect do
described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id)
end.to change(described_class.jobs, :size).by(1)
expect(WebMock).to have_requested(:post, webhook_url.url).once
args = described_class.jobs.last['args'].first
expect(args['attempt']).to eq(1)
expect(args['last_status']).to eq(401)
expect(args['webhook_url_id']).to eq(webhook_url.id)
expect(args['template_id']).to eq(template.id)
end
it "doesn't send again if the max attempts is reached" do
stub_request(:post, webhook_url.url).to_return(status: 401)
expect do
described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id, 'attempt' => 11)
end.not_to change(described_class.jobs, :size)
expect(WebMock).to have_requested(:post, webhook_url.url).once
end
end
end
+3 -1
View File
@@ -44,7 +44,9 @@ RSpec.describe 'Template' do
it 'archives a template' do it 'archives a template' do
expect do expect do
click_button 'Archive' accept_confirm('Are you sure?') do
click_button 'Archive'
end
end.to change { Template.active.count }.by(-1) end.to change { Template.active.count }.by(-1)
expect(page).to have_content('Template has been archived') expect(page).to have_content('Template has been archived')
+167 -9
View File
@@ -8,24 +8,182 @@ RSpec.describe 'Webhook Settings' do
before do before do
sign_in(user) sign_in(user)
end
it 'shows webhook settings page with empty form when there are no webhooks' do
visit settings_webhooks_path visit settings_webhooks_path
end
it 'shows webhook settings page' do expect(page).to have_content('Webhook')
expect(page).to have_content('Webhooks') expect(page).to have_content('Webhook URL')
expect(page).to have_field('Webhook URL') expect(page).to have_field('webhook_url[url]', type: 'url')
expect(page).to have_button('Save') expect(page).to have_button('Save')
WebhookUrl::EVENTS.each do |event|
expect(page).to have_field(event, type: 'checkbox')
end
end end
it 'updates the webhook URL' do it 'shows list of webhooks when there are more than one' do
fill_in 'Webhook URL', with: 'https://example.com' webhook_urls = create_list(:webhook_url, 2, account:)
visit settings_webhooks_path
expect(page).to have_content('Webhooks')
expect(page).to have_link('New Webhook')
webhook_urls.each do |webhook_url|
expect(page).to have_content(webhook_url.url)
within("a[href='#{settings_webhook_path(webhook_url)}']") do
webhook_url.events.each do |event|
expect(page).to have_content(event)
end
end
end
end
it 'shows webhook settings page with pre-filled form when there is one webhook' do
webhook_url = create(:webhook_url, account:)
visit settings_webhooks_path
expect(page).to have_content('Webhook')
expect(page).to have_field('webhook_url[url]', type: 'url', with: webhook_url.url)
expect(page).to have_button('Save')
expect(page).to have_button('Delete')
expect(page).to have_link('Add Secret')
WebhookUrl::EVENTS.each do |event|
expect(page).to have_field(event, type: 'checkbox', checked: webhook_url.events.include?(event))
end
end
it 'creates the webhook' do
visit settings_webhooks_path
fill_in 'webhook_url[url]', with: 'https://example.com/webhook'
expect do expect do
click_button 'Save' click_button 'Save'
end.to change(EncryptedConfig, :count).by(1) end.to change(WebhookUrl, :count).by(1)
encrypted_config = EncryptedConfig.find_by(account:, key: EncryptedConfig::WEBHOOK_URL_KEY) webhook_url = account.webhook_urls.first
expect(encrypted_config.value).to eq('https://example.com') expect(webhook_url.url).to eq('https://example.com/webhook')
expect(page).to have_content('Webhook URL has been saved.')
expect(page.current_path).to eq(settings_webhooks_path)
end
it 'updates the webhook' do
webhook_url = create(:webhook_url, account:, url: 'https://example.com/webhook')
visit settings_webhooks_path
fill_in 'webhook_url[url]', with: 'https://example.org/webhook'
click_button 'Save'
webhook_url.reload
expect(webhook_url.url).to eq('https://example.org/webhook')
expect(page).to have_content('Webhook URL has been updated.')
expect(page.current_path).to eq(settings_webhooks_path)
end
it 'deletes the webhook' do
create(:webhook_url, account:)
visit settings_webhooks_path
expect do
accept_confirm('Are you sure?') do
click_button 'Delete'
end
end.to change(WebhookUrl, :count).by(-1)
expect(page).to have_content('Webhook URL has been deleted.')
expect(page.current_path).to eq(settings_webhooks_path)
end
it 'updates the webhook events' do
webhook_url = create(:webhook_url, account:)
visit settings_webhooks_path
expect(webhook_url.events).not_to include('submission.created')
check('submission.created')
webhook_url.reload
expect(webhook_url.events).to include('submission.created')
end
it 'adds a secret to the webhook' do
webhook_url = create(:webhook_url, account:)
visit settings_webhooks_path
expect(webhook_url.secret).to eq({})
click_link 'Add Secret'
within '#modal' do
fill_in 'Key', with: 'X-Signature'
fill_in 'Value', with: 'secret-value'
click_button 'Submit'
webhook_url.reload
expect(webhook_url.secret).to eq({ 'X-Signature' => 'secret-value' })
end
expect(page).to have_link('Edit Secret')
expect(page).to have_content('Webhook Secret has been saved.')
end
it 'removes a secret from the webhook' do
webhook_url = create(:webhook_url, account:, secret: { 'X-Signature' => 'secret-value' })
visit settings_webhooks_path
click_link 'Edit Secret'
within '#modal' do
fill_in 'Key', with: ''
fill_in 'Value', with: ''
click_button 'Submit'
webhook_url.reload
expect(webhook_url.secret).to eq({})
end
expect(page).to have_link('Add Secret')
expect(page).to have_content('Webhook Secret has been saved.')
end
context 'when testing the webhook' do
let!(:webhook_url) { create(:webhook_url, account:) }
let!(:template) { create(:template, account:, author: user) }
let!(:submission) { create(:submission, template:, created_by_user: user) }
let!(:submitter) do
create(:submitter, submission:, uuid: template.submitters.first['uuid'], completed_at: Time.current)
end
it 'sends the webhook request' do
visit settings_webhooks_path
expect do
click_button 'Test Webhook'
end.to change(SendFormCompletedWebhookRequestJob.jobs, :size).by(1)
args = SendFormCompletedWebhookRequestJob.jobs.last['args'].first
expect(args['webhook_url_id']).to eq(webhook_url.id)
expect(args['submitter_id']).to eq(submitter.id)
expect(page).to have_content('Webhook request has been sent.')
end
end end
end end