Compare commits

..
20 Commits
Author SHA1 Message Date
Alex Turchyn d875477ee6 improve SMTP configs form 2023-08-10 23:44:49 +03:00
Alex Turchyn 1daee8214c fix anonymous checkboxes form 2023-08-09 21:10:07 +03:00
Alex Turchyn 9cc85595e3 fix font size on large pdfs 2023-08-09 10:09:36 +03:00
Alex Turchyn f6879d7f94 fix GitHub CI 2023-08-08 23:38:53 +03:00
Alex Turchyn f6247e68b5 fix typo 2023-08-08 23:28:26 +03:00
Alex Turchyn 454acb63db update readme 2023-08-08 23:28:26 +03:00
Alex Turchyn 6d78d5430c add access tokens 2023-08-08 23:28:26 +03:00
Alex Turchyn e75e7b18b4 developer console fixes 2023-08-08 23:28:26 +03:00
Alex Turchyn 644b142798 add source to submissions 2023-08-08 23:28:25 +03:00
Alex Turchyn e4bb5466f5 add webhook settings 2023-08-08 23:28:25 +03:00
Alex Turchyn 8929adbe83 add settings api page 2023-08-08 23:28:25 +03:00
Alex Turchyn 896b149b25 add console redirect 2023-08-08 23:28:25 +03:00
Alex Turchyn 7697b94f73 show alert when upload non pdf or image 2023-08-08 23:28:25 +03:00
Alex Turchyn 7912c9a1ee add submitters api 2023-08-08 23:28:25 +03:00
Alex Turchyn 63e1246a11 adjust readme 2023-08-08 23:27:44 +03:00
Alex Turchyn cbca34a7d7 add reply to into submission invitation email 2023-08-02 23:22:31 +03:00
Alex Turchyn 9471a510c9 adjust email footer 2023-08-02 23:22:31 +03:00
Alex Turchyn e754c5d414 update README 2023-08-02 14:25:23 +03:00
Alex Turchyn 182f593d86 fix form previous day date bug 2023-08-01 00:26:37 +03:00
Alex Turchyn 7f4548e5c7 use company name for email friendly from name 2023-07-31 22:53:49 +03:00
57 changed files with 679 additions and 69 deletions

No files matched your search

+1
View File
@@ -27,3 +27,4 @@
/attachments
/docuseal
.DS_Store
.env
+1
View File
@@ -127,6 +127,7 @@ jobs:
bundle config path vendor/bundle
bundle install --jobs 4 --retry 4
yarn install
sudo apt-get update
sudo apt-get install libvips
- name: Run
env:
+1
View File
@@ -35,3 +35,4 @@ yarn-debug.log*
/coverage
/attachments
/docuseal
/ee
+1 -1
View File
@@ -27,7 +27,7 @@ Lint/MissingSuper:
Enabled: false
Metrics/MethodLength:
Max: 20
Max: 25
Exclude:
- 'db/migrate/**'
+1
View File
@@ -13,6 +13,7 @@ gem 'faraday'
gem 'google-cloud-storage', require: false
gem 'hexapdf'
gem 'image_processing'
gem 'jwt'
gem 'lograge'
gem 'mysql2', require: false
gem 'oj'
+1
View File
@@ -540,6 +540,7 @@ DEPENDENCIES
google-cloud-storage
hexapdf
image_processing
jwt
letter_opener_web
lograge
mysql2
+4 -2
View File
@@ -42,6 +42,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
- [x] PDF signature verification
- [x] Users management
- [x] Mobile-optimized
- [x] API and Webhooks for integrations
- [x] Easy to deploy in minutes
## Deploy
@@ -76,10 +77,11 @@ HOST=your-domain-name.com docker-compose up
## For Companies
### Integrate seamless document signing into your web or mobile apps with DocuSeal!
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, 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 an Integration Demo](https://cal.com/book-with-cal-dark.svg)](https://cal.com/docuseal)
## License
DocuSeal is released under the GNU Affero General Public License v3.0.
Distributed under the AGPLv3 License. See [LICENSE](https://github.com/docusealco/docuseal/blob/master/LICENSE) for more information.
Unless otherwise noted, all files © 2023 Oleksandr Turchyn.
@@ -0,0 +1,40 @@
# frozen_string_literal: true
module Api
class SubmissionsController < ApiBaseController
def create
template = current_account.templates.find(params[:template_id])
submissions =
if params[:emails].present?
Submissions.create_from_emails(template:,
user: current_user,
source: :api,
send_email: params[:send_email] != 'false',
emails: params[:emails] || params[:email])
else
Submissions.create_from_submitters(template:,
user: current_user,
source: :api,
send_email: params[:send_email] != 'false',
submissions_attrs: submissions_params[:submission])
end
submitters = submissions.flat_map(&:submitters)
if params[:send_email] != 'false'
submitters.each do |submitter|
SubmitterMailer.invitation_email(submitter, message: params[:message]).deliver_later!
end
end
render json: submitters
end
private
def submissions_params
params.permit(submission: [{ submitters: [%i[uuid name email]] }])
end
end
end
@@ -2,6 +2,10 @@
module Api
class TemplatesController < ApiBaseController
def index
render json: current_account.templates
end
def update
@template = current_account.templates.find(params[:id])
@@ -0,0 +1,5 @@
# frozen_string_literal: true
class ApiSettingsController < ApplicationController
def index; end
end
+2 -2
View File
@@ -34,8 +34,8 @@ class ApplicationController < ActionController::Base
redirect_to setup_index_path unless User.exists?
end
def button_title(title: 'Submit', disabled_with: 'Submitting', icon: nil)
render_to_string(partial: 'shared/button_title', locals: { title:, disabled_with:, icon: })
def button_title(title: 'Submit', disabled_with: 'Submitting', icon: nil, icon_disabled: nil)
render_to_string(partial: 'shared/button_title', locals: { title:, disabled_with:, icon:, icon_disabled: })
end
def svg_icon(icon_name, class: '')
@@ -0,0 +1,11 @@
# frozen_string_literal: true
class ConsoleRedirectController < ApplicationController
def index
auth = JsonWebToken.encode(uuid: current_user.uuid,
scope: :console,
exp: 1.minute.from_now.to_i)
redirect_to("#{Docuseal::CONSOLE_URL}?#{{ auth: }.to_query}", allow_other_host: true)
end
end
@@ -7,10 +7,16 @@ class EmailSettingsController < ApplicationController
def create
if @encrypted_config.update(storage_configs)
SettingsMailer.smtp_successful_setup(@encrypted_config.value['from_email']).deliver_now!
redirect_to settings_email_index_path, notice: 'Changes have been saved'
else
render :index, status: :unprocessable_entity
end
rescue Net::SMTPError, OpenSSL::SSL::SSLError, Net::ReadTimeout => e
flash[:alert] = e.message
render :index, status: :unprocessable_entity
end
private
+1 -1
View File
@@ -25,7 +25,7 @@ class StartFormController < ApplicationController
ua: request.user_agent
)
@submitter.submission ||= Submission.new(template: @template)
@submitter.submission ||= Submission.new(template: @template, source: :link)
if @submitter.save
redirect_to submit_form_path(@submitter.slug)
+10 -26
View File
@@ -17,9 +17,17 @@ class SubmissionsController < ApplicationController
def create
submissions =
if params[:emails].present?
create_submissions_from_emails
Submissions.create_from_emails(template: @template,
user: current_user,
source: :invite,
send_email: params[:send_email] == '1',
emails: params[:emails])
else
create_submissions_from_submitters
Submissions.create_from_submitters(template: @template,
user: current_user,
source: :invite,
send_email: params[:send_email] == '1',
submissions_attrs: submissions_params[:submission].to_h.values)
end
submitters = submissions.flat_map(&:submitters)
@@ -45,30 +53,6 @@ class SubmissionsController < ApplicationController
private
def create_submissions_from_emails
emails = params[:emails].to_s.scan(User::EMAIL_REGEXP)
emails.map do |email|
submission = @template.submissions.new(created_by_user: current_user)
submission.submitters.new(email:, uuid: @template.submitters.first['uuid'],
sent_at: params[:send_email] == '1' ? Time.current : nil)
submission.tap(&:save!)
end
end
def create_submissions_from_submitters
submissions_params[:submission].to_h.map do |_, attrs|
submission = @template.submissions.new(created_by_user: current_user)
attrs[:submitters].each do |submitter_attrs|
submission.submitters.new(**submitter_attrs, sent_at: params[:send_email] == '1' ? Time.current : nil)
end
submission.tap(&:save!)
end
end
def submissions_params
params.permit(submission: { submitters: [%i[uuid email]] })
end
+15 -4
View File
@@ -17,11 +17,8 @@ class SubmitFormController < ApplicationController
def update
submitter = Submitter.find_by!(slug: params[:slug])
submitter.values.merge!(normalized_values)
submitter.completed_at = Time.current if params[:completed] == 'true'
submitter.opened_at ||= Time.current
submitter.save!
update_submitter!(submitter)
Submissions.update_template_fields!(submitter.submission) if submitter.submission.template_fields.blank?
@@ -30,6 +27,10 @@ class SubmitFormController < ApplicationController
if submitter.completed_at?
GenerateSubmitterResultAttachmentsJob.perform_later(submitter)
if submitter.account.encrypted_configs.exists?(key: EncryptedConfig::WEBHOOK_URL_KEY)
SendWebhookRequestJob.perform_later(submitter)
end
submitter.submission.template.account.users.active.each do |user|
SubmitterMailer.completed_email(submitter, user).deliver_later!
end
@@ -44,6 +45,16 @@ class SubmitFormController < ApplicationController
private
def update_submitter!(submitter)
submitter.values.merge!(normalized_values)
submitter.completed_at = Time.current if params[:completed] == 'true'
submitter.opened_at ||= Time.current
submitter.save!
submitter
end
def normalized_values
params.fetch(:values, {}).to_unsafe_h.transform_values do |v|
if params[:cast_boolean] == 'true'
@@ -0,0 +1,31 @@
# frozen_string_literal: true
class WebhookSettingsController < ApplicationController
def show
@encrypted_config =
current_account.encrypted_configs.find_or_initialize_by(key: EncryptedConfig::WEBHOOK_URL_KEY)
end
def create
@encrypted_config =
current_account.encrypted_configs.find_or_initialize_by(key: EncryptedConfig::WEBHOOK_URL_KEY)
@encrypted_config.update!(encrypted_config_params)
redirect_back(fallback_location: settings_webhooks_path, notice: 'Webhook URL has been saved.')
end
def update
submitter = current_account.submitters.where.not(completed_at: nil).order(:id).last
SendWebhookRequestJob.perform_later(submitter)
redirect_back(fallback_location: settings_webhooks_path, notice: 'Webhook request has been sent.')
end
private
def encrypted_config_params
params.require(:encrypted_config).permit(:value)
end
end
+4
View File
@@ -70,3 +70,7 @@ button[disabled] .enabled {
.base-radio {
@apply radio bg-white radio-sm no-animation;
}
.base-select {
@apply select base-input w-full font-normal;
}
+1 -1
View File
@@ -1,7 +1,7 @@
export default class extends HTMLElement {
connectedCallback () {
this.querySelectorAll('a').forEach((link) => {
if (document.location.pathname.startsWith(link.pathname)) {
if (document.location.pathname.startsWith(link.pathname) && !link.getAttribute('href').startsWith('http')) {
link.classList.add('bg-base-300')
}
})
+1 -1
View File
@@ -212,7 +212,7 @@ export default {
},
formattedDate () {
if (this.field.type === 'date' && this.modelValue) {
return new Intl.DateTimeFormat([], { year: 'numeric', month: 'long', day: 'numeric' }).format(new Date(this.modelValue))
return new Intl.DateTimeFormat([], { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }).format(new Date(this.modelValue))
} else {
return ''
}
+7
View File
@@ -195,6 +195,13 @@
>
<template v-if="isAnonymousChecboxes">
Complete hightlighted checkboxes and click <span class="font-semibold">{{ stepFields.length === currentStep + 1 ? 'submit' : 'next' }}</span>.
<input
v-for="field in currentStepFields"
:key="field.uuid"
type="hidden"
:name="`values[${field.uuid}]`"
:value="!!values[field.uuid]"
>
</template>
<template v-else>
<div
+6 -2
View File
@@ -101,9 +101,13 @@ export default {
methods: {
upload: Upload.methods.upload,
onDropFiles (e) {
this.$refs.input.files = e.dataTransfer.files
if ([...e.dataTransfer.files].every((f) => f.type.match(/(?:image\/)|(?:application\/pdf)/))) {
this.$refs.input.files = e.dataTransfer.files
this.upload()
this.upload()
} else {
alert('Only PDF and images are supported.')
}
}
}
}
+1 -1
View File
@@ -76,7 +76,7 @@
Draw a text field on the page with a mouse
</li>
<li>
Single click of the page to add a checkbox
Single click on the page to add a checkbox
</li>
<li>
Drag &amp; drop any other field type on the page
+24
View File
@@ -0,0 +1,24 @@
# frozen_string_literal: true
class SendWebhookRequestJob < ApplicationJob
USER_AGENT = 'DocuSeal.co Webhook'
def perform(submitter)
config = submitter.submission.account.encrypted_configs.find_by(key: EncryptedConfig::WEBHOOK_URL_KEY)
return if config.blank? || config.value.blank?
Submissions::EnsureResultGenerated.call(submitter)
ActiveStorage::Current.url_options = Docuseal.default_url_options
Faraday.post(config.value,
{
event_type: 'form.submitted',
timestamp: Time.current.iso8601,
data: Submitters::SerializeForWebhook.call(submitter)
}.to_json,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
end
end
+7
View File
@@ -0,0 +1,7 @@
# frozen_string_literal: true
class SettingsMailer < ApplicationMailer
def smtp_successful_setup(email)
mail(to: email, subject: 'SMTP has been configured')
end
end
+4 -3
View File
@@ -3,12 +3,13 @@
class SubmitterMailer < ApplicationMailer
DEFAULT_MESSAGE = %(You have been invited to submit the "%<name>s" form:)
def invitation_email(submitter, message: format(DEFAULT_MESSAGE, name: submitter.submission.template.name))
def invitation_email(submitter, message: '')
@submitter = submitter
@message = message
@message = message.presence || format(DEFAULT_MESSAGE, name: submitter.submission.template.name)
mail(to: @submitter.email,
subject: 'You have been invited to submit a form')
subject: 'You have been invited to submit a form',
reply_to: submitter.submission.created_by_user&.friendly_name)
end
def completed_email(submitter, user)
+39
View File
@@ -0,0 +1,39 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: access_tokens
#
# id :bigint not null, primary key
# sha256 :text not null
# token :text not null
# created_at :datetime not null
# updated_at :datetime not null
# user_id :bigint not null
#
# Indexes
#
# index_access_tokens_on_sha256 (sha256) UNIQUE
# index_access_tokens_on_user_id (user_id)
#
# Foreign Keys
#
# fk_rails_... (user_id => users.id)
#
class AccessToken < ApplicationRecord
TOKEN_LENGTH = 22
belongs_to :user
before_validation :set_sha256, on: :create
attribute :token, :string, default: -> { SecureRandom.base58(TOKEN_LENGTH) }
encrypts :token
private
def set_sha256
self.sha256 = Digest::SHA256.hexdigest(token)
end
end
+2
View File
@@ -15,6 +15,8 @@ class Account < ApplicationRecord
has_many :users, dependent: :destroy
has_many :encrypted_configs, dependent: :destroy
has_many :templates, dependent: :destroy
has_many :submissions, through: :templates
has_many :submitters, through: :submissions
has_many :active_users, -> { active }, dependent: :destroy,
inverse_of: :account, class_name: 'User'
+1
View File
@@ -25,6 +25,7 @@ class EncryptedConfig < ApplicationRecord
EMAIL_SMTP_KEY = 'action_mailer_smtp'
ESIGN_CERTS_KEY = 'esign_certs'
APP_URL_KEY = 'app_url'
WEBHOOK_URL_KEY = 'webhook_url'
belongs_to :account
+11
View File
@@ -6,6 +6,7 @@
#
# id :bigint not null, primary key
# deleted_at :datetime
# source :text not null
# template_fields :text
# template_schema :text
# template_submitters :text
@@ -26,6 +27,7 @@
#
class Submission < ApplicationRecord
belongs_to :template
has_one :account, through: :template
belongs_to :created_by_user, class_name: 'User', optional: true
has_many :submitters, dependent: :destroy
@@ -34,5 +36,14 @@ class Submission < ApplicationRecord
serialize :template_schema, JSON
serialize :template_submitters, JSON
attribute :source, :string, default: 'link'
scope :active, -> { where(deleted_at: nil) }
enum :source, {
invite: 'invite',
api: 'api',
embed: 'embed',
link: 'link'
}, scope: false, prefix: true
end
+2
View File
@@ -30,6 +30,8 @@
#
class Submitter < ApplicationRecord
belongs_to :submission
has_one :template, through: :submission
has_one :account, through: :template
attribute :values, :string, default: -> { {} }
attribute :slug, :string, default: -> { SecureRandom.base58(14) }
+9 -2
View File
@@ -22,6 +22,7 @@
# role :string not null
# sign_in_count :integer default(0), not null
# unlock_token :string
# uuid :text not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
@@ -32,6 +33,7 @@
# index_users_on_email (email) UNIQUE
# index_users_on_reset_password_token (reset_password_token) UNIQUE
# index_users_on_unlock_token (unlock_token) UNIQUE
# index_users_on_uuid (uuid) UNIQUE
#
# Foreign Keys
#
@@ -40,18 +42,23 @@
class User < ApplicationRecord
ROLES = %w[admin].freeze
EMAIL_REGEXP =
/[a-z0-9][.']?(?:(?:[a-z0-9_-]++[.'])*[a-z0-9_-]++)*@(?:[a-z0-9]++[.-])*[a-z0-9]++\.[a-z]{2,}/i
EMAIL_REGEXP = /[^@,\s]+@[^@,\s]+/
belongs_to :account
has_one :access_token, dependent: :destroy
devise :database_authenticatable, :recoverable, :rememberable, :validatable, :trackable
devise :registerable, :omniauthable, omniauth_providers: [:google_oauth2] if Docuseal.multitenant?
attribute :role, :string, default: 'admin'
attribute :uuid, :string, default: -> { SecureRandom.uuid }
scope :active, -> { where(deleted_at: nil) }
def access_token
super || build_access_token.tap(&:save!)
end
def active_for_authentication?
!deleted_at?
end
+2 -2
View File
@@ -11,13 +11,13 @@
<div class="grid md:grid-cols-2 gap-4">
<div class="form-control">
<%= ff.label :timezone, class: 'label' %>
<%= ff.select :timezone, nil, {}, class: 'select base-input w-full font-normal' do %>
<%= ff.select :timezone, nil, {}, class: 'base-select' do %>
<%= time_zone_options_for_select(current_account.timezone) %>
<% end %>
</div>
<div class="form-control">
<%= ff.label :locale, 'Time format', class: 'label' %>
<%= ff.select :locale, options_for_select(controller.class::LOCALE_OPTIONS.invert, current_account.locale), {}, class: 'select base-input w-full font-normal' %>
<%= ff.select :locale, options_for_select(controller.class::LOCALE_OPTIONS.invert, current_account.locale), {}, class: 'base-select' %>
</div>
</div>
<% end %>
+76
View File
@@ -0,0 +1,76 @@
<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">
<h1 class="text-4xl font-bold mb-4">API</h1>
<div class="card bg-base-200">
<div class="card-body p-6">
<label for="api_key" class="text-sm font-semibold">X-Auth-Token</label>
<div class="flex flex-row space-x-4">
<input id="api_key" type="text" value="<%= current_user.access_token.token %>" class="input font-mono input-bordered w-full" autocomplete="off" readonly>
<%= 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: 'Copy', copied_title: 'Copied' %>
</div>
</div>
</div>
<div class="space-y-4 mt-4">
<div class="collapse collapse-plus bg-base-200 px-1">
<input type="checkbox">
<div class="collapse-title text-xl font-medium">
<div>
Request signature, single submitter
</div>
<div class="mt-1">
<div class="badge badge-warning badge-lg">POST</div>
<div class="badge badge-primary badge-lg"><%= api_submissions_path %></div>
</div>
</div>
<div class="collapse-content" style="display: inherit">
<div class="mockup-code overflow-hidden">
<% text = capture do %>curl --location '<%= api_submissions_url %>' \
--header 'X-Auth-Token: <%= current_user.access_token.token %>' \
--data-raw '{
"template_id": <%= current_account.templates.last&.id || 1 %>,
"emails": "<%= current_user.email.sub('@', '+test@') %>, <%= current_user.email.sub('@', '+test2@') %>"
}'<% end.to_str %>
<span class="top-0 right-0 absolute">
<%= render 'shared/clipboard_copy', icon: 'copy', text:, class: 'btn btn-ghost text-white', icon_class: 'w-6 h-6 text-white', copy_title: 'Copy', copied_title: 'Copied' %>
</span>
<pre data-prefix="$"><code class="overflow-hidden w-full"><%= text %></code></pre>
</div>
</div>
</div>
<div class="collapse collapse-plus bg-base-200 px-1">
<input type="checkbox">
<div class="collapse-title text-xl font-medium">
<div>
Request signature, multiple submitters
</div>
<div class="mt-1">
<div class="badge badge-warning badge-lg">POST</div>
<div class="badge badge-primary badge-lg"><%= api_submissions_path %></div>
</div>
</div>
<div class="collapse-content" style="display: inherit">
<div class="mockup-code overflow-hidden">
<% text = capture do %>curl --location '<%= api_submissions_url %>' \
--header 'X-Auth-Token: <%= current_user.access_token.token %>' \
--data-raw '{
"template_id": <%= current_account.templates.last&.id || 1 %>,
"submission": [
{
"submitters": [
{ "name": "<%= current_account.templates.last ? current_account.templates.last.submitters.first['name'] : 'First Submitter' %>", "email": "<%= current_user.email.sub('@', '+test@') %>" },
{ "name": "Second Submitter", "email": "<%= current_user.email.sub('@', '+test2@') %>" }
]
}
]
}'<% end.to_str %>
<span class="top-0 right-0 absolute">
<%= render 'shared/clipboard_copy', icon: 'copy', text:, class: 'btn btn-ghost text-white', icon_class: 'w-6 h-6 text-white', copy_title: 'Copy', copied_title: 'Copied' %>
</span>
<pre data-prefix="$"><code class="overflow-hidden w-full"><%= text %></code></pre>
</div>
</div>
</div>
</div>
</div>
</div>
+21
View File
@@ -25,6 +25,27 @@
<%= ff.password_field :password, value: value['password'], required: true, class: 'base-input' %>
</div>
</div>
<div class="grid md:grid-cols-2 gap-4">
<div class="form-control">
<%= ff.label :domain, 'Domain (optional)', class: 'label' %>
<%= ff.text_field :domain, value: value['domain'], class: 'base-input' %>
</div>
<div class="form-control">
<%= ff.label :authentication, class: 'label' %>
<%= ff.select :authentication, options_for_select([%w[Plain plain], %w[Login login], %w[CRAM-MD5 cram_md5]], value.fetch('authentication', 'plain')), { prompt: true }, required: true, class: 'base-select' %>
</div>
</div>
<div class="form-control">
<%= ff.label :security_label, 'SMTP Security', class: 'label' %>
<div class="flex items-center space-x-6">
<% [%w[None none], %w[SSL ssl], %w[TLS tls]].each do |(label, val)| %>
<%= ff.label :security, value: val, for: "#{val}_radio", class: 'label' do %>
<%= ff.radio_button :security, val, checked: (value['security'].blank? && val == 'none') || value['security'] == val, id: "#{val}_radio", class: 'base-radio mr-2' %>
<%= label %>
<% end %>
<% end %>
</div>
</div>
<div class="form-control">
<%= ff.label :from_email, 'Send from Email', class: 'label' %>
<%= ff.email_field :from_email, value: value['from_email'], required: true, class: 'base-input' %>
+1 -1
View File
@@ -12,7 +12,7 @@
---
</p>
<p>
Sent using <a href="<%= Docuseal::PRODUCT_URL %>"><%= Docuseal::PRODUCT_NAME %></a> documents software.
Sent using <a href="<%= Docuseal::PRODUCT_URL %>"><%= Docuseal::PRODUCT_NAME %></a> free document signing.
</p>
</body>
</html>
@@ -0,0 +1 @@
<p>SMTP has been successfully configured!</p>
+1 -1
View File
@@ -8,7 +8,7 @@
</span>
<span class="disabled">
<span class="flex items-center justify-center space-x-2">
<%= svg_icon('loader', class: 'w-6 h-6 animate-spin') %>
<%= local_assigns[:icon_disabled] || svg_icon('loader', class: 'w-6 h-6 animate-spin') %>
<span><%= disabled_with %>...</span>
</span>
</span>
+2 -2
View File
@@ -2,13 +2,13 @@
<label class="<%= local_assigns[:class] %>">
<input type="radio" class="peer hidden">
<span class="peer-checked:hidden flex items-center space-x-2">
<%= svg_icon('link', class: local_assigns[:icon_class] || 'w-6 h-6 text-white') %>
<%= svg_icon(local_assigns[:icon] || 'link', class: local_assigns[:icon_class] || 'w-6 h-6 text-white') %>
<span class="hidden md:inline">
<%= local_assigns[:copy_title] || 'Copy' %>
</span>
</span>
<span class="hidden peer-checked:flex items-center space-x-2">
<%= svg_icon('clipboard_copy', class: local_assigns[:icon_class] || 'w-6 h-6 text-white') %>
<%= svg_icon(local_assigns[:copied_icon] || 'clipboard_copy', class: local_assigns[:icon_class] || 'w-6 h-6 text-white') %>
<span class="hidden md:inline">
<%= local_assigns[:copied_title] || 'Copied' %>
</span>
+16
View File
@@ -23,6 +23,22 @@
<li>
<%= link_to 'Team', settings_users_path, class: 'text-base hover:bg-base-300' %>
</li>
<% if Docuseal.demo? || !Docuseal.multitenant? %>
<li>
<%= link_to 'API', settings_api_index_path, class: 'text-base hover:bg-base-300' %>
</li>
<li>
<%= link_to 'Webhooks', settings_webhooks_path, class: 'text-base hover:bg-base-300' %>
</li>
<% end %>
<% unless Docuseal.demo? %>
<li>
<%= link_to Docuseal.multitenant? ? console_redirect_index_path : Docuseal::CONSOLE_URL, class: 'text-base hover:bg-base-300' do %>
Console
<span class="badge badge-warning">New</span>
<% end %>
</li>
<% end %>
</ul>
</menu-active>
<div class="mx-4 border-t border-base-300 hidden md:block">
+42
View File
@@ -0,0 +1,42 @@
<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">
<h1 class="text-4xl font-bold mb-4">Webhooks</h1>
<div class="card bg-base-200">
<div class="card-body p-6">
<%= form_for @encrypted_config, url: settings_webhooks_path, method: :post, html: { autocomplete: 'off' } do |f| %>
<%= f.label :value, 'Webhook URL', class: 'text-sm font-semibold' %>
<div class="flex flex-row space-x-4 mt-2">
<%= f.text_field :value, required: true, class: 'input font-mono input-bordered w-full', placeholder: 'https://example.com/hook' %>
<%= f.button button_title(title: 'Save', disabled_with: 'Saving'), class: 'base-button w-32' %>
</div>
<% end %>
</div>
</div>
<% submitter = current_account.submitters.where.not(completed_at: nil).order(:id).last %>
<% if submitter %>
<div class="space-y-4 mt-4">
<div class="collapse collapse-open bg-base-200 px-1">
<div class="p-4 text-xl font-medium">
<div class="flex items-center justify-between">
<span>
Submission example payload
</span>
<% if @encrypted_config.value.present? %>
<%= button_to button_title(title: 'Test Webhook', disabled_with: '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 %>
<% end %>
</div>
</div>
<div class="collapse-content" style="display: inherit">
<div class="mockup-code overflow-hidden relative">
<span class="top-0 right-0 absolute">
<%= render 'shared/clipboard_copy', icon: 'copy', text: code = JSON.pretty_generate({ event_type: 'form.submitted', timestamp: Time.current.iso8601, data: Submitters::SerializeForWebhook.call(submitter) }).gsub(/^/, ' ').sub(/^\s+/, ''), class: 'btn btn-ghost text-white', icon_class: 'w-6 h-6 text-white', copy_title: 'Copy', copied_title: 'Copied' %>
</span>
<pre><code class="overflow-hidden w-full"><%= code %></code></pre>
</div>
</div>
</div>
</div>
<% end %>
</div>
</div>
+3
View File
@@ -9,6 +9,8 @@ require 'action_view/railtie'
require 'action_mailer/railtie'
require 'active_job/railtie'
require_relative '../lib/api_path_consider_json_middleware'
Bundler.require(*Rails.groups)
module DocuSeal
@@ -26,5 +28,6 @@ module DocuSeal
config.action_view.frozen_string_literal = true
config.middleware.insert_before ActionDispatch::Static, Rack::Deflater
config.middleware.insert_before ActionDispatch::Static, ApiPathConsiderJsonMiddleware
end
end
+8 -4
View File
@@ -1,5 +1,9 @@
# frozen_string_literal: true
require_relative '../../lib/auth_with_token_strategy'
Warden::Strategies.add(:auth_token, AuthWithTokenStrategy)
# Assuming you have not yet modified this file, each configuration option below
# is set to its default value. Note that some are commented out while others
# are not: uncommented lines are intended to protect your configuration from
@@ -272,10 +276,10 @@ Devise.setup do |config|
# If you want to use other strategies, that are not supported by Devise, or
# change the failure app, you can configure them inside the config.warden block.
#
# config.warden do |manager|
# manager.intercept_401 = false
# manager.default_strategies(scope: :user).unshift :some_external_strategy
# end
config.warden do |manager|
# manager.intercept_401 = false
manager.default_strategies(scope: :user).unshift(:auth_token)
end
# ==> Mountable engine configurations
# When using Devise inside an engine, let's call it `MyEngine`, and this engine
+9 -2
View File
@@ -27,9 +27,11 @@ Rails.application.routes.draw do
end
end
namespace :api do
namespace :api, defaults: { format: :json } do
resources :attachments, only: %i[create]
resources :templates, only: %i[update] do
resources :submissions, only: %i[create]
resources :templates, only: %i[update index] do
resources :submissions, only: %i[create]
resources :documents, only: %i[create destroy], controller: 'templates_documents'
end
end
@@ -39,6 +41,7 @@ Rails.application.routes.draw do
resource :newsletter, only: %i[show update]
resources :users, only: %i[new create edit update destroy]
resources :submissions, only: %i[show destroy]
resources :console_redirect, only: %i[index]
resources :templates, only: %i[new create edit show destroy] do
resources :submissions, only: %i[new create]
end
@@ -67,6 +70,10 @@ Rails.application.routes.draw do
end
resources :esign, only: %i[index create], controller: 'esign_settings'
resources :users, only: %i[index]
if !Docuseal.multitenant? || Docuseal.demo?
resources :api, only: %i[index], controller: 'api_settings'
resource :webhooks, only: %i[show create update], controller: 'webhook_settings'
end
resource :account, only: %i[show update]
resources :profile, only: %i[index] do
collection do
@@ -0,0 +1,22 @@
# frozen_string_literal: true
class AddUuidToUsers < ActiveRecord::Migration[7.0]
class MigrationUser < ApplicationRecord
self.table_name = 'users'
end
def up
add_column :users, :uuid, :text
add_index :users, :uuid, unique: true
MigrationUser.all.each do |user|
user.update_columns(uuid: SecureRandom.uuid)
end
change_column_null :users, :uuid, false
end
def down
drop_column :users, :uuid
end
end
@@ -0,0 +1,19 @@
# frozen_string_literal: true
class AddSourceToSubmissions < ActiveRecord::Migration[7.0]
class MigrationSubmission < ApplicationRecord
self.table_name = 'submissions'
end
def up
add_column :submissions, :source, :text
MigrationSubmission.where(source: nil).update_all(source: :invite)
change_column_null :submissions, :source, false
end
def down
drop_column :submissions, :source
end
end
@@ -0,0 +1,13 @@
# frozen_string_literal: true
class CreateAccessTokens < ActiveRecord::Migration[7.0]
def change
create_table :access_tokens do |t|
t.references :user, null: false, foreign_key: true, index: true
t.text :token, null: false
t.text :sha256, null: false, index: { unique: true }
t.timestamps
end
end
end
+15 -1
View File
@@ -10,10 +10,20 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2023_07_26_062428) do
ActiveRecord::Schema[7.0].define(version: 2023_08_06_140534) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
create_table "access_tokens", force: :cascade do |t|
t.bigint "user_id", null: false
t.text "token", null: false
t.text "sha256", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["sha256"], name: "index_access_tokens_on_sha256", unique: true
t.index ["user_id"], name: "index_access_tokens_on_user_id"
end
create_table "accounts", force: :cascade do |t|
t.string "name", null: false
t.string "timezone", null: false
@@ -80,6 +90,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_07_26_062428) do
t.text "template_fields"
t.text "template_schema"
t.text "template_submitters"
t.text "source", null: false
t.index ["created_by_user_id"], name: "index_submissions_on_created_by_user_id"
t.index ["template_id"], name: "index_submissions_on_template_id"
end
@@ -139,12 +150,15 @@ ActiveRecord::Schema[7.0].define(version: 2023_07_26_062428) do
t.datetime "deleted_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.text "uuid", null: false
t.index ["account_id"], name: "index_users_on_account_id"
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
t.index ["unlock_token"], name: "index_users_on_unlock_token", unique: true
t.index ["uuid"], name: "index_users_on_uuid", unique: true
end
add_foreign_key "access_tokens", "users"
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "document_generation_events", "submitters"
+18 -6
View File
@@ -19,17 +19,29 @@ module ActionMailerConfigsInterceptor
email_configs = EncryptedConfig.find_by(key: EncryptedConfig::EMAIL_SMTP_KEY)
if email_configs
message.delivery_method(:smtp, user_name: email_configs.value['username'],
password: email_configs.value['password'],
address: email_configs.value['host'],
port: email_configs.value['port'],
tls: email_configs.value['port'].to_s == '465')
message.delivery_method(:smtp, build_smtp_configs_hash(email_configs))
message.from = email_configs.value['from_email']
message.from = "#{email_configs.account.name} <#{email_configs.value['from_email']}>"
else
message.delivery_method(:test)
end
message
end
def build_smtp_configs_hash(email_configs)
value = email_configs.value
{
user_name: value['username'],
password: value['password'],
address: value['host'],
port: value['port'],
domain: value['domain'],
authentication: value.fetch('authentication', 'plain'),
enable_starttls_auto: true,
ssl: value['security'] == 'ssl',
tls: value['security'] == 'tls' || (value['security'].blank? && value['port'].to_s == '465')
}.compact_blank
end
end
+17
View File
@@ -0,0 +1,17 @@
# frozen_string_literal: true
class ApiPathConsiderJsonMiddleware
def initialize(app)
@app = app
end
def call(env)
if env['PATH_INFO'].starts_with?('/api') &&
!env['PATH_INFO'].ends_with?('/documents') &&
!env['PATH_INFO'].ends_with?('/attachments')
env['CONTENT_TYPE'] = 'application/json'
end
@app.call(env)
end
end
+21
View File
@@ -0,0 +1,21 @@
# frozen_string_literal: true
class AuthWithTokenStrategy < Devise::Strategies::Base
def valid?
request.headers['X-Auth-Token'].present?
end
def authenticate!
sha256 = Digest::SHA256.hexdigest(request.headers['X-Auth-Token'])
user = User.joins(:access_token).find_by(access_token: { sha256: })
if user
success!(user)
else
fail!('Invalid token')
end
rescue JWT::VerificationError
fail!('Invalid token')
end
end
+5
View File
@@ -10,6 +10,11 @@ module Docuseal
TWITTER_URL = 'https://twitter.com/docusealco'
TWITTER_HANDLE = '@docusealco'
SUPPORT_EMAIL = 'support@docuseal.co'
CONSOLE_URL = if Rails.env.development?
'http://console.localhost.io:3001'
else
'https://console.docuseal.co'
end
CERTS = JSON.parse(ENV.fetch('CERTS', '{}'))
+13
View File
@@ -0,0 +1,13 @@
# frozen_string_literal: true
module JsonWebToken
module_function
def encode(payload)
JWT.encode(payload, Rails.application.secrets.secret_key_base)
end
def decode(token)
JWT.decode(token, Rails.application.secrets.secret_key_base)[0]
end
end
+34
View File
@@ -10,4 +10,38 @@ module Submissions
submission.save!
end
def create_from_emails(template:, user:, emails:, source:, send_email: false)
emails = emails.to_s.scan(User::EMAIL_REGEXP)
emails.map do |email|
submission = template.submissions.new(created_by_user: user, source:)
submission.submitters.new(email:,
uuid: template.submitters.first['uuid'],
sent_at: send_email ? Time.current : nil)
submission.tap(&:save!)
end
end
def create_from_submitters(template:, user:, submissions_attrs:, source:, send_email: false)
submissions_attrs.map do |attrs|
submission = template.submissions.new(created_by_user: user, source:)
attrs[:submitters].each_with_index do |submitter_attrs, index|
uuid =
submitter_attrs[:uuid].presence ||
template.submitters.find { |e| e['name'] == submitter_attrs[:name] }&.dig('uuid') ||
template.submitters[index]&.dig('uuid')
next if uuid.blank?
submission.submitters.new(email: submitter_attrs[:email],
sent_at: send_email ? Time.current : nil,
uuid:)
end
submission.tap(&:save!)
end
end
end
@@ -37,6 +37,7 @@ module Submissions
width = page.box.width
height = page.box.height
font_size = ((page.box.width / A4_SIZE[0].to_f) * FONT_SIZE).to_i
value = submitter.values[field['uuid']]
@@ -70,13 +71,13 @@ module Submissions
items = Array.wrap(value).each_with_object([]) do |uuid, acc|
attachment = submitter.attachments.find { |a| a.uuid == uuid }
acc << HexaPDF::Layout::InlineBox.create(width: FONT_SIZE, height: FONT_SIZE,
acc << HexaPDF::Layout::InlineBox.create(width: font_size, height: font_size,
margin: [0, 1, -2, 0]) do |cv, box|
cv.image(PdfIcons.paperclip_io, at: [0, 0], width: box.content_width)
end
acc << HexaPDF::Layout::TextFragment.create("#{attachment.filename}\n", font: pdf.fonts.add(FONT_NAME),
font_size: FONT_SIZE)
font_size:)
end
lines = layouter.fit(items, area['w'] * width, height).lines
@@ -132,7 +133,7 @@ module Submissions
value.chars.each_with_index do |char, index|
text = HexaPDF::Layout::TextFragment.create(char, font: pdf.fonts.add(FONT_NAME),
font_size: FONT_SIZE)
font_size:)
cell_layouter.fit([text], cell_width, area['h'] * height)
.draw(canvas, ((area['x'] * width) + (cell_width * index)),
@@ -142,7 +143,7 @@ module Submissions
value = I18n.l(Date.parse(value), format: :long) if field['type'] == 'date'
text = HexaPDF::Layout::TextFragment.create(Array.wrap(value).join(', '), font: pdf.fonts.add(FONT_NAME),
font_size: FONT_SIZE)
font_size:)
lines = layouter.fit([text], area['w'] * width, height).lines
box_height = lines.sum(&:height)
+54
View File
@@ -0,0 +1,54 @@
# frozen_string_literal: true
module Submitters
module SerializeForWebhook
module_function
def call(submitter)
values = build_values_array(submitter)
documents = build_documents_array(submitter)
submitter_name = submitter.submission.template_submitters.find { |e| e['uuid'] == submitter.uuid }['name']
submitter.as_json(include: [template: { only: %i[id name created_at updated_at] }])
.except('uuid', 'values', 'slug')
.merge('values' => values,
'documents' => documents,
'submitter_name' => submitter_name)
end
def build_values_array(submitter)
fields_index = submitter.submission.template_fields.index_by { |e| e['uuid'] }
attachments_index = submitter.attachments.preload(:blob).index_by(&:uuid)
submitter_field_counters = Hash.new { 0 }
submitter.values.map do |uuid, value|
field = fields_index[uuid]
submitter_field_counters[field['type']] += 1
field_name =
field['name'].presence || "#{field['type'].titleize} Field #{submitter_field_counters[field['type']]}"
value = fetch_field_value(field, value, attachments_index)
{ field: field_name, value: }
end
end
def build_documents_array(submitter)
submitter.documents.preload(:blob).map do |attachment|
{ name: attachment.filename.base, url: attachment.url }
end
end
def fetch_field_value(field, value, attachments_index)
if field['type'].in?(%w[image signature])
attachments_index[value]&.url
elsif field['type'] == 'file'
Array.wrap(value).compact_blank.filter_map { |e| attachments_index[e]&.url }
else
value
end
end
end
end
@@ -0,0 +1,7 @@
# frozen_string_literal: true
class SettingsMailerPreview < ActionMailer::Preview
def smtp_successful_setup
SettingsMailer.smtp_successful_setup('example@gmail.com')
end
end