Compare commits

...

23 Commits

Author SHA1 Message Date
Alex Turchyn c1123fef63 Merge from docusealco/wip 2025-09-08 13:05:04 +03:00
Pete Matsyburka 4dc6149530 add reset password link 2025-09-06 14:46:51 +03:00
Pete Matsyburka 1245ff2cce disable edit password 2025-09-06 10:41:51 +03:00
Pete Matsyburka 284204fd78 refactor, use invalid class 2025-09-06 10:18:01 +03:00
Alex Turchyn f7be74eb73 improve user password reset 2025-09-06 09:21:59 +03:00
Pete Matsyburka aeea619059 skip annotation 2025-09-05 15:30:58 +03:00
Pete Matsyburka ea2b7f20e7 count segments 2025-09-05 14:59:20 +03:00
Alex Turchyn f40f1d25de reveal API key with user password 2025-09-05 09:58:16 +03:00
Pete Matsyburka c04bb2d7cf allow to disable user 2fa 2025-09-04 21:19:43 +03:00
Pete Matsyburka 4c1ccd65bf fix condition field value 2025-09-04 18:16:00 +03:00
Pete Matsyburka 54e064b0fc fix fields sort 2025-09-04 16:13:29 +03:00
Pete Matsyburka 9a8b72883c fix relation 2025-09-03 12:50:16 +03:00
Pete Matsyburka 5d75ee2e47 filter export 2025-09-02 21:17:25 +03:00
Pete Matsyburka 7357fa4871 adjust settings nav links 2025-09-01 18:34:23 +03:00
Pete Matsyburka f6850b5427 add completed submitters verification method 2025-09-01 14:35:56 +03:00
Pete Matsyburka 1e9a181e60 fix typo 2025-09-01 14:01:40 +03:00
Pete Matsyburka 7224a1ccec adjust expire link 2025-09-01 13:45:28 +03:00
Alex Turchyn 9b935a8180 Merge from docusealco/wip 2025-09-01 12:47:09 +03:00
Pete Matsyburka c4a693846e expire download links 2025-09-01 11:59:02 +03:00
Pete Matsyburka 02c7cdcd97 fix rotate 2025-08-28 17:43:39 +03:00
Pete Matsyburka 36bee92e8e fix condition remove 2025-08-28 10:31:19 +03:00
Pete Matsyburka 6542804f44 fix markdown 2025-08-27 22:01:49 +03:00
Pete Matsyburka d805fd614c private template placeholder 2025-08-26 12:38:41 +03:00
57 changed files with 553 additions and 147 deletions
@@ -15,6 +15,7 @@ class AccountConfigsController < ApplicationController
AccountConfig::ESIGNING_PREFERENCE_KEY,
AccountConfig::FORM_WITH_CONFETTI_KEY,
AccountConfig::DOWNLOAD_LINKS_AUTH_KEY,
AccountConfig::DOWNLOAD_LINKS_EXPIRE_KEY,
AccountConfig::FORCE_SSO_AUTH_KEY,
AccountConfig::FLATTEN_RESULT_PDF_KEY,
AccountConfig::ENFORCE_SIGNING_ORDER_KEY,
@@ -13,7 +13,7 @@ module Api
def show
blob_uuid, purp, exp = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid])
if blob_uuid.blank? || (purp.present? && purp != 'blob') || (exp && exp < Time.current.to_i)
if blob_uuid.blank? || purp != 'blob'
Rollbar.error('Blob not found') if defined?(Rollbar)
return head :not_found
@@ -24,8 +24,9 @@ module Api
attachment = blob.attachments.take
@record = attachment.record
@record = @record.record if @record.is_a?(ActiveStorage::Attachment)
authorization_check!(attachment) if exp.blank?
authorization_check!(attachment, @record, exp)
if request.headers['Range'].present?
send_blob_byte_range_data blob, request.headers['Range']
@@ -41,16 +42,22 @@ module Api
private
def authorization_check!(attachment)
is_authorized = attachment.name.in?(%w[logo preview_images]) ||
(current_user && attachment.record.account.id == current_user.account_id) ||
(current_user && !Docuseal.multitenant? && current_user.role == 'superadmin') ||
!attachment.record.account.account_configs
.find_or_initialize_by(key: AccountConfig::DOWNLOAD_LINKS_AUTH_KEY).value
def authorization_check!(attachment, record, exp)
return if attachment.name == 'logo'
return if exp.to_i >= Time.current.to_i
return if current_user && current_ability.can?(:read, record)
return if is_authorized
if exp.blank?
configs = record.account.account_configs.where(key: [AccountConfig::DOWNLOAD_LINKS_AUTH_KEY,
AccountConfig::DOWNLOAD_LINKS_EXPIRE_KEY])
Rollbar.error('Blob aunauthorized') if defined?(Rollbar)
require_auth = configs.any? { |c| c.key == AccountConfig::DOWNLOAD_LINKS_AUTH_KEY && c.value }
require_ttl = configs.none? { |c| c.key == AccountConfig::DOWNLOAD_LINKS_EXPIRE_KEY && c.value == false }
return if !require_ttl && !require_auth
end
Rollbar.error('Blob unauthorized') if defined?(Rollbar)
raise CanCan::AccessDenied
end
@@ -18,12 +18,14 @@ module Api
field: :completed_at
)
expires_at = Accounts.link_expires_at(current_account)
render json: {
data: submitters.map do |s|
{
event_type: 'form.completed',
timestamp: s.completed_at,
data: Submitters::SerializeForWebhook.call(s)
data: Submitters::SerializeForWebhook.call(s, expires_at:)
}
end,
pagination: {
@@ -34,10 +34,12 @@ module Api
associations: [:blob]
).call
expires_at = Accounts.link_expires_at(current_account)
render json: {
id: @submission.id,
documents: documents.map do |attachment|
{ name: attachment.filename.base, url: ActiveStorage::Blob.proxy_url(attachment.blob) }
{ name: attachment.filename.base, url: ActiveStorage::Blob.proxy_url(attachment.blob, expires_at:) }
end
}
end
@@ -14,16 +14,19 @@ module Api
:created_by_user, :submission_events,
template: :folder,
submitters: { documents_attachments: :blob, attachments_attachments: :blob },
audit_trail_attachment: :blob
audit_trail_attachment: :blob,
combined_document_attachment: :blob
),
field: :completed_at)
expires_at = Accounts.link_expires_at(current_account)
render json: {
data: submissions.map do |s|
{
event_type: 'submission.completed',
timestamp: s.completed_at,
data: Submissions::SerializeForApi.call(s, s.submitters)
data: Submissions::SerializeForApi.call(s, s.submitters, expires_at:)
}
end,
pagination: {
@@ -18,10 +18,12 @@ module Api
combined_document_attachment: :blob,
audit_trail_attachment: :blob))
expires_at = Accounts.link_expires_at(current_account)
render json: {
data: submissions.map do |s|
Submissions::SerializeForApi.call(s, s.submitters, params,
with_events: false, with_documents: false, with_values: false)
with_events: false, with_documents: false, with_values: false, expires_at:)
end,
pagination: {
count: submissions.size,
+3 -1
View File
@@ -14,9 +14,11 @@ module Api
documents_attachments: :blob, attachments_attachments: :blob)
)
expires_at = Accounts.link_expires_at(current_account)
render json: {
data: submitters.map do |s|
Submitters::SerializeForApi.call(s, with_template: true, with_events: true, params:)
Submitters::SerializeForApi.call(s, with_template: true, with_events: true, params:, expires_at:)
end,
pagination: {
count: submitters.size,
@@ -26,13 +26,15 @@ module Api
original_template: @template,
documents: params[:documents])
Templates.maybe_assign_access(cloned_template)
cloned_template.save!
WebhookUrls.enqueue_events(cloned_template, 'template.created')
SearchEntries.enqueue_reindex(cloned_template)
render json: Templates::SerializeForApi.call(cloned_template, schema_documents)
render json: Templates::SerializeForApi.call(cloned_template, schema_documents:)
end
end
end
+6 -5
View File
@@ -24,13 +24,14 @@ module Api
name: :preview_images)
.preload(:blob)
expires_at = Accounts.link_expires_at(current_account)
render json: {
data: templates.map do |t|
Templates::SerializeForApi.call(
t,
schema_documents.select { |e| e.record_id == t.id },
preview_image_attachments
)
Templates::SerializeForApi.call(t,
schema_documents: schema_documents.select { |e| e.record_id == t.id },
preview_image_attachments:,
expires_at:)
end,
pagination: {
count: templates.size,
+10
View File
@@ -1,6 +1,10 @@
# frozen_string_literal: true
class PasswordsController < Devise::PasswordsController
# rubocop:disable Rails/LexicallyScopedActionFilter
skip_before_action :require_no_authentication, only: %i[edit update]
# rubocop:enable Rails/LexicallyScopedActionFilter
class Current < ActiveSupport::CurrentAttributes
attribute :user
end
@@ -16,4 +20,10 @@ class PasswordsController < Devise::PasswordsController
Current.user = resource
end
end
private
def after_resetting_password_path_for(_)
new_session_path(resource_name)
end
end
+2 -2
View File
@@ -16,7 +16,7 @@ class ProfileController < ApplicationController
end
def update_password
if current_user.update(password_params)
if current_user.update_with_password(password_params)
bypass_sign_in(current_user)
redirect_to settings_profile_index_path, notice: I18n.t('password_has_been_changed')
else
@@ -31,6 +31,6 @@ class ProfileController < ApplicationController
end
def password_params
params.require(:user).permit(:password, :password_confirmation)
params.require(:user).permit(:password, :password_confirmation, :current_password)
end
end
@@ -0,0 +1,21 @@
# frozen_string_literal: true
class RevealAccessTokenController < ApplicationController
def show
authorize!(:manage, current_user.access_token)
end
def create
authorize!(:manage, current_user.access_token)
if current_user.valid_password?(params[:password])
render turbo_stream: turbo_stream.replace(:access_token_container,
partial: 'reveal_access_token/access_token',
locals: { token: current_user.access_token.token })
else
render turbo_stream: turbo_stream.replace(:modal, template: 'reveal_access_token/show',
locals: { error_message: I18n.t('wrong_password') }),
status: :unprocessable_content
end
end
end
@@ -10,11 +10,16 @@ class SubmissionsExportController < ApplicationController
attachments_attachments: :blob })
.order(id: :asc)
submissions = Submissions.search(current_user, submissions, params[:q], search_values: true)
submissions = Submissions::Filter.call(submissions, current_user, params)
expires_at = Accounts.link_expires_at(current_account)
if params[:format] == 'csv'
send_data Submissions::GenerateExportFiles.call(submissions, format: params[:format]),
send_data Submissions::GenerateExportFiles.call(submissions, format: params[:format], expires_at:),
filename: "#{@template.name}.csv"
elsif params[:format] == 'xlsx'
send_data Submissions::GenerateExportFiles.call(submissions, format: params[:format]),
send_data Submissions::GenerateExportFiles.call(submissions, format: params[:format], expires_at:),
filename: "#{@template.name}.xlsx"
end
end
@@ -17,6 +17,8 @@ class TemplatesCloneAndReplaceController < ApplicationController
documents = Templates::ReplaceAttachments.call(cloned_template, params, extract_fields: true)
Templates.maybe_assign_access(cloned_template)
cloned_template.save!
Templates::CloneAttachments.call(template: cloned_template, original_template: @template,
+2
View File
@@ -69,6 +69,8 @@ class TemplatesController < ApplicationController
@template.account = current_account
end
Templates.maybe_assign_access(@template)
if @template.save
Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template
@@ -46,6 +46,8 @@ class TemplatesUploadsController < ApplicationController
template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
template.name = File.basename((url_params || params)[:files].first.original_filename, '.*')
Templates.maybe_assign_access(template)
template.save!
template
+3 -2
View File
@@ -30,6 +30,7 @@ class UsersController < ApplicationController
return render turbo_stream: turbo_stream.replace(:modal, template: 'users/new'), status: :unprocessable_content
end
@user.password = SecureRandom.hex if @user.password.blank?
@user.role = User::ADMIN_ROLE unless role_valid?(@user.role)
if @user.save
@@ -54,7 +55,7 @@ class UsersController < ApplicationController
@user.account = account
end
if @user.update(attrs.except(current_user == @user ? :role : nil))
if @user.update(attrs.except(*(current_user == @user ? %i[password otp_required_for_login role] : %i[password])))
redirect_back fallback_location: settings_users_path, notice: I18n.t('user_has_been_updated')
else
render turbo_stream: turbo_stream.replace(:modal, template: 'users/edit'), status: :unprocessable_content
@@ -83,7 +84,7 @@ class UsersController < ApplicationController
def user_params
if params.key?(:user)
permitted_params = %i[email first_name last_name password archived_at]
permitted_params = %i[email first_name last_name password archived_at otp_required_for_login]
permitted_params << :role if role_valid?(params.dig(:user, :role))
@@ -0,0 +1,20 @@
# frozen_string_literal: true
class UsersSendResetPasswordController < ApplicationController
load_and_authorize_resource :user
LIMIT_DURATION = 10.minutes
def update
authorize!(:manage, @user)
if @user.reset_password_sent_at && @user.reset_password_sent_at > LIMIT_DURATION.ago
redirect_back fallback_location: settings_users_path, notice: I18n.t('email_has_been_sent_already')
else
@user.send_reset_password_instructions
redirect_back fallback_location: settings_users_path,
notice: I18n.t('an_email_with_password_reset_instructions_has_been_sent')
end
end
end
+5 -1
View File
@@ -1015,7 +1015,11 @@ export default {
const aArea = (fieldAreasIndex[aField.uuid] ||= [...(aField.areas || [])].sort(sortArea)[0])
const bArea = (fieldAreasIndex[bField.uuid] ||= [...(bField.areas || [])].sort(sortArea)[0])
return sortArea(aArea, bArea)
if (aArea && bArea) {
return sortArea(aArea, bArea)
} else {
return 0
}
})
}
@@ -60,7 +60,15 @@ export default {
return ['UL', 'I', 'EM', 'B', 'STRONG', 'P']
},
dom () {
const text = this.string.replace(/(?<!\(\s*)(https?:\/\/[^\s)]+)(?!\s*\))/g, (url) => `[${url}](${url})`)
const linkParts = this.string.split(/(https?:\/\/[^\s)]+)/g)
const text = linkParts.map((part, index) => {
if (part.match(/^https?:\/\//) && !linkParts[index - 1]?.match(/\(\s*$/) && !linkParts[index + 1]?.match(/^\s*\)/)) {
return `[${part}](${part})`
} else {
return part
}
}).join('')
return new DOMParser().parseFromString(snarkdown(text.replace(/\n/g, '<br>')), 'text/html')
}
+25 -11
View File
@@ -1265,24 +1265,31 @@ export default {
if (!field.areas.length) {
this.template.fields.splice(this.template.fields.indexOf(field), 1)
this.template.fields.forEach((f) => {
(f.conditions || []).forEach((c) => {
this.removeFieldConditions(field)
}
this.save()
},
removeFieldConditions (field) {
this.template.fields.forEach((f) => {
if (f.conditions) {
f.conditions.forEach((c) => {
if (c.field_uuid === field.uuid) {
f.conditions.splice(f.conditions.indexOf(c), 1)
}
})
})
}
})
this.template.schema.forEach((item) => {
(item.conditions || []).forEach((c) => {
this.template.schema.forEach((item) => {
if (item.conditions) {
item.conditions.forEach((c) => {
if (c.field_uuid === field.uuid) {
item.conditions.splice(item.conditions.indexOf(c), 1)
}
})
})
}
this.save()
}
})
},
pasteField () {
const field = this.template.fields.find((f) => f.areas?.includes(this.copiedArea))
@@ -1715,8 +1722,15 @@ export default {
})
})
this.template.fields =
this.template.fields.filter((f) => !removedFieldUuids.includes(f.uuid) || f.areas?.length)
this.template.fields = this.template.fields.reduce((acc, f) => {
if (removedFieldUuids.includes(f.uuid) && !f.areas?.length) {
this.removeFieldConditions(f)
} else {
acc.push(f)
}
return acc
}, [])
this.save()
}
+8 -1
View File
@@ -38,12 +38,19 @@ class ProcessSubmitterCompletionJob
submission = submitter.submission
complete_verification_events, sms_events =
submitter.submission_events.where(event_type: %i[send_sms send_2fa_sms complete_verification])
.partition { |e| e.event_type == 'complete_verification' }
complete_verification_event = complete_verification_events.first
completed_submitter.assign_attributes(
submission_id: submitter.submission_id,
account_id: submission.account_id,
template_id: submission.template_id,
source: submission.source,
sms_count: submitter.submission_events.where(event_type: %w[send_sms send_2fa_sms]).count,
sms_count: sms_events.sum { |e| e.data['segments'] || 1 },
verification_method: complete_verification_event&.data&.dig('method'),
completed_at: submitter.completed_at
)
+1
View File
@@ -38,6 +38,7 @@ class AccountConfig < ApplicationRecord
FORM_PREFILL_SIGNATURE_KEY = 'form_prefill_signature'
ESIGNING_PREFERENCE_KEY = 'esigning_preference'
DOWNLOAD_LINKS_AUTH_KEY = 'download_links_auth'
DOWNLOAD_LINKS_EXPIRE_KEY = 'download_links_expire'
FORCE_SSO_AUTH_KEY = 'force_sso_auth'
FLATTEN_RESULT_PDF_KEY = 'flatten_result_pdf'
WITH_SIGNATURE_ID = 'with_signature_id'
+12 -11
View File
@@ -4,16 +4,17 @@
#
# Table name: completed_submitters
#
# id :bigint not null, primary key
# completed_at :datetime not null
# sms_count :integer not null
# source :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# submission_id :bigint not null
# submitter_id :bigint not null
# template_id :bigint
# id :bigint not null, primary key
# completed_at :datetime not null
# sms_count :integer not null
# source :string not null
# verification_method :string
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# submission_id :bigint not null
# submitter_id :bigint not null
# template_id :bigint
#
# Indexes
#
@@ -29,5 +30,5 @@ class CompletedSubmitter < ApplicationRecord
has_many :completed_documents, dependent: :destroy,
primary_key: :submitter_id,
foreign_key: :submitter_id,
inverse_of: :submitter
inverse_of: :completed_submitter
end
+4 -4
View File
@@ -114,16 +114,16 @@ class Submission < ApplicationRecord
@fields_uuid_index ||= (template_fields || template.fields).index_by { |f| f['uuid'] }
end
def audit_trail_url
def audit_trail_url(expires_at: nil)
return if audit_trail.blank?
ActiveStorage::Blob.proxy_url(audit_trail.blob)
ActiveStorage::Blob.proxy_url(audit_trail.blob, expires_at:)
end
alias audit_log_url audit_trail_url
def combined_document_url
def combined_document_url(expires_at: nil)
return if combined_document.blank?
ActiveStorage::Blob.proxy_url(combined_document.blob)
ActiveStorage::Blob.proxy_url(combined_document.blob, expires_at:)
end
end
+12
View File
@@ -130,6 +130,18 @@
</div>
<% end %>
<% end %>
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::DOWNLOAD_LINKS_EXPIRE_KEY) %>
<% if can?(:manage, account_config) %>
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
<%= f.hidden_field :key %>
<div class="flex items-center justify-between py-2.5">
<span>
<%= t('expirable_file_download_links') %>
</span>
<%= f.check_box :value, class: 'toggle', checked: account_config.value != false, onchange: 'this.form.requestSubmit()' %>
</div>
<% end %>
<% end %>
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::DOWNLOAD_LINKS_AUTH_KEY) %>
<% if can?(:manage, account_config) %>
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
+15 -4
View File
@@ -11,10 +11,21 @@
<div class="flex flex-col md:flex-row gap-4">
<div class="flex w-full space-x-4">
<% token = current_user.access_token.token %>
<masked-input class="block w-full" data-token="<%= token %>">
<input id="api_key" type="text" value="<%= token.sub(token[5..], '*' * token[5..].size) %>" class="input font-mono input-bordered w-full" autocomplete="off" readonly>
</masked-input>
<%= render 'shared/clipboard_copy', icon: 'copy', text: token, class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
<% obscured_token = current_user.access_token.token.sub(token[5..], '*' * token[5..].size) %>
<% if current_account.testing? %>
<masked-input class="block w-full" data-token="<%= token %>">
<input id="api_key" type="text" value="<%= obscured_token %>" class="input font-mono input-bordered w-full" autocomplete="off" readonly>
</masked-input>
<%= render 'shared/clipboard_copy', icon: 'copy', text: token, class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
<% else %>
<a id="access_token_container" href="<%= settings_reveal_access_token_path %>" data-turbo-frame="modal" class="flex w-full space-x-4">
<input id="api_key" type="text" value="<%= obscured_token %>" class="input font-mono input-bordered w-full" autocomplete="off" readonly>
<div class="base-button">
<%= svg_icon('copy', class: 'w-6 h-6 text-white') %>
<span class="hidden md:inline"><%= t('copy') %></span>
</div>
</a>
<% end %>
</div>
<%= button_to button_title(title: t('rotate'), disabled_with: t('rotate'), icon: svg_icon('reload', class: 'w-6 h-6')), settings_api_index_path, class: 'white-button w-full', data: { turbo_confirm: t('remove_existing_api_token_and_generated_a_new_one_are_you_sure_') } %>
</div>
+21 -11
View File
@@ -54,19 +54,29 @@
<p class="text-2xl font-bold mt-8 mb-4">
<%= t('change_password') %>
</p>
<%= form_for current_user, url: update_password_settings_profile_index_path, method: :patch, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
<div class="form-control">
<%= f.label :password, t('new_password'), class: 'label' %>
<%= f.password_field :password, autocomplete: 'off', class: 'base-input' %>
</div>
<div class="form-control">
<%= f.label :password_confirmation, t('confirm_password'), class: 'label' %>
<%= f.password_field :password_confirmation, autocomplete: 'off', class: 'base-input' %>
</div>
<div class="form-control pt-2">
<%= f.button button_title(title: t('update'), disabled_with: t('updating')), class: 'base-button' %>
<%= form_for current_user, url: update_password_settings_profile_index_path, method: :patch, html: { autocomplete: 'off' } do |f| %>
<%= f.label :password, t('new_password'), class: 'label' %>
<%= f.password_field :password, autocomplete: 'off', class: 'base-input peer w-full', required: true %>
<div class="<%= 'peer-invalid:hidden' if current_user.errors.blank? %> space-y-4 mt-4">
<div class="form-control">
<%= f.label :password_confirmation, t('confirm_password'), class: 'label' %>
<%= f.password_field :password_confirmation, autocomplete: 'off', class: 'base-input' %>
</div>
<div class="form-control">
<%= f.label :current_password, t('current_password'), class: 'label' %>
<%= f.password_field :current_password, autocomplete: 'current-password', class: 'base-input' %>
<% if Accounts.can_send_emails?(current_account) %>
<span class="label-text-alt mt-1">
<%= t('dont_remember_your_current_password_click_here_to_reset_it_html') %>
</span>
<% end %>
</div>
<div class="form-control">
<%= f.button button_title(title: t('update'), disabled_with: t('updating')), class: 'base-button' %>
</div>
</div>
<% end %>
<%= button_to nil, user_send_reset_password_path(current_user), id: 'resend_password_button', method: :put, class: 'hidden', data: { turbo_confirm: t('are_you_sure_') } %>
<p class="text-2xl font-bold mt-8 mb-4">
<%= t('two_factor_authentication') %>
</p>
@@ -0,0 +1,2 @@
<input id="api_key" type="text" value="<%= token %>" class="input font-mono input-bordered w-full" autocomplete="off" readonly>
<%= render 'shared/clipboard_copy', icon: 'copy', text: token, class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
@@ -0,0 +1,14 @@
<%= render 'shared/turbo_modal', title: t('reveal_api_key') do %>
<%= form_tag settings_reveal_access_token_path, enctype: 'multipart/form-data', data: { turbo_frame: :_top } do %>
<div class="form-control">
<%= label_tag :password, t('enter_your_password_to_reveal_the_api_key'), class: 'label' %>
<%= password_field_tag :password, nil, class: 'base-input', autocomplete: 'current-password', required: true, autofocus: true, placeholder: t('password') %>
<% if local_assigns[:error_message].present? %>
<span class="label-text-alt text-red-400 mt-1"><%= local_assigns[:error_message] %></span>
<% end %>
</div>
<div class="form-control mt-4">
<%= submit_tag t('submit'), class: 'base-button' %>
</div>
<% end %>
<% end %>
+3 -1
View File
@@ -56,6 +56,8 @@
<%= link_to 'API', settings_api_index_path, class: 'text-base hover:bg-base-300' %>
</li>
<% end %>
<% end %>
<% if Docuseal.demo? || !Docuseal.multitenant? || (current_user != true_user && !current_account.testing?) %>
<% if can?(:read, WebhookUrl) %>
<li>
<%= link_to 'Webhooks', settings_webhooks_path, class: 'text-base hover:bg-base-300' %>
@@ -70,7 +72,7 @@
<% end %>
</li>
<% 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>
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}#{'/test' if current_account.testing?}/api") : "#{Docuseal::CONSOLE_URL}/on_premises", class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
<% if Docuseal.multitenant? %> API <% else %> <%= t('console') %> <% end %>
+1 -1
View File
@@ -109,7 +109,7 @@
<%= render 'submissions/annotation', annot: %>
<% end %>
<% fields_index.dig(document.uuid, index)&.each do |(area, field)| %>
<% value = values[field['uuid']].presence || (field['default_value'] != '{{date}}' && field['readonly'] == true && field['default_value'].present? ? Submitters::SubmitValues.template_default_value_for_submitter(field['default_value'], @submission.submitters.find { |e| e.uuid == field['submitter_uuid'] }, with_time: false) : nil) %>
<% value = values[field['uuid']].presence || (field['default_value'] != '{{date}}' && field['readonly'] == true && field['conditions'].blank? && field['default_value'].present? ? Submitters::SubmitValues.template_default_value_for_submitter(field['default_value'], @submission.submitters.find { |e| e.uuid == field['submitter_uuid'] }, with_time: false) : nil) %>
<% value ||= field['default_value'] if field['type'] == 'heading' %>
<% next if value.blank? %>
<% submitter = submitters_index[field['submitter_uuid']] %>
+3 -2
View File
@@ -1,6 +1,7 @@
<% filter_params = params.permit(:q, *Submissions::Filter::ALLOWED_PARAMS) %>
<%= render 'shared/turbo_modal', title: t('export'), close_after_submit: false do %>
<div class="space-y-2">
<%= button_to template_submissions_export_index_path(@template), params: { format: :xlsx }, method: :get, data: { turbo_frame: :_top } do %>
<%= button_to template_submissions_export_index_path(@template), params: { format: :xlsx, **filter_params }, method: :get, data: { turbo_frame: :_top } do %>
<div class="flex items-center p-4 text-left rounded-2xl border border-neutral-300 hover:cursor-pointer hover:bg-neutral hover:text-gray-300">
<div class="enabled">
<%= svg_icon('download', class: 'w-12 h-12 stroke-2 mr-2') %>
@@ -14,7 +15,7 @@
</div>
</div>
<% end %>
<%= button_to template_submissions_export_index_path(@template), params: { format: :csv }, method: :get, data: { turbo_frame: :_top } do %>
<%= button_to template_submissions_export_index_path(@template), params: { format: :csv, **filter_params }, method: :get, data: { turbo_frame: :_top } do %>
<div class="flex items-center text-left p-4 rounded-2xl border border-neutral-300 hover:cursor-pointer hover:bg-neutral hover:text-gray-300">
<div class="enabled">
<%= svg_icon('download', class: 'w-12 h-12 stroke-2 mr-2') %>
+1 -1
View File
@@ -13,7 +13,7 @@
<% if params[:q].present? || params[:status].present? || filter_params.present? || @pagy.pages > 1 %>
<%= render 'shared/search_input', title_selector: 'h2' %>
<% end %>
<%= link_to new_template_submissions_export_path(@template), class: 'hidden md:flex btn btn-ghost text-base', data: { turbo_frame: 'modal' } do %>
<%= link_to new_template_submissions_export_path(@template, params.permit(:q, *Submissions::Filter::ALLOWED_PARAMS)), class: 'hidden md:flex btn btn-ghost text-base', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('download', class: 'w-6 h-6 stroke-2') %>
<span><%= t('export') %></span>
<% end %>
+29 -11
View File
@@ -1,22 +1,37 @@
<%= form_for user, html: { class: 'space-y-4' }, data: { turbo_frame: :_top } do |f| %>
<div class="space-y-2">
<div class="form-control">
<%= f.label :first_name, t('first_name'), class: 'label' %>
<%= f.text_field :first_name, required: true, class: 'base-input', dir: 'auto' %>
</div>
<div class="form-control">
<%= f.label :last_name, t('last_name'), class: 'label' %>
<%= f.text_field :last_name, required: true, class: 'base-input', dir: 'auto' %>
<div class="flex space-x-4">
<div class="w-full">
<%= f.label :first_name, t('first_name'), class: 'label' %>
<%= f.text_field :first_name, required: true, class: 'base-input w-full', dir: 'auto' %>
</div>
<div class="w-full">
<%= f.label :last_name, t('last_name'), class: 'label' %>
<%= f.text_field :last_name, required: true, class: 'base-input w-full', dir: 'auto' %>
</div>
</div>
<div class="form-control">
<%= f.label :email, t('email'), class: 'label' %>
<%= f.email_field :email, required: true, class: 'base-input' %>
<% if user.persisted? && Accounts.can_send_emails?(current_account) %>
<span class="label-text-alt mt-2 mx-1">
<%= t('click_here_to_send_a_reset_password_email_html') %>
</span>
<% end %>
</div>
<div class="form-control">
<%= f.label :password, t('password'), class: 'label' %>
<%= f.password_field :password, required: user.new_record?, class: 'base-input' %>
</div>
<% if user.new_record? && !Docuseal.multitenant? %>
<div class="form-control">
<%= f.label :password, t('password'), class: 'label' %>
<%= f.password_field :password, class: 'base-input' %>
</div>
<% end %>
<% if f.object != current_user %>
<% if user.otp_required_for_login %>
<div class="form-control">
<%= f.label :otp_required_for_login, t('two_factor_authentication'), class: 'label' %>
<%= f.select :otp_required_for_login, [[t('enabled'), true], [t('disabled'), false]], { include_blank: false }, class: 'base-select' %>
</div>
<% end %>
<%= render 'role_select', f: %>
<% end %>
<% if local_assigns[:extra_fields_html].present? %>
@@ -27,3 +42,6 @@
<%= f.button button_title, class: 'base-button' %>
</div>
<% end %>
<% if user.persisted? %>
<%= button_to nil, user_send_reset_password_path(user), id: 'resend_password_button', method: :put, class: 'hidden', data: { turbo_confirm: t('are_you_sure_'), turbo_frame: :_top } %>
<% end %>
+1 -1
View File
@@ -4,7 +4,7 @@
<div class="flex flex-col gap-2 md:flex-row md:justify-between md:items-center mb-4">
<h1 class="text-4xl font-bold">Webhook</h1>
<div class="flex flex-col gap-2 md:flex-row md:justify-between md:items-center">
<% if params[:action] == 'index' %>
<% if params[:action] == 'index' && (current_user == true_user || current_account.testing?) %>
<%= render 'shared/test_mode_toggle' %>
<% end %>
<% if @webhook_url.persisted? && params[:action] == 'index' %>
+66 -1
View File
@@ -24,8 +24,12 @@ en: &en
thanks: Thanks
private: Private
select: Select
enabled: Enabled
disabled: Disabled
party: Party
click_here_to_send_a_reset_password_email_html: '<label class="link" for="resend_password_button">Click here</label> to send a reset password email.'
edit_order: Edit Order
expirable_file_download_links: Expirable file download links
invite_form_fields: Invite form fields
default_parties: Default parties
authenticate_embedded_form_preview_with_token: Authenticate embedded form preview with token
@@ -36,13 +40,14 @@ en: &en
bcc_recipients: BCC recipients
resend_pending: Re-send pending
always_enforce_signing_order: Always enforce the signing order
create_templates_with_private_access_by_default: Create templates with private access by default
ensure_unique_recipients: Ensure unique recipients
edit_per_party: Edit per party
reply_to: Reply to
pending_by_me: Pending by me
partially_completed: Partially completed
require_phone_2fa_to_open: Require phone 2FA to open
the_sender_has_requested_a_two_factor_authentication_via_one_time_password_sent_to_your_html: The sender has requested a two factor authentication via one time password sent to your <b>%{phone}</b> phone number.
the_sender_has_requested_a_two_factor_authentication_via_one_time_password_sent_to_your_html: The sender has requested two-factor authentication via a one-time password sent to your <b>%{phone}</b> phone number.
send_verification_code: Send verification code
code_has_been_resent: Code has been re-sent
invalid_code: Invalid code
@@ -794,6 +799,12 @@ en: &en
template_name_has_been_completed_by_submitters_html: '"<strong>{template.name}</strong>" has been completed by <strong>{submission.submitters}</strong>'
please_check_the_copy_of_your_template_name_in_the_email_attachments_html: 'Please check the copy of your "<strong>{template.name}</strong>" in the email attachments.'
you_have_been_invited_to_sign_the_template_name_html: 'You have been invited to sign the "<strong>{template.name}</strong>".'
reveal_api_key: Reveal API Key
enter_your_password_to_reveal_the_api_key: Enter your password to reveal the API key
wrong_password: Wrong password.
current_password: Current password
dont_remember_your_current_password_click_here_to_reset_it_html: 'Don''t remember your current password? <label class="link" for="resend_password_button">Click here</label> to reset it.'
an_email_with_password_reset_instructions_has_been_sent: An email with password reset instructions has been sent.
submission_sources:
api: API
bulk: Bulk Send
@@ -900,6 +911,10 @@ en: &en
range_without_total: "%{from}-%{to} events"
es: &es
enabled: Habilitado
disabled: Deshabilitado
expirable_file_download_links: Enlaces de descarga de archivos con vencimiento
create_templates_with_private_access_by_default: Crear plantillas con acceso privado por defecto
party: Parte
edit_order: Edita Pedido
select: Seleccionar
@@ -1674,6 +1689,12 @@ es: &es
template_name_has_been_completed_by_submitters_html: '"<strong>{template.name}</strong>" ha sido completado por <strong>{submission.submitters}</strong>'
please_check_the_copy_of_your_template_name_in_the_email_attachments_html: 'Por favor, revisa la copia de tu "<strong>{template.name}</strong>" en los archivos adjuntos del correo electrónico.'
you_have_been_invited_to_sign_the_template_name_html: 'Has sido invitado a firmar el "<strong>{template.name}</strong>".'
reveal_api_key: Revelar clave API
enter_your_password_to_reveal_the_api_key: Introduce tu contraseña para revelar la clave API
wrong_password: Contraseña incorrecta.
current_password: Contraseña actual
dont_remember_your_current_password_click_here_to_reset_it_html: '¿No recuerdas tu contraseña actual? <label class="link" for="resend_password_button">Haz clic aquí</label> para restablecerla.'
an_email_with_password_reset_instructions_has_been_sent: Se enviará un correo electrónico con las instrucciones para restablecer tu contraseña en unos minutos.
submission_sources:
api: API
bulk: Envío masivo
@@ -1780,6 +1801,11 @@ es: &es
range_without_total: "%{from}-%{to} eventos"
it: &it
click_here_to_send_a_reset_password_email_html: '<label class="link" for="resend_password_button">Clicca qui</label> per inviare una email per reimpostare la password.'
enabled: Abilitato
disabled: Disabilitato
expirable_file_download_links: Link di download di file con scadenza
create_templates_with_private_access_by_default: Crea modelli con accesso privato per impostazione predefinita
party: Parte
edit_order: Modifica Ordine
select: Seleziona
@@ -2554,6 +2580,12 @@ it: &it
template_name_has_been_completed_by_submitters_html: '"<strong>{template.name}</strong>" è stato completato da <strong>{submission.submitters}</strong>'
please_check_the_copy_of_your_template_name_in_the_email_attachments_html: 'Per favore, controlla la copia del tuo "<strong>{template.name}</strong>" negli allegati dell''email.'
you_have_been_invited_to_sign_the_template_name_html: 'Sei stato invitato a firmare il "<strong>{template.name}</strong>".'
reveal_api_key: Mostra chiave API
enter_your_password_to_reveal_the_api_key: Inserisci la tua password per mostrare la chiave API
wrong_password: Password errata.
current_password: Password attuale
dont_remember_your_current_password_click_here_to_reset_it_html: 'Non ricordi la tua password attuale? <label class="link" for="resend_password_button">Clicca qui</label> per reimpostarla.'
an_email_with_password_reset_instructions_has_been_sent: Un'email con le istruzioni per reimpostare la password ti è stata inviata e arriverà entro pochi minuti.
submission_sources:
api: API
bulk: Invio massivo
@@ -2660,6 +2692,11 @@ it: &it
range_without_total: "%{from}-%{to} eventi"
fr: &fr
click_here_to_send_a_reset_password_email_html: '<label class="link" for="resend_password_button">Cliquez ici</label> pour envoyer un e-mail de réinitialisation du mot de passe.'
enabled: Activé
disabled: Désactivé
expirable_file_download_links: Liens de téléchargement de fichiers expirables
create_templates_with_private_access_by_default: Créer des modèles avec un accès privé par défaut
party: Partie
edit_order: Modifier la commande
select: Sélectionner
@@ -3437,6 +3474,12 @@ fr: &fr
template_name_has_been_completed_by_submitters_html: '"<strong>{template.name}</strong>" a été complété par <strong>{submission.submitters}</strong>'
please_check_the_copy_of_your_template_name_in_the_email_attachments_html: 'Veuillez vérifier la copie de votre "<strong>{template.name}</strong>" dans les pièces jointes de le-mail.'
you_have_been_invited_to_sign_the_template_name_html: 'Vous avez été invité à signer le "<strong>{template.name}</strong>".'
reveal_api_key: Révéler la clé API
enter_your_password_to_reveal_the_api_key: Entrez votre mot de passe pour révéler la clé API
wrong_password: Mot de passe incorrect.
current_password: Mot de passe actuel
dont_remember_your_current_password_click_here_to_reset_it_html: 'Vous ne vous souvenez plus de votre mot de passe actuel ? <label class="link" for="resend_password_button">Cliquez ici</label> pour le réinitialiser.'
an_email_with_password_reset_instructions_has_been_sent: Un e-mail contenant les instructions pour réinitialiser votre mot de passe vous sera envoyé dans quelques minutes.
submission_sources:
api: API
bulk: Envoi en masse
@@ -3543,6 +3586,11 @@ fr: &fr
range_without_total: "%{from} à %{to} événements"
pt: &pt
click_here_to_send_a_reset_password_email_html: '<label class="link" for="resend_password_button">Clique aqui</label> para enviar um e-mail de redefinição de senha.'
enabled: Ativado
disabled: Desativado
expirable_file_download_links: Links de download de arquivos com expiração
create_templates_with_private_access_by_default: Criar modelos com acesso privado por padrão
party: Parte
edit_order: Edita Pedido
select: Selecionar
@@ -4318,6 +4366,12 @@ pt: &pt
template_name_has_been_completed_by_submitters_html: '"<strong>{template.name}</strong>" foi concluído por <strong>{submission.submitters}</strong>'
please_check_the_copy_of_your_template_name_in_the_email_attachments_html: 'Por favor, verifique a cópia do seu "<strong>{template.name}</strong>" nos anexos do e-mail.'
you_have_been_invited_to_sign_the_template_name_html: 'Você foi convidado a assinar o "<strong>{template.name}</strong>".'
reveal_api_key: Revelar chave API
enter_your_password_to_reveal_the_api_key: Insira sua senha para revelar a chave API
wrong_password: Senha incorreta.
current_password: Senha atual
dont_remember_your_current_password_click_here_to_reset_it_html: 'Não se lembra da sua senha atual? <label class="link" for="resend_password_button">Clique aqui</label> para redefini-la.'
an_email_with_password_reset_instructions_has_been_sent: Um e-mail com instruções para redefinir sua senha será enviado em alguns minutos.
submission_sources:
api: API
bulk: Envio em massa
@@ -4424,6 +4478,11 @@ pt: &pt
range_without_total: "%{from}-%{to} eventos"
de: &de
click_here_to_send_a_reset_password_email_html: '<label class="link" for="resend_password_button">Klicken Sie hier</label>, um eine E-Mail zum Zurücksetzen des Passworts zu senden.'
enabled: Aktiviert
disabled: Deaktiviert
expirable_file_download_links: Ablaufbare Datei-Download-Links
create_templates_with_private_access_by_default: Vorlagen standardmäßig mit privatem Zugriff erstellen
party: Partei
edit_order: Bestellung bearbeiten
select: Auswählen
@@ -5199,6 +5258,12 @@ de: &de
template_name_has_been_completed_by_submitters_html: '"<strong>{template.name}</strong>" wurde von <strong>{submission.submitters}</strong> abgeschlossen'
please_check_the_copy_of_your_template_name_in_the_email_attachments_html: 'Bitte prüfen Sie die Kopie Ihres "<strong>{template.name}</strong>" in den E-Mail-Anhängen.'
you_have_been_invited_to_sign_the_template_name_html: 'Du wurdest eingeladen, "<strong>{template.name}</strong>" zu unterschreiben.'
reveal_api_key: API-Schlüssel anzeigen
enter_your_password_to_reveal_the_api_key: Gib dein Passwort ein, um den API-Schlüssel anzuzeigen
wrong_password: Falsches Passwort.
current_password: Aktuelles Passwort
dont_remember_your_current_password_click_here_to_reset_it_html: 'Sie erinnern sich nicht an Ihr aktuelles Passwort? <label class="link" for="resend_password_button">Klicken Sie hier</label>, um es zurückzusetzen.'
an_email_with_password_reset_instructions_has_been_sent: Eine E-Mail mit Anweisungen zum Zurücksetzen Ihres Passworts wurde Ihnen in wenigen Minuten zugesendet.
submission_sources:
api: API
bulk: Massenversand
+4 -1
View File
@@ -65,7 +65,9 @@ Rails.application.routes.draw do
resources :setup, only: %i[index create]
resource :newsletter, only: %i[show update]
resources :enquiries, only: %i[create]
resources :users, only: %i[new create edit update destroy]
resources :users, only: %i[new create edit update destroy] do
resource :send_reset_password, only: %i[update], controller: 'users_send_reset_password'
end
resource :user_signature, only: %i[edit update destroy]
resource :user_initials, only: %i[edit update destroy]
resources :submissions_archived, only: %i[index], path: 'submissions/archived'
@@ -179,6 +181,7 @@ Rails.application.routes.draw do
defaults: { status: :integration }
resource :personalization, only: %i[show create], controller: 'personalization_settings'
resources :api, only: %i[index create], controller: 'api_settings'
resource :reveal_access_token, only: %i[show create], controller: 'reveal_access_token'
resources :webhooks, only: %i[index show new create update destroy], controller: 'webhook_settings' do
post :resend
@@ -0,0 +1,30 @@
# frozen_string_literal: true
class PopulateExpireLinkConfigs < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
class MigrationAccount < ActiveRecord::Base
self.table_name = 'accounts'
end
class MigrationAccountConfig < ActiveRecord::Base
self.table_name = 'account_configs'
serialize :value, coder: JSON
end
def up
MigrationAccount.find_each do |account|
config = MigrationAccountConfig.find_or_initialize_by(key: 'download_links_expire', account_id: account.id)
next if config.persisted?
config.value = false
config.save!
end
end
def down
nil
end
end
@@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddVerificationMethodToCompletedSubmitters < ActiveRecord::Migration[8.0]
def change
add_column :completed_submitters, :verification_method, :string
end
end
+2 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_07_18_121133) do
ActiveRecord::Schema[8.0].define(version: 2025_09_01_110606) do
# These are extensions that must be enabled in order to support this database
enable_extension "btree_gin"
enable_extension "plpgsql"
@@ -117,6 +117,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_18_121133) do
t.datetime "completed_at", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "verification_method"
t.index ["account_id"], name: "index_completed_submitters_on_account_id"
t.index ["submitter_id"], name: "index_completed_submitters_on_submitter_id", unique: true
end
+9
View File
@@ -1,6 +1,8 @@
# frozen_string_literal: true
module Accounts
LINK_EXPIRES_AT = 40.minutes
module_function
def create_duplicate(account)
@@ -185,4 +187,11 @@ module Accounts
rescue TZInfo::InvalidTimezoneIdentifier
'UTC'
end
def link_expires_at(account)
return if AccountConfig.find_or_initialize_by(account: account,
key: AccountConfig::DOWNLOAD_LINKS_EXPIRE_KEY).value == false
LINK_EXPIRES_AT.from_now
end
end
+6 -1
View File
@@ -102,6 +102,7 @@ module ReplaceEmailVariables
return unless submitter
value = submitter.try(field_name)
expires_at = nil
if value_name
field = (submission.template_fields || submission.template.fields).find { |e| e['name'] == value_name }
@@ -114,7 +115,11 @@ module ReplaceEmailVariables
attachment = submitter.attachments.find { |e| e.uuid == attachment_uuid }
ActiveStorage::Blob.proxy_url(attachment.blob) if attachment
if attachment
expires_at ||= Accounts.link_expires_at(Account.new(id: submission.account_id))
ActiveStorage::Blob.proxy_url(attachment.blob, expires_at:)
end
else
value[field&.dig('uuid')]
end
+1 -1
View File
@@ -239,7 +239,7 @@ module Submissions
item['conditions'].each_with_object([]) do |condition, acc|
result =
if fields_index[condition['field_uuid']]['submitter_uuid'] == include_submitter_uuid
if fields_index.dig(condition['field_uuid'], 'submitter_uuid') == include_submitter_uuid
submitter_conditions_acc << condition if submitter_conditions_acc
true
+6 -2
View File
@@ -391,8 +391,8 @@ module Submissions
composer.formatted_text_box(
Array.wrap(value).map do |uuid|
attachment = submitter.attachments.find { |a| a.uuid == uuid }
link =
ActiveStorage::Blob.proxy_url(attachment.blob)
link = r.submissions_preview_url(submission.slug, **Docuseal.default_url_options)
{ link:, text: "#{attachment.filename}\n", style: :link }
end,
@@ -489,6 +489,10 @@ module Submissions
padding: [5, 0, 0, 8],
position: :float, text_align: :left)
end
def r
Rails.application.routes.url_helpers
end
# rubocop:enable Metrics
end
end
+10 -8
View File
@@ -6,8 +6,8 @@ module Submissions
module_function
def call(submissions, format: :csv)
rows = build_table_rows(submissions)
def call(submissions, format: :csv, expires_at: nil)
rows = build_table_rows(submissions, expires_at:)
if format.to_sym == :csv
rows_to_csv(rows)
@@ -57,7 +57,7 @@ module Submissions
headers.map { |key| row.find { |e| e[:name] == key }&.dig(:value) }
end
def build_table_rows(submissions)
def build_table_rows(submissions, expires_at: nil)
submissions.preload(submitters: [attachments_attachments: :blob, documents_attachments: :blob])
.find_each.map do |submission|
submission_data = []
@@ -69,7 +69,7 @@ module Submissions
submission_data += build_submission_data(submitter, submitter_name, submitters_count)
submission_data += submitter_formatted_fields(submitter).map do |field|
submission_data += submitter_formatted_fields(submitter, expires_at:).map do |field|
{
name: column_name(field[:name], submitter_name, submitters_count),
value: field[:value]
@@ -81,7 +81,7 @@ module Submissions
submission_data += submitter.documents.map.with_index(1) do |attachment, index|
{
name: "#{I18n.t('document')} #{index}",
value: ActiveStorage::Blob.proxy_url(attachment.blob)
value: ActiveStorage::Blob.proxy_url(attachment.blob, expires_at:)
}
end
end
@@ -123,7 +123,7 @@ module Submissions
submitters_count > 1 ? "#{submitter_name} - #{name}" : name
end
def submitter_formatted_fields(submitter)
def submitter_formatted_fields(submitter, expires_at: nil)
fields = submitter.submission.template_fields || submitter.submission.template.fields
template_fields = fields.select { |f| f['submitter_uuid'] == submitter.uuid }
@@ -142,11 +142,13 @@ module Submissions
value =
if template_field_type.in?(%w[image signature])
attachment = attachments_index[submitter_value]
ActiveStorage::Blob.proxy_url(attachment.blob) if attachment
ActiveStorage::Blob.proxy_url(attachment.blob, expires_at:) if attachment
elsif template_field_type == 'file'
Array.wrap(submitter_value).compact_blank.filter_map do |e|
attachment = attachments_index[e]
ActiveStorage::Blob.proxy_url(attachment.blob) if attachment
ActiveStorage::Blob.proxy_url(attachment.blob, expires_at:) if attachment
end
else
submitter_value
+13 -7
View File
@@ -201,13 +201,15 @@ module Submissions
attachments_data_cache = {}
return pdfs_index if submitter.submission.template_fields.blank?
submission = submitter.submission
with_headings = find_last_submitter(submitter.submission, submitter:).blank? if with_headings.nil?
return pdfs_index if submission.template_fields.blank?
with_headings = find_last_submitter(submission, submitter:).blank? if with_headings.nil?
locale = submitter.metadata.fetch('lang', account.locale)
submitter.submission.template_fields.each do |field|
submission.template_fields.each do |field|
next if field['type'] == 'heading' && !with_headings
next if field['submitter_uuid'] != submitter.uuid && field['type'] != 'heading'
@@ -224,7 +226,7 @@ module Submissions
page[:Annots] ||= []
page[:Annots] = page[:Annots].try(:reject) do |e|
next if e.is_a?(Integer)
next if e.is_a?(Integer) || e.is_a?(Symbol)
e.present? && e[:A] && e[:A][:URI].to_s.starts_with?('file:///docuseal_field')
end || page[:Annots]
@@ -476,7 +478,7 @@ module Submissions
height_diff - (height_diff.zero? ? diff : 0)
],
A: { Type: :Action, S: :URI,
URI: ActiveStorage::Blob.proxy_url(attachment.blob) }
URI: r.submissions_preview_url(submission.slug, **Docuseal.default_url_options) }
}
)
@@ -747,11 +749,15 @@ module Submissions
def maybe_rotate_pdf(pdf)
return pdf if pdf.pages.size > MAX_PAGE_ROTATE
is_pages_rotated = pdf.pages.root[:Rotate].present? && pdf.pages.root[:Rotate] != 0
pdf.pages.root[:Rotate] = 0 if is_pages_rotated
is_rotated = pdf.pages.filter_map do |page|
page.rotate(0, flatten: true) if page[:Rotate] != 0
end.present?
return pdf unless is_rotated
return pdf if !is_rotated && !is_pages_rotated
io = StringIO.new
@@ -873,7 +879,7 @@ module Submissions
end
end
def h
def r
Rails.application.routes.url_helpers
end
end
+12 -7
View File
@@ -4,7 +4,6 @@ module Submissions
module SerializeForApi
SERIALIZE_PARAMS = {
only: %i[id name slug source submitters_order expire_at created_at updated_at archived_at],
methods: %i[audit_log_url combined_document_url],
include: {
submitters: { only: %i[id] },
template: { only: %i[id name external_id created_at updated_at],
@@ -15,11 +14,13 @@ module Submissions
module_function
def call(submission, submitters = nil, params = {}, with_events: true, with_documents: true, with_values: true)
def call(submission, submitters = nil, params = {}, with_events: true, with_documents: true, with_values: true,
expires_at: Accounts.link_expires_at(Account.new(id: submission.account_id)))
submitters ||= submission.submitters.preload(documents_attachments: :blob, attachments_attachments: :blob)
serialized_submitters = submitters.map do |submitter|
Submitters::SerializeForApi.call(submitter, with_documents:, with_events: false, with_values:, params:)
Submitters::SerializeForApi.call(submitter, with_documents:, with_events: false, with_values:, params:,
expires_at:)
end
json = submission.as_json(SERIALIZE_PARAMS)
@@ -30,8 +31,6 @@ module Submissions
json['submission_events'] = Submitters::SerializeForApi.serialize_events(submission.submission_events)
end
json['combined_document_url'] ||= maybe_build_combined_url(submitters, submission, params)
if submitters.all?(&:completed_at?)
last_submitter = submitters.max_by(&:completed_at)
@@ -39,10 +38,16 @@ module Submissions
json['documents'] = serialized_submitters.find { |e| e['id'] == last_submitter.id }['documents']
end
json['audit_log_url'] = submission.audit_log_url(expires_at:)
json['combined_document_url'] = submission.combined_document_url(expires_at:)
json['combined_document_url'] ||= maybe_build_combined_url(submitters, submission, params, expires_at:)
json['status'] = 'completed'
json['completed_at'] = last_submitter.completed_at.as_json
else
json['documents'] = [] if with_documents
json['audit_log_url'] = nil
json['combined_document_url'] = nil
json['status'] = build_status(submission, submitters)
json['completed_at'] = nil
end
@@ -60,7 +65,7 @@ module Submissions
end
end
def maybe_build_combined_url(submitters, submission, params)
def maybe_build_combined_url(submitters, submission, params, expires_at: nil)
return unless submitters.all?(&:completed_at?)
attachment = submission.combined_document_attachment
@@ -71,7 +76,7 @@ module Submissions
attachment = Submissions::GenerateCombinedAttachment.call(submitter)
end
ActiveStorage::Blob.proxy_url(attachment.blob) if attachment
ActiveStorage::Blob.proxy_url(attachment.blob, expires_at:) if attachment
end
end
end
+8 -4
View File
@@ -11,7 +11,7 @@ module Submitters
module_function
def call(submitter, with_template: false, with_events: false, with_documents: true, with_urls: false,
with_values: true, params: {})
with_values: true, params: {}, expires_at: Accounts.link_expires_at(Account.new(id: submitter.account_id)))
ActiveRecord::Associations::Preloader.new(
records: [submitter],
associations: if with_documents
@@ -24,15 +24,19 @@ module Submitters
additional_attrs = {}
if params[:include].to_s.include?('fields')
additional_attrs['fields'] = SerializeForWebhook.build_fields_array(submitter)
additional_attrs['fields'] = SerializeForWebhook.build_fields_array(submitter, expires_at:)
end
if with_template
additional_attrs['template'] = submitter.submission.template.as_json(only: %i[id name created_at updated_at])
end
additional_attrs['values'] = SerializeForWebhook.build_values_array(submitter) if with_values
additional_attrs['documents'] = SerializeForWebhook.build_documents_array(submitter) if with_documents
additional_attrs['values'] = SerializeForWebhook.build_values_array(submitter, expires_at:) if with_values
if with_documents
additional_attrs['documents'] = SerializeForWebhook.build_documents_array(submitter, expires_at:)
end
additional_attrs['preferences'] = submitter.preferences.except('default_values')
additional_attrs['submission_events'] = serialize_events(submitter.submission_events) if with_events
+17 -17
View File
@@ -10,13 +10,13 @@ module Submitters
module_function
def call(submitter)
def call(submitter, expires_at: Accounts.link_expires_at(Account.new(id: submitter.account_id)))
ActiveRecord::Associations::Preloader.new(
records: [submitter], associations: [documents_attachments: :blob, attachments_attachments: :blob]
).call
values = build_values_array(submitter)
documents = build_documents_array(submitter)
values = build_values_array(submitter, expires_at:)
documents = build_documents_array(submitter, expires_at:)
submission = submitter.submission
@@ -32,7 +32,7 @@ module Submitters
'preferences' => submitter.preferences.except('default_values'),
'values' => values,
'documents' => documents,
'audit_log_url' => submitter.submission.audit_log_url,
'audit_log_url' => submitter.submission.audit_log_url(expires_at:),
'submission_url' => r.submissions_preview_url(submission.slug, **Docuseal.default_url_options),
'template' => submission.template.as_json(
only: %i[id name external_id created_at updated_at],
@@ -40,15 +40,15 @@ module Submitters
),
'submission' => {
'id' => submission.id,
'audit_log_url' => submission.audit_log_url,
'combined_document_url' => submission.combined_document_url,
'audit_log_url' => submission.audit_log_url(expires_at:),
'combined_document_url' => submission.combined_document_url(expires_at:),
'status' => build_submission_status(submission),
'url' => r.submissions_preview_url(submission.slug, **Docuseal.default_url_options),
'created_at' => submission.created_at.as_json
})
end
def build_values_array(submitter)
def build_values_array(submitter, expires_at: nil)
fields = submitter.submission.template_fields.presence || submitter.submission&.template&.fields || []
attachments_index = submitter.attachments.index_by(&:uuid)
submitter_field_counters = Hash.new { 0 }
@@ -64,13 +64,13 @@ module Submitters
next if !submitter.values.key?(field['uuid']) && !submitter.completed_at?
value = fetch_field_value(field, submitter.values[field['uuid']], attachments_index)
value = fetch_field_value(field, submitter.values[field['uuid']], attachments_index, expires_at:)
{ 'field' => field_name, 'value' => value }
end
end
def build_fields_array(submitter)
def build_fields_array(submitter, expires_at: nil)
fields = submitter.submission.template_fields.presence || submitter.submission&.template&.fields || []
attachments_index = submitter.attachments.index_by(&:uuid)
submitter_field_counters = Hash.new { 0 }
@@ -86,7 +86,7 @@ module Submitters
next if !submitter.values.key?(field['uuid']) && !submitter.completed_at?
value = fetch_field_value(field, submitter.values[field['uuid']], attachments_index)
value = fetch_field_value(field, submitter.values[field['uuid']], attachments_index, expires_at:)
{ 'name' => field_name, 'uuid' => field['uuid'], 'value' => value, 'readonly' => field['readonly'] == true }
end
@@ -104,26 +104,26 @@ module Submitters
end
end
def build_documents_array(submitter)
def build_documents_array(submitter, expires_at: nil)
submitter.documents.map do |attachment|
{ 'name' => attachment.filename.base, 'url' => rails_storage_proxy_url(attachment) }
{ 'name' => attachment.filename.base, 'url' => rails_storage_proxy_url(attachment, expires_at:) }
end
end
def fetch_field_value(field, value, attachments_index)
def fetch_field_value(field, value, attachments_index, expires_at: nil)
if field['type'].in?(%w[image signature initials stamp payment])
rails_storage_proxy_url(attachments_index[value])
rails_storage_proxy_url(attachments_index[value], expires_at:)
elsif field['type'] == 'file'
Array.wrap(value).compact_blank.filter_map { |e| rails_storage_proxy_url(attachments_index[e]) }
Array.wrap(value).compact_blank.filter_map { |e| rails_storage_proxy_url(attachments_index[e], expires_at:) }
else
value
end
end
def rails_storage_proxy_url(attachment)
def rails_storage_proxy_url(attachment, expires_at: nil)
return if attachment.blank?
ActiveStorage::Blob.proxy_url(attachment.blob)
ActiveStorage::Blob.proxy_url(attachment.blob, expires_at:)
end
def r
+4
View File
@@ -37,6 +37,10 @@ module Templates
hash
end
def maybe_assign_access(_template)
nil
end
def search(current_user, templates, keyword)
if Docuseal.fulltext_search?
fulltext_search(current_user, templates, keyword)
+1
View File
@@ -10,6 +10,7 @@ module Templates
pdf.pages.flat_map.with_index do |page, index|
(page[:Annots] || []).filter_map do |annot|
next if annot.blank?
next if annot.is_a?(Integer) || annot.is_a?(Symbol)
next if annot[:A].blank? || annot[:A][:URI].blank?
next unless annot[:Subtype] == :Link
next if !annot[:A][:URI].starts_with?('https://') && !annot[:A][:URI].starts_with?('http://')
+4 -3
View File
@@ -14,7 +14,8 @@ module Templates
module_function
def call(template, schema_documents = template.schema_documents.preload(:blob), preview_image_attachments = nil)
def call(template, schema_documents: template.schema_documents.preload(:blob), preview_image_attachments: nil,
expires_at: Accounts.link_expires_at(Account.new(id: template.account_id)))
json = template.as_json(SERIALIZE_PARAMS)
preview_image_attachments ||=
@@ -40,8 +41,8 @@ module Templates
{
'id' => attachment.id,
'uuid' => attachment.uuid,
'url' => ActiveStorage::Blob.proxy_url(attachment.blob),
'preview_image_url' => first_page_blob && ActiveStorage::Blob.proxy_url(first_page_blob),
'url' => ActiveStorage::Blob.proxy_url(attachment.blob, expires_at:),
'preview_image_url' => first_page_blob && ActiveStorage::Blob.proxy_url(first_page_blob, expires_at:),
'filename' => attachment.filename
}
end
+7 -3
View File
@@ -8,6 +8,10 @@ describe 'Templates API' do
let(:folder) { create(:template_folder, account:) }
let(:template_preferences) { { 'request_email_subject' => 'Subject text', 'request_email_body' => 'Body Text' } }
before do
allow(Accounts).to receive(:link_expires_at).and_return(Accounts::LINK_EXPIRES_AT)
end
describe 'GET /api/templates' do
it 'returns a list of templates' do
templates = [
@@ -211,8 +215,8 @@ describe 'Templates API' do
{
id: template.documents.first.id,
uuid: template.documents.first.uuid,
url: ActiveStorage::Blob.proxy_url(attachment.blob),
preview_image_url: ActiveStorage::Blob.proxy_url(first_page_blob),
url: ActiveStorage::Blob.proxy_url(attachment.blob, expires_at: Accounts::LINK_EXPIRES_AT),
preview_image_url: ActiveStorage::Blob.proxy_url(first_page_blob, expires_at: Accounts::LINK_EXPIRES_AT),
filename: 'sample-document.pdf'
}
],
@@ -235,7 +239,7 @@ describe 'Templates API' do
folder_name: folder.name,
source: 'native',
external_id: template.external_id,
application_key: template.external_id # Backward compatibility
application_key: template.external_id
}
end
+22
View File
@@ -14,4 +14,26 @@ RSpec.describe 'API Settings' do
token = user.access_token.token
expect(page).to have_field('X-Auth-Token', with: token.sub(token[5..], '*' * token[5..].size))
end
it 'reveals API key with correct password' do
find('#api_key').click
within('.modal') do
fill_in 'password', with: user.password
click_button 'Submit'
end
expect(page).to have_field('X-Auth-Token', with: user.access_token.token)
end
it 'shows error with incorrect password' do
find('#api_key').click
within('.modal') do
fill_in 'password', with: 'wrong_password'
click_button 'Submit'
end
expect(page).to have_content('Wrong password')
end
end
+45 -1
View File
@@ -5,6 +5,9 @@ RSpec.describe 'Profile Settings' do
before do
sign_in(user)
allow(Accounts).to receive(:can_send_emails?).and_return(true)
visit settings_profile_index_path
end
@@ -16,7 +19,6 @@ RSpec.describe 'Profile Settings' do
expect(page).to have_content('Change Password')
expect(page).to have_field('user[password]')
expect(page).to have_field('user[password_confirmation]')
end
context 'when changes contact information' do
@@ -47,6 +49,7 @@ RSpec.describe 'Profile Settings' do
it 'updates password' do
fill_in 'New password', with: 'newpassword'
fill_in 'Confirm new password', with: 'newpassword'
fill_in 'Current password', with: 'password'
all(:button, 'Update')[1].click
@@ -56,10 +59,51 @@ RSpec.describe 'Profile Settings' do
it 'does not update if password confirmation does not match' do
fill_in 'New password', with: 'newpassword'
fill_in 'Confirm new password', with: 'newpassword1'
fill_in 'Current password', with: 'password'
all(:button, 'Update')[1].click
expect(page).to have_content("Password confirmation doesn't match Password")
end
it 'does not update if current password is incorrect' do
fill_in 'New password', with: 'newpassword'
fill_in 'Confirm new password', with: 'newpassword'
fill_in 'Current password', with: 'wrongpassword'
all(:button, 'Update')[1].click
expect(page).to have_content('Current password is invalid')
end
it 'resets password and signs in with new password', sidekiq: :inline do
fill_in 'New password', with: 'newpassword'
accept_confirm('Are you sure?') do
find('label', text: 'Click here').click
end
expect(page).to have_content('An email with password reset instructions has been sent.')
email = ActionMailer::Base.deliveries.last
reset_password_url = email.body
.encoded[/href="([^"]+)"/, 1]
.sub(%r{https?://(.*?)/}, "#{Capybara.current_session.server.base_url}/")
visit reset_password_url
fill_in 'New password', with: 'new_strong_password'
fill_in 'Confirm new password', with: 'new_strong_password'
click_button 'Change my password'
expect(page).to have_content('Your password has been changed successfully. You are now signed in.')
visit new_user_session_path
fill_in 'Email', with: user.email
fill_in 'Password', with: 'new_strong_password'
click_button 'Sign In'
expect(page).to have_content('Signed in successfully')
end
end
end
-1
View File
@@ -115,7 +115,6 @@ RSpec.describe 'Team Settings' do
fill_in 'First name', with: 'Adam'
fill_in 'Last name', with: 'Meier'
fill_in 'Email', with: 'adam.meier@example.com'
fill_in 'Password', with: 'new_password'
expect do
click_button 'Submit'