Compare commits

...

45 Commits

Author SHA1 Message Date
Alex Turchyn 7ce6c29f4d Merge from docusealco/wip 2026-07-27 15:56:18 +03:00
Pete Matsyburka ae50b2e323 add i18n 2026-07-27 14:56:08 +03:00
Pete Matsyburka 152cbc11a6 fix specs 2026-07-26 17:44:01 +03:00
Pete Matsyburka 3a406de655 adjust release tag 2026-07-26 09:37:03 +03:00
Pete Matsyburka 2e31cda08c adjust url pattern 2026-07-26 08:26:32 +03:00
Pete Matsyburka 094b7f47bd set forwarded priority 2026-07-25 20:12:18 +03:00
Pete Matsyburka 99ac349ecc sanitize dynamic document attributes 2026-07-25 19:46:41 +03:00
Pete Matsyburka 8830e03cf3 adjust cert config 2026-07-25 14:46:45 +03:00
Pete Matsyburka 706f3d6d65 fix image load 2026-07-25 13:51:55 +03:00
Pete Matsyburka 12b49f5fa7 log os 2026-07-25 10:53:41 +03:00
Pete Matsyburka 85327760b9 refactor mcp 2026-07-25 10:04:44 +03:00
Pete Matsyburka c94ed9c3d4 update gem 2026-07-24 23:05:57 +03:00
Pete Matsyburka bc50735d2a update gem 2026-07-23 14:13:55 +03:00
Pete Matsyburka d05e125fa9 use :has selectors for search input 2026-07-23 11:13:10 +03:00
Alex Turchyn 48d595ca81 full width search input on mobile devices 2026-07-23 10:36:45 +03:00
Pete Matsyburka a156de5e75 optional initials 2026-07-22 08:45:20 +03:00
Pete Matsyburka a7003bf4d0 bump stars 2026-07-21 13:10:36 +03:00
Alex Turchyn 5fe75c84ff Merge from docusealco/wip 2026-07-20 14:01:14 +03:00
Alex Turchyn 4549a04fc5 disable turbo on sign out form 2026-07-18 14:27:45 +03:00
Pete Matsyburka 0ea6b912a3 file download variables 2026-07-17 12:30:15 +03:00
Pete Matsyburka 9b86fa3d40 add date formats config 2026-07-17 11:27:44 +03:00
Pete Matsyburka 374f66c59f send view only 2026-07-17 11:12:47 +03:00
Pete Matsyburka da43d881f1 edit message per 10 parties 2026-07-17 09:34:43 +03:00
Pete Matsyburka 9cbfa3a0ba fix condition on complete 2026-07-17 00:30:06 +03:00
Pete Matsyburka 77a925f33b fix invite form return to fields 2026-07-16 15:55:21 +03:00
Pete Matsyburka 3f7d7beedd fix sharing toggle 2026-07-15 10:55:04 +03:00
Pete Matsyburka 40d91df732 Revert "adjust shared query"
This reverts commit 56da326709.
2026-07-15 10:49:09 +03:00
Pete Matsyburka c8e783a210 multi select context menu readonly and required 2026-07-14 17:30:41 +03:00
Pete Matsyburka 61fc24d35d add with signature id completed config 2026-07-14 12:47:38 +03:00
Pete Matsyburka 73734b0de7 add variables_schema response 2026-07-13 19:34:09 +03:00
Pete Matsyburka 09bd831d54 adjust submission.name variable 2026-07-13 19:13:46 +03:00
Pete Matsyburka 2c9f1b999e update icon 2026-07-13 17:16:42 +03:00
Alex Turchyn 6a3a185310 Merge from docusealco/wip 2026-07-13 13:23:35 +03:00
Pete Matsyburka 7d381b7aee add reset smtp action 2026-07-13 10:58:08 +03:00
Alex Turchyn bba5626dea add ability to edit html email 2026-07-10 21:57:23 +03:00
Pete Matsyburka bdf5e58929 fix autocomplete index use 2026-07-10 10:32:04 +03:00
Pete Matsyburka 035168230e match generated font frontend 2026-07-09 15:44:34 +03:00
Pete Matsyburka 28168f082d adjust view only 2026-07-09 12:46:38 +03:00
Pete Matsyburka a29e09ed2f add submission update api 2026-07-08 11:16:56 +03:00
Pete Matsyburka e88874e757 view only party 2026-07-07 17:34:23 +03:00
Pete Matsyburka b6fb7c6977 fix find blob by checksum 2026-07-07 09:50:11 +03:00
Pete Matsyburka 8a60f17d13 optimize created_at filter 2026-07-07 09:41:37 +03:00
Pete Matsyburka e1d860a42c add submission created_at index 2026-07-07 09:14:15 +03:00
Pete Matsyburka 56da326709 adjust shared query 2026-07-06 18:45:37 +03:00
Pete Matsyburka 672008f8af add shared templates index param 2026-07-06 17:56:35 +03:00
129 changed files with 2680 additions and 934 deletions
+3 -1
View File
@@ -30,7 +30,9 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Create .version file
run: echo ${{ github.ref_name }} > .version
env:
REF_NAME: ${{ github.ref_name }}
run: echo "$REF_NAME" > .version
- name: Login to Docker Hub
uses: docker/login-action@v3
+4 -4
View File
@@ -149,7 +149,7 @@ GEM
crack (1.0.1)
bigdecimal
rexml
crass (1.0.6)
crass (1.0.7)
csv (3.3.5)
csv-safe (3.3.1)
csv (~> 3.0)
@@ -293,7 +293,7 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.25.1)
loofah (2.25.2)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.0)
@@ -404,8 +404,8 @@ GEM
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.7.0)
loofah (~> 2.25)
rails-html-sanitizer (1.7.1)
loofah (~> 2.25, >= 2.25.2)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (8.1.0)
i18n (>= 0.7, < 2)
@@ -30,7 +30,7 @@ module Api
private
def build_completed_documents(submission, merge: false)
last_submitter = submission.submitters.max_by(&:completed_at)
last_submitter = submission.submitters.select(&:completed_at?).max_by(&:completed_at)
if merge
if submission.merged_document_attachment.blank?
+42 -3
View File
@@ -7,7 +7,7 @@ module Api
TEMPLATE_COLUMNS = %i[id name external_id created_at updated_at folder_id submitters].freeze
load_and_authorize_resource :template, only: :create
load_and_authorize_resource :submission, only: %i[show index destroy]
load_and_authorize_resource :submission, only: %i[show index update destroy]
before_action only: :create do
authorize!(:create, Submission)
@@ -80,8 +80,9 @@ module Api
Submissions.send_signature_requests(submissions)
submissions.each do |submission|
if submission.submitters.all?(&:completed_at?) && Submissions.maybe_update_completed_at(submission)
last_submitter = submission.submitters.max_by(&:completed_at)
if submission.submitters.all? { |s| s.viewer? || s.completed_at? } &&
Submissions.maybe_update_completed_at(submission)
last_submitter = submission.submitters.reject(&:viewer?).max_by(&:completed_at)
end
submission.submitters.each do |submitter|
@@ -103,6 +104,25 @@ module Api
render json: { error: e.message }, status: :unprocessable_content
end
def update
@submission = assign_submission_attrs(@submission, submission_params)
@submission.save!
if @submission.saved_change_to_archived_at? && @submission.archived_at?
WebhookUrls.enqueue_events(@submission, 'submission.archived')
end
if @submission.saved_change_to_expire_at? && @submission.expire_at?
ProcessSubmissionExpiredJob.perform_at(@submission.expire_at, 'submission_id' => @submission.id,
'expire_at' => @submission.expire_at.to_i)
end
SearchEntries.enqueue_reindex(@submission) if @submission.saved_change_to_name?
render json: Submissions::SerializeForApi.call(@submission, nil, params, with_events: false)
end
def destroy
if params[:permanently].in?(['true', true])
@submission.destroy!
@@ -117,6 +137,25 @@ module Api
private
def assign_submission_attrs(submission, attrs)
archived = attrs.key?(:archived) ? attrs[:archived] : attrs[:archived_at]
if archived.in?([true, false, 'true', 'false']) && current_ability.can?(:destroy, submission)
submission.archived_at = archived.in?(Submitters::TRUE_VALUES) ? Time.current : nil
end
submission.name = attrs[:name] if attrs.key?(:name)
submission.expire_at = attrs[:expire_at].presence if attrs.key?(:expire_at)
submission
end
def submission_params
submission_params = params.key?(:submission) ? params.require(:submission) : params
submission_params.permit(:name, :expire_at, :archived, :archived_at)
end
def maybe_return_template_error
return render json: { error: 'Template not found' }, status: :unprocessable_content if @template.nil?
+1 -1
View File
@@ -101,7 +101,7 @@ module Api
private
def maybe_return_submitter_error
if @submitter.completed_at?
if @submitter.completed_at? || @submitter.submission.completed_at?
return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_content
end
+10 -2
View File
@@ -5,6 +5,8 @@ module Api
load_and_authorize_resource :template
def index
@templates = Templates.shared(current_user) if params[:shared].in?(['true', true])
templates = filter_templates(@templates, params)
templates = paginate(templates.preload(:author, folder: :parent_folder))
@@ -54,7 +56,7 @@ module Api
@template.update!(template_params)
SearchEntries.enqueue_reindex(@template)
SearchEntries.enqueue_reindex(@template) if @template.saved_change_to_name?
WebhookUrls.enqueue_events(@template, 'template.updated')
@@ -115,7 +117,13 @@ module Api
end
def filter_templates(templates, params)
templates = Templates.search(current_user, templates, params[:q])
templates =
if params[:shared].in?(['true', true])
Templates.search_shared(current_user, templates, params[:q])
else
Templates.search(current_user, templates, params[:q])
end
templates = params[:archived].in?(['true', true]) ? templates.archived : templates.active
templates = templates.where(external_id: params[:application_key]) if params[:application_key].present?
templates = templates.where(external_id: params[:external_id]) if params[:external_id].present?
@@ -3,7 +3,7 @@
class EmailSmtpSettingsController < ApplicationController
before_action :load_encrypted_config
authorize_resource :encrypted_config, only: :index
authorize_resource :encrypted_config, parent: false, only: :create
authorize_resource :encrypted_config, parent: false, only: %i[create destroy]
def index; end
@@ -23,6 +23,12 @@ class EmailSmtpSettingsController < ApplicationController
render :index, status: :unprocessable_content
end
def destroy
@encrypted_config.destroy!
redirect_to settings_email_index_path, notice: I18n.t('smtp_settings_have_been_reset')
end
private
def load_encrypted_config
+1 -2
View File
@@ -14,8 +14,7 @@ class EsignSettingsController < ApplicationController
prepend_before_action :maybe_redirect_com, only: %i[show]
before_action :load_encrypted_config
authorize_resource :encrypted_config, parent: false, only: %i[new create]
authorize_resource :encrypted_config, only: %i[update destroy show]
authorize_resource :encrypted_config, parent: false
def show
cert_data = @encrypted_config.value || {}
@@ -0,0 +1,90 @@
# frozen_string_literal: true
module Mcp
class CreateTemplateController < McpBaseController
SCHEMA = {
name: 'create_template',
title: 'Create Template',
description: 'Create a document template. Provide a URL to upload a PDF/DOCX file, or provide only a name ' \
'to create an empty template and receive an edit URL where the file can be uploaded via the UI.',
inputSchema: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Template name (used as the template name and required when url is not provided)'
},
url: {
type: 'string',
description: 'Optional URL of a PDF or DOCX file to upload. If omitted, an empty template is ' \
'created and the returned edit_url can be used to upload a file via the UI.'
}
},
required: %w[name]
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true
}
}.freeze
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
def call
account = current_user.account
@template = Template.new(
account:,
author: current_user,
folder: account.default_template_folder,
source: :mcp,
name: mcp_params['name'].to_s.presence || 'New Template',
fields: [],
schema: []
)
authorize!(:create, @template)
if mcp_params['url'].present?
tempfile = Tempfile.new
tempfile.binmode
tempfile.write(DownloadUtils.call(mcp_params['url'], validate: true).body)
tempfile.rewind
filename = File.basename(URI.decode_www_form_component(mcp_params['url']))
file = ActionDispatch::Http::UploadedFile.new(
tempfile:,
filename:,
type: Marcel::MimeType.for(tempfile)
)
@template.name = mcp_params['name'].presence || File.basename(filename, '.*')
@template.save!
documents, = Templates::CreateAttachments.call(@template, { files: [file] }, extract_fields: true)
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
if @template.fields.blank?
@template.fields = Templates::ProcessDocument.normalize_attachment_fields(@template, documents)
end
@template.update!(schema:)
else
@template.save!
end
WebhookUrls.enqueue_events(@template, 'template.created')
SearchEntries.enqueue_reindex(@template)
render_tool_result(
id: @template.id,
name: @template.name,
edit_url: edit_template_url(@template)
)
end
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
end
end
@@ -0,0 +1,54 @@
# frozen_string_literal: true
module Mcp
class LoadTemplateController < McpBaseController
SCHEMA = {
name: 'load_template',
title: 'Load Template',
description: 'Load a template with its fields. Each field includes name, type, and the signing role name.',
inputSchema: {
type: 'object',
properties: {
template_id: {
type: 'integer',
description: 'Template identifier'
}
},
required: %w[template_id]
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false
}
}.freeze
def call
@template = Template.accessible_by(current_ability).find(mcp_params['template_id'])
authorize!(:read, @template)
submitters_index = @template.submitters.index_by { |s| s['uuid'] }
roles = @template.submitters.pluck('name')
fields = @template.fields.filter_map do |field|
next if field['name'].blank?
{
name: field['name'],
type: field['type'],
role: submitters_index[field['submitter_uuid']]&.dig('name')
}
end
render_tool_result(
id: @template.id,
name: @template.name,
roles: roles,
fields: fields
)
end
end
end
@@ -0,0 +1,81 @@
# frozen_string_literal: true
module Mcp
class McpBaseController < ActionController::API
wrap_parameters false
before_action :authenticate_user!
before_action :verify_mcp_enabled!
check_authorization
before_action do
raise CanCan::AccessDenied unless can?(:manage, :mcp)
end
rescue_from CanCan::AccessDenied do
render_error(-32_603, 'Forbidden', status: :forbidden)
end
rescue_from ActiveRecord::RecordNotFound do
render_tool_error('Not found')
end
private
def default_url_options
Docuseal.default_url_options
end
def mcp_body
request.request_parameters
end
def mcp_params
mcp_body.dig('params', 'arguments') || {}
end
def render_result(result)
render json: { jsonrpc: '2.0', id: mcp_body['id'], result: }
end
def render_error(code, message, id: nil, status: :ok)
render json: { jsonrpc: '2.0', id:, error: { code:, message: } }, status:
end
def render_tool_result(data)
render_result(content: [{ type: 'text', text: data.to_json }])
end
def render_tool_error(message)
render_result(content: [{ type: 'text', text: message }], isError: true)
end
def authenticate_user!
render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user
end
def verify_mcp_enabled!
return if Docuseal.multitenant?
return if AccountConfig.exists?(account_id: current_user.account_id,
key: AccountConfig::ENABLE_MCP_KEY,
value: true)
render json: { error: 'MCP is disabled' }, status: :forbidden
end
def current_user
@current_user ||= user_from_api_key
end
def user_from_api_key
token = request.headers['Authorization'].to_s[/\ABearer\s+(.+)\z/, 1]
return if token.blank?
sha256 = Digest::SHA256.hexdigest(token)
User.joins(:mcp_tokens).active.find_by(mcp_tokens: { sha256:, archived_at: nil })
end
end
end
@@ -0,0 +1,50 @@
# frozen_string_literal: true
module Mcp
class ProtocolController < McpBaseController
skip_authorization_check
def ok
head :ok
end
def initialize_request
render_result(
protocolVersion: '2025-11-25',
serverInfo: {
name: 'DocuSeal',
version: Docuseal.version.to_s
},
capabilities: {
tools: {
listChanged: false
}
}
)
end
def initialized_notification
head :accepted
end
def ping
render_result({})
end
def tools_list
render_result(tools: McpController::TOOLS)
end
def method_not_found
render_error(-32_601, "Method not found: #{mcp_body['method']}", id: mcp_body['id'])
end
def tool_not_found
render_error(-32_602, "Unknown tool: #{mcp_body.dig('params', 'name')}", id: mcp_body['id'])
end
def parse_error
render_error(-32_700, 'Parse error', status: :bad_request)
end
end
end
@@ -0,0 +1,59 @@
# frozen_string_literal: true
module Mcp
class SearchDocumentsController < McpBaseController
SCHEMA = {
name: 'search_documents',
title: 'Search Documents',
description: 'Search signed or pending documents by submitter name, email, phone, or template name',
inputSchema: {
type: 'object',
properties: {
q: {
type: 'string',
description: 'Search by submitter name, email, phone, or template name'
},
limit: {
type: 'integer',
description: 'The number of results to return (default 10)'
}
},
required: %w[q]
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false
}
}.freeze
def call
authorize!(:read, Submission)
submissions = Submissions.search(current_user, Submission.accessible_by(current_ability).active,
mcp_params['q'], search_template: true)
limit = mcp_params.fetch('limit', 10).to_i
limit = 10 if limit <= 0
limit = [limit, 100].min
submissions = submissions.preload(:submitters, :template)
.order(id: :desc)
.limit(limit)
data = submissions.map do |submission|
{
id: submission.id,
template_name: submission.template&.name,
status: Submissions::SerializeForApi.build_status(submission, submission.submitters),
submitters: submission.submitters.map do |s|
{ email: s.email, name: s.name, phone: s.phone, status: s.status }
end,
documents_url: submission_url(submission.id)
}
end
render_tool_result(data)
end
end
end
@@ -0,0 +1,44 @@
# frozen_string_literal: true
module Mcp
class SearchTemplatesController < McpBaseController
SCHEMA = {
name: 'search_templates',
title: 'Search Templates',
description: 'Search document templates by name',
inputSchema: {
type: 'object',
properties: {
q: {
type: 'string',
description: 'Search query to filter templates by name'
},
limit: {
type: 'integer',
description: 'The number of templates to return (default 10)'
}
},
required: %w[q]
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false
}
}.freeze
def call
authorize!(:read, Template)
templates = Templates.search(current_user, Template.accessible_by(current_ability).active, mcp_params['q'])
limit = mcp_params.fetch('limit', 10).to_i
limit = 10 if limit <= 0
limit = [limit, 100].min
templates = templates.order(id: :desc).limit(limit)
render_tool_result(templates.map { |t| { id: t.id, name: t.name } })
end
end
end
@@ -0,0 +1,120 @@
# frozen_string_literal: true
module Mcp
class SendDocumentsController < McpBaseController
SCHEMA = {
name: 'send_documents',
title: 'Send Documents',
description: 'Send a document template for signing to specified submitters',
inputSchema: {
type: 'object',
properties: {
template_id: {
type: 'integer',
description: 'Template identifier'
},
submitters: {
type: 'array',
description: 'The list of submitters (signers)',
items: {
type: 'object',
properties: {
email: {
type: 'string',
description: 'Submitter email address'
},
name: {
type: 'string',
description: 'Submitter name'
},
phone: {
type: 'string',
description: 'Submitter phone number in E.164 format'
},
role: {
type: 'string',
description: 'Signing role name from the template'
},
fields: {
type: 'array',
description: 'Prefill field values for this submitter (fields become readonly)',
items: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Field name'
},
value: {
description: 'Prefilled value for the field'
}
},
required: %w[name value]
}
}
}
}
}
},
required: %w[template_id submitters]
},
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
openWorldHint: true
}
}.freeze
# rubocop:disable Metrics
def call
@template = Template.accessible_by(current_ability).find(mcp_params['template_id'])
authorize!(:read, @template)
return render_tool_error('Template has been archived') if @template.archived_at?
authorize!(:create, Submission.new(template: @template, account_id: current_user.account_id))
return render_tool_error('Template has no fields') if @template.fields.blank?
submitters = (mcp_params['submitters'] || []).map do |s|
attrs = s.slice('email', 'name', 'role', 'phone').compact_blank
fields = Array.wrap(s['fields']).filter_map do |f|
next if f['name'].blank?
{ 'name' => f['name'], 'default_value' => f['value'], 'readonly' => true }
end
attrs['fields'] = fields if fields.present?
attrs.with_indifferent_access
end
submissions = Submissions.create_from_submitters(
template: @template,
user: current_user,
source: :mcp,
submitters_order: @template.preferences['submitters_order'].presence || 'random',
submissions_attrs: { submitters: },
params: { 'send_email' => true, 'submitters' => submitters }
)
return render_tool_error('No valid submitters provided') if submissions.blank?
WebhookUrls.enqueue_events(submissions, 'submission.created')
Submissions.send_signature_requests(submissions)
SearchEntries.enqueue_reindex(submissions)
submission = submissions.first
render_tool_result(id: submission.id, status: 'pending')
rescue Submissions::CreateFromSubmitters::BaseError => e
render_tool_error(e.message)
end
# rubocop:enable Metrics
end
end
+31 -45
View File
@@ -1,58 +1,44 @@
# frozen_string_literal: true
class McpController < ActionController::API
before_action :authenticate_user!
before_action :verify_mcp_enabled!
class McpController < ActionController::Metal
TOOL_CONTROLLERS = {
'search_templates' => Mcp::SearchTemplatesController,
'load_template' => Mcp::LoadTemplateController,
'create_template' => Mcp::CreateTemplateController,
'send_documents' => Mcp::SendDocumentsController,
'search_documents' => Mcp::SearchDocumentsController
}.freeze
before_action do
authorize!(:manage, :mcp)
end
TOOLS = TOOL_CONTROLLERS.map { |_, controller| controller::SCHEMA }.freeze
def call
return head :ok if request.raw_post.blank?
return Mcp::ProtocolController.dispatch(:ok, request, response) if request.raw_post.blank?
body = JSON.parse(request.raw_post)
body = nil unless body.is_a?(Hash)
result = Mcp::HandleRequest.call(body, current_user, current_ability)
request.request_parameters = body || {}
if result
render json: result
else
head :accepted
end
rescue CanCan::AccessDenied
render json: { jsonrpc: '2.0', id: nil, error: { code: -32_603, message: 'Forbidden' } }, status: :forbidden
action =
case body&.dig('method')
when 'initialize' then :initialize_request
when 'notifications/initialized' then :initialized_notification
when 'ping' then :ping
when 'tools/list' then :tools_list
when 'tools/call'
tool = TOOL_CONTROLLERS[body.dig('params', 'name')]
return tool.dispatch(:call, request, response) if tool
:tool_not_found
else
:method_not_found
end
Mcp::ProtocolController.dispatch(action, request, response)
rescue JSON::ParserError
render json: { jsonrpc: '2.0', id: nil, error: { code: -32_700, message: 'Parse error' } }, status: :bad_request
end
request.request_parameters = {}
private
def authenticate_user!
render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user
end
def verify_mcp_enabled!
return if Docuseal.multitenant?
return if AccountConfig.exists?(account_id: current_user.account_id,
key: AccountConfig::ENABLE_MCP_KEY,
value: true)
render json: { error: 'MCP is disabled' }, status: :forbidden
end
def current_user
@current_user ||= user_from_api_key
end
def user_from_api_key
token = request.headers['Authorization'].to_s[/\ABearer\s+(.+)\z/, 1]
return if token.blank?
sha256 = Digest::SHA256.hexdigest(token)
User.joins(:mcp_tokens).active.find_by(mcp_tokens: { sha256:, archived_at: nil })
Mcp::ProtocolController.dispatch(:parse_error, request, response)
end
end
@@ -16,18 +16,16 @@ class SendSubmissionEmailController < ApplicationController
@submitter =
Submitter.completed.where(submission: template.submissions).find_by(email: params[:email].to_s.downcase)
elsif params[:submission_slug]
submission = Submission.find_by(slug: params[:submission_slug])
submission = Submission.find_by!(slug: params[:submission_slug])
if submission
@submitter = Submitter.completed.find_by(submission: submission, email: params[:email].to_s.downcase)
end
@submitter = submission.submitters.order(:completed_at).find_by(email: params[:email].to_s.downcase)
return redirect_to submissions_preview_completed_path(params[:submission_slug], status: :error) unless @submitter
else
@submitter = Submitter.completed.find_by!(slug: params[:submitter_slug])
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
end
if @submitter
if @submitter && completed_submitter?(@submitter)
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter)
@@ -41,6 +39,10 @@ class SendSubmissionEmailController < ApplicationController
private
def completed_submitter?(submitter)
submitter.completed_at? || (submitter.viewer? && submitter.submission.completed_at?)
end
def can_send?(submitter)
return false if submitter.account.archived_at?
return false if EmailEvent.exists?(tag: :submitter_documents_copy, email: submitter.email, emailable: submitter,
+8 -4
View File
@@ -93,9 +93,12 @@ class StartFormController < ApplicationController
SearchEntries.enqueue_reindex(submitter)
return unless submitter.submission.expire_at?
expire_at = submitter.submission.expire_at
ProcessSubmissionExpiredJob.perform_at(submitter.submission.expire_at, 'submission_id' => submitter.submission_id)
return unless expire_at
ProcessSubmissionExpiredJob.perform_at(expire_at, 'submission_id' => submitter.submission_id,
'expire_at' => expire_at.to_i)
end
def load_resubmit_submitter
@@ -138,8 +141,7 @@ class StartFormController < ApplicationController
submitter ||=
Submitter
.where(submission: template.submissions.where(expire_at: Time.current..)
.or(template.submissions.where(expire_at: nil)).where(archived_at: nil))
.where(submission: template.submissions.non_expired.active)
.order(id: :desc)
.where(declined_at: nil)
.where(external_id: nil)
@@ -147,6 +149,8 @@ class StartFormController < ApplicationController
.then { |rel| params[:resubmit].present? || params[:selfsign].present? ? rel.where(completed_at: nil) : rel }
.find_or_initialize_by(find_params)
submitter = Submitter.new(find_params) if submitter.submission&.completed_at? && submitter.viewer?
submitter.name = required_params['name'] if submitter.new_record?
unless @resubmit_submitter
+21
View File
@@ -87,6 +87,8 @@ class SubmissionsController < ApplicationController
private
def create_submissions(template, submissions_params, params)
normalize_message_submitter_uuids!(params)
submissions_attrs = submissions_params[:submission].to_h.values
submissions_attrs, _, new_fields =
@@ -111,4 +113,23 @@ class SubmissionsController < ApplicationController
def submissions_params
params.permit(submission: { submitters: [:uuid, :email, :phone, :name, { values: {} }] })
end
def normalize_message_submitter_uuids!(params)
return if params[:request_email_per_submitter] == '1'
uuids = params[:email_message_submitter_uuids]
return if uuids.blank?
return if params[:subject].blank? && params[:body].blank?
params[:submitter_preferences] =
Array.wrap(uuids).index_with { { 'subject' => params[:subject], 'body' => params[:body] } }
params[:request_email_per_submitter] = '1'
params.delete(:subject)
params.delete(:body)
params
end
end
@@ -19,7 +19,7 @@ class SubmitFormCompletedDownloadController < ApplicationController
@submitter ||= Submitter.find_by!(slug: submitter_slug)
Submissions::EnsureResultGenerated.call(@submitter)
Submissions::EnsureResultGenerated.call(@submitter) if @submitter.completed_at?
last_submitter = @submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last
@@ -28,11 +28,7 @@ class SubmitFormCompletedDownloadController < ApplicationController
Submissions::EnsureResultGenerated.call(last_submitter)
if !signature_valid && !current_user_submitter?(last_submitter)
unless Submitters::AuthorizedForForm.call(@submitter, current_user, request)
Rollbar.info("2FA download error: #{last_submitter.id}") if defined?(Rollbar)
return head :not_found
end
return head :not_found unless Submitters::AuthorizedForForm.call(@submitter, current_user, request)
if last_submitter.completed_at < TTL.ago
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
+10 -1
View File
@@ -18,7 +18,10 @@ class SubmitFormController < ApplicationController
submission = @submitter.submission
return render :email_2fa unless Submitters::AuthorizedForForm.pass_email_2fa?(@submitter, request)
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
if @submitter.completed_at? || submission.completed_at?
return redirect_to submit_form_completed_path(@submitter.slug)
end
@form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS)
@@ -71,6 +74,12 @@ class SubmitFormController < ApplicationController
status: :unprocessable_content
end
if @submitter.viewer?
Rollbar.warning("Submit viewer: #{@submitter.id}") if defined?(Rollbar)
return render json: { error: I18n.t('form_is_view_only') }, status: :unprocessable_content
end
Submitters::SubmitValues.call(@submitter, params, request)
head :ok
@@ -13,6 +13,7 @@ class SubmitFormDeclineController < ApplicationController
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
@submitter.submission.template&.archived_at? ||
@submitter.viewer? ||
!Submitters::AuthorizedForForm.call(@submitter,
current_user,
request)
@@ -12,6 +12,7 @@ class SubmitFormDelegateController < ApplicationController
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
@submitter.submission.template&.archived_at? ||
@submitter.viewer? ||
!Submitters::AuthorizedForForm.call(@submitter,
current_user,
request)
@@ -14,6 +14,7 @@ class SubmitFormDownloadController < ApplicationController
return head :unprocessable_content if @submitter.declined_at? ||
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
@submitter.submission.completed_at? ||
@submitter.submission.template&.archived_at? ||
AccountConfig.exists?(account_id: @submitter.account_id,
key: AccountConfig::ALLOW_TO_PARTIAL_DOWNLOAD_KEY,
@@ -12,6 +12,8 @@ class SubmitFormDrawSignatureController < ApplicationController
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
return redirect_to submit_form_path(@submitter.slug) if @submitter.viewer?
if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at? ||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
return redirect_to submit_form_path(@submitter.slug)
@@ -48,6 +48,7 @@ class SubmitFormInviteController < ApplicationController
!submitter.submission.archived_at? &&
!submitter.submission.expired? &&
!submitter.submission.template&.archived_at? &&
!submitter.viewer? &&
Submitters::AuthorizedForForm.call(submitter, current_user, request)
end
@@ -7,13 +7,7 @@ class SubmitFormMetadataController < ApplicationController
def index
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return head :not_found if @submitter.declined_at? ||
@submitter.completed_at? ||
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
@submitter.submission.template&.archived_at? ||
@submitter.account.archived_at? ||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
return head :not_found unless authorized_submitter?(@submitter)
submission = @submitter.submission
values = submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) }
@@ -34,4 +28,17 @@ class SubmitFormMetadataController < ApplicationController
render json: { text_runs: }
end
private
def authorized_submitter?(submitter)
!submitter.declined_at? &&
!submitter.completed_at? &&
!submitter.submission.archived_at? &&
!submitter.submission.completed_at? &&
!submitter.submission.expired? &&
!submitter.submission.template&.archived_at? &&
!submitter.account.archived_at? &&
Submitters::AuthorizedForForm.call(submitter, current_user, request)
end
end
+2 -2
View File
@@ -50,7 +50,7 @@ class SubmittersController < ApplicationController
def submitter_editable?(submission)
!@submitter.submission_events.exists?(event_type: 'start_form') &&
!@submitter.completed_at? && !@submitter.declined_at? &&
!@submitter.completed_at? && !@submitter.declined_at? && !submission.completed_at? &&
!submission.archived_at? && !submission.expired? && !submission.template&.archived_at?
end
@@ -58,7 +58,7 @@ class SubmittersController < ApplicationController
if params[:send_email] == '1' && submitter.email.present?
is_sent_recently = Docuseal.multitenant? &&
EmailEvent.exists?(email: submitter.email,
tag: 'submitter_invitation',
tag: %w[submitter_invitation submitter_view_invitation],
emailable: submitter,
event_type: 'send',
created_at: 4.hours.ago..Time.current)
@@ -5,6 +5,8 @@ class TemplatesPreferencesController < ApplicationController
RESETTABLE_PREFERENCE_KEYS = {
AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY => %w[request_email_subject request_email_body submitters],
AccountConfig::SUBMITTER_VIEW_INVITATION_EMAIL_KEY => %w[invitation_view_email_subject
invitation_view_email_body],
AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY => %w[invitation_reminder_email_subject
invitation_reminder_email_body],
AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => %w[documents_copy_email_subject documents_copy_email_body],
@@ -48,12 +50,12 @@ class TemplatesPreferencesController < ApplicationController
def template_params
params.require(:template).permit(
preferences: %i[bcc_completed request_email_subject request_email_body
invitation_view_email_subject invitation_view_email_body
invitation_reminder_email_subject invitation_reminder_email_body
documents_copy_email_subject documents_copy_email_body
documents_copy_email_enabled documents_copy_email_attach_audit
documents_copy_email_attach_documents documents_copy_email_reply_to
completed_notification_email_attach_documents
completed_redirect_url validate_unique_submitters
completed_notification_email_attach_documents completed_redirect_url validate_unique_submitters
require_all_submitters submitters_order require_phone_2fa require_email_2fa
default_expire_at_duration shared_link_2fa default_expire_at request_email_enabled
completed_notification_email_subject completed_notification_email_body
+3
View File
@@ -42,6 +42,7 @@ import RequiredCheckboxGroup from './elements/required_checkbox_group'
import PageContainer from './elements/page_container'
import EmailEditor from './elements/email_editor'
import MarkdownEditor from './elements/markdown_editor'
import HtmlEditor from './elements/html_editor'
import MountOnClick from './elements/mount_on_click'
import RemoveOnEvent from './elements/remove_on_event'
import ScrollTo from './elements/scroll_to'
@@ -135,6 +136,7 @@ safeRegisterElement('required-checkbox-group', RequiredCheckboxGroup)
safeRegisterElement('page-container', PageContainer)
safeRegisterElement('email-editor', EmailEditor)
safeRegisterElement('markdown-editor', MarkdownEditor)
safeRegisterElement('html-editor', HtmlEditor)
safeRegisterElement('mount-on-click', MountOnClick)
safeRegisterElement('remove-on-event', RemoveOnEvent)
safeRegisterElement('scroll-to', ScrollTo)
@@ -162,6 +164,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
this.app = createApp(TemplateBuilder, {
template,
customFields: reactive(JSON.parse(this.dataset.customFields || '[]')),
dateFormats: JSON.parse(this.dataset.dateFormats || '[]'),
dynamicDocuments: reactive(JSON.parse(this.dataset.dynamicDocuments || '[]')),
backgroundColor: '#faf7f5',
locale: this.dataset.locale,
+75 -3
View File
@@ -9,8 +9,9 @@ function loadCodeMirror () {
import(/* webpackChunkName: "email-editor" */ '@codemirror/commands'),
import(/* webpackChunkName: "email-editor" */ '@codemirror/language'),
import(/* webpackChunkName: "email-editor" */ '@codemirror/lang-html'),
import(/* webpackChunkName: "email-editor" */ '@codemirror/lint'),
import(/* webpackChunkName: "email-editor" */ '@specious/htmlflow')
]).then(([view, commands, language, html, htmlflow]) => {
]).then(([view, commands, language, html, lint, htmlflow]) => {
return {
minimalSetup: [
commands.history(),
@@ -19,6 +20,8 @@ function loadCodeMirror () {
],
EditorView: view.EditorView,
html: html.html,
htmlLanguage: html.htmlLanguage,
linter: lint.linter,
htmlflow: htmlflow.default || htmlflow
}
})
@@ -46,6 +49,70 @@ export default targetable(class extends HTMLElement {
this.previewViewTab.addEventListener('click', this.showPreviewView)
this.codeViewTab.addEventListener('click', this.showCodeView)
this.form = this.closest('form')
this.form?.addEventListener('submit', this.validateOnSubmit)
}
disconnectedCallback () {
this.form?.removeEventListener('submit', this.validateOnSubmit)
}
validateOnSubmit = (e) => {
if (!this.htmlLanguage) return
const bodyType = this.form.querySelector('input[name$="[body_type]"]:checked')?.value
if (bodyType && bodyType !== 'html') return
const diagnostics = this.buildDiagnostics(this.input.value)
if (diagnostics.length === 0) return
e.preventDefault()
this.showCodeView()
const pos = Math.min(diagnostics[0].from, this.editorView.state.doc.length)
this.editorView.dispatch({ selection: { anchor: pos }, scrollIntoView: true })
this.editorView.focus()
alert(diagnostics[0].message)
}
buildDiagnostics (value) {
const diagnostics = []
if (!value.trim()) return diagnostics
if (!/^\s*(<!doctype[^>]*>\s*)?<html/i.test(value)) {
diagnostics.push({
from: 0,
to: Math.min(5, value.length),
severity: 'error',
message: 'The email template must start with the <html> tag'
})
}
const seen = new Set()
this.htmlLanguage.parser.parse(value).iterate({
enter: (node) => {
if (!node.type.isError || seen.has(node.from) || seen.size >= 20) return
seen.add(node.from)
diagnostics.push({
from: node.from,
to: Math.min(node.to + 1, value.length),
severity: 'error',
message: 'The email template contains invalid HTML'
})
}
})
return diagnostics
}
showCodeView = () => {
@@ -76,7 +143,9 @@ export default targetable(class extends HTMLElement {
this.input = this.querySelector('input[type="hidden"]')
this.input.style.display = 'none'
const { EditorView, minimalSetup, html, htmlflow } = await loadCodeMirror()
const { EditorView, minimalSetup, html, htmlLanguage, linter, htmlflow } = await loadCodeMirror()
this.htmlLanguage = htmlLanguage
this.editorView = new EditorView({
doc: this.input.value,
@@ -85,8 +154,11 @@ export default targetable(class extends HTMLElement {
html(),
minimalSetup,
EditorView.lineWrapping,
linter((view) => this.buildDiagnostics(view.state.doc.toString()), { delay: 600 }),
EditorView.updateListener.of(update => {
if (update.docChanged) this.input.value = update.state.doc.toString()
if (update.docChanged) {
this.input.value = update.state.doc.toString()
}
}),
EditorView.theme({
'&': {
+667
View File
@@ -0,0 +1,667 @@
import { target, targetable } from '@github/catalyst/lib/targetable'
import { actionable } from '@github/catalyst/lib/actionable'
import { LinkTooltip } from './markdown_editor'
async function loadTiptap () {
const [core, document, text, hardBreak, gapcursor, dropcursor, extensions, pmState, pmView] = await Promise.all([
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/core'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-document'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-text'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-hard-break'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-gapcursor'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-dropcursor'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extensions'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/state'),
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/view')
])
return {
Editor: core.Editor,
Extension: core.Extension,
Node: core.Node,
Mark: core.Mark,
Document: document.default || document,
Text: text.default || text,
HardBreak: hardBreak.default || hardBreak,
Gapcursor: gapcursor.default || gapcursor,
Dropcursor: dropcursor.default || dropcursor,
UndoRedo: extensions.UndoRedo,
Plugin: pmState.Plugin,
Decoration: pmView.Decoration,
DecorationSet: pmView.DecorationSet
}
}
const editorStylesheet = new CSSStyleSheet()
editorStylesheet.replaceSync(`
:host {
display: block;
max-height: 360px;
overflow: auto;
border-radius: 0 0 1rem 1rem;
}
.ProseMirror {
word-wrap: break-word;
-webkit-font-variant-ligatures: none;
font-variant-ligatures: none;
font-feature-settings: "liga" 0;
outline: none;
min-height: 220px;
padding: 12px;
}
img.ProseMirror-separator {
display: inline !important;
border: none !important;
margin: 0 !important;
width: 0 !important;
height: 0 !important;
}
.ProseMirror-gapcursor {
display: none;
pointer-events: none;
position: absolute;
margin: 0;
}
.ProseMirror-gapcursor:after {
content: "";
display: block;
position: absolute;
top: -2px;
width: 20px;
border-top: 1px solid black;
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
}
@keyframes ProseMirror-cursor-blink {
to {
visibility: hidden;
}
}
.ProseMirror-hideselection *::selection {
background: transparent;
}
.ProseMirror-hideselection *::-moz-selection {
background: transparent;
}
.ProseMirror-hideselection * {
caret-color: transparent;
}
.ProseMirror-focused .ProseMirror-gapcursor {
display: block;
}
.variable-highlight {
background-color: #fef3c7;
padding: 1px 2px;
border-radius: 4px;
}
`)
const DROP_ATTRS = [
'srcdoc', 'xlink:href', 'srcset', 'action', 'formaction', 'poster',
'background', 'data', 'cite', 'ping', 'longdesc', 'manifest', 'profile'
]
const SAFE_URL_REGEXP = /^(?:https?:\/\/|data:image\/|blob:|mailto:|tel:|\{|#)/i
function isSafeAttr (name, value) {
const lowerName = name.toLowerCase()
if (lowerName.startsWith('on') || DROP_ATTRS.includes(lowerName)) return false
if ((lowerName === 'href' || lowerName === 'src') && !SAFE_URL_REGEXP.test(value.trim())) return false
return true
}
function collectDomAttrs (dom) {
const attrs = {}
for (let i = 0; i < dom.attributes.length; i++) {
const { name, value } = dom.attributes[i]
if (isSafeAttr(name, value)) attrs[name] = value
}
return { htmlAttrs: attrs }
}
function collectSpanDomAttrs (dom) {
const result = collectDomAttrs(dom)
if (result.htmlAttrs.style) {
const temp = document.createElement('span')
temp.style.cssText = result.htmlAttrs.style
if (['bold', '700'].includes(temp.style.fontWeight)) {
temp.style.removeProperty('font-weight')
}
if (temp.style.fontStyle === 'italic') {
temp.style.removeProperty('font-style')
}
if (temp.style.textDecoration === 'underline') {
temp.style.removeProperty('text-decoration')
}
if (temp.style.cssText) {
result.htmlAttrs.style = temp.style.cssText
} else {
delete result.htmlAttrs.style
}
}
return result
}
function buildExtensions ({ Node, Mark, Extension, Plugin, Decoration, DecorationSet }) {
const blockNode = (name, tag, content, extra = {}) => Node.create({
name,
group: 'block',
content: content || 'block+',
...extra,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag, getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return [tag, node.attrs.htmlAttrs, 0]
}
})
const attrsMark = (name, tag) => Mark.create({
name,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag, getAttrs: collectDomAttrs }]
},
renderHTML ({ mark }) {
return [tag, mark.attrs.htmlAttrs, 0]
}
})
const SpanMark = Mark.create({
name: 'span',
excludes: '',
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'span', getAttrs: collectSpanDomAttrs }]
},
renderHTML ({ mark }) {
return ['span', mark.attrs.htmlAttrs, 0]
}
})
const toggleMark = (name, renderTag, parseRules, shortcuts) => Mark.create({
name,
parseHTML () {
return parseRules
},
renderHTML () {
return [renderTag, 0]
},
addCommands () {
const commandName = `toggle${name[0].toUpperCase()}${name.slice(1)}`
return {
[commandName]: () => ({ commands }) => commands.toggleMark(name)
}
},
addKeyboardShortcuts () {
return {
[shortcuts]: () => this.editor.commands.toggleMark(name)
}
}
})
const Heading = Node.create({
name: 'heading',
group: 'block',
content: 'inline*',
addAttributes () {
return {
htmlAttrs: { default: {} },
level: { default: 1 }
}
},
parseHTML () {
return [1, 2, 3, 4, 5, 6].map((level) => ({
tag: `h${level}`,
getAttrs: (dom) => ({ ...collectDomAttrs(dom), level })
}))
},
renderHTML ({ node }) {
return [`h${node.attrs.level}`, node.attrs.htmlAttrs, 0]
}
})
const ImageNode = Node.create({
name: 'image',
inline: true,
group: 'inline',
draggable: true,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'img', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['img', node.attrs.htmlAttrs]
}
})
const HrNode = Node.create({
name: 'horizontalRule',
group: 'block',
atom: true,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'hr', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['hr', node.attrs.htmlAttrs]
}
})
const StyleNode = Node.create({
name: 'style',
group: 'block',
atom: true,
selectable: false,
addAttributes () {
return {
htmlAttrs: { default: {} },
css: { default: '' }
}
},
parseHTML () {
return [{ tag: 'style', getAttrs: (dom) => ({ ...collectDomAttrs(dom), css: dom.textContent }) }]
},
renderHTML ({ node }) {
return ['style', node.attrs.htmlAttrs, node.attrs.css]
}
})
const EmptySpanNode = Node.create({
name: 'emptySpan',
inline: true,
group: 'inline',
atom: true,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{
tag: 'span',
priority: 60,
getAttrs (dom) {
if (dom.childNodes.length === 0 && dom.attributes.length > 0) {
return collectDomAttrs(dom)
}
return false
}
}]
},
renderHTML ({ node }) {
return ['span', node.attrs.htmlAttrs]
}
})
const LinkMark = Mark.create({
name: 'link',
inclusive: true,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'a', getAttrs: collectDomAttrs }]
},
renderHTML ({ mark }) {
return ['a', mark.attrs.htmlAttrs, 0]
},
addCommands () {
return {
setLink: ({ href }) => ({ editor, commands }) => {
const htmlAttrs = { ...(editor.getAttributes('link').htmlAttrs || {}), href }
return commands.setMark('link', { htmlAttrs })
},
unsetLink: () => ({ commands }) => commands.unsetMark('link', { extendEmptyMarkRange: true })
}
}
})
const buildDecorations = (doc) => {
const decorations = []
const regex = /\{\{?[a-zA-Z0-9_.-]+\}\}?/g
doc.descendants((node, pos) => {
if (!node.isText) return
let match
while ((match = regex.exec(node.text)) !== null) {
decorations.push(
Decoration.inline(pos + match.index, pos + match.index + match[0].length, {
class: 'variable-highlight'
})
)
}
})
return DecorationSet.create(doc, decorations)
}
const VariableHighlight = Extension.create({
name: 'variableHighlight',
addProseMirrorPlugins () {
return [new Plugin({
state: {
init (_, { doc }) {
return buildDecorations(doc)
},
apply (tr, oldSet) {
return tr.docChanged ? buildDecorations(tr.doc) : oldSet
}
},
props: {
decorations (state) {
return this.getState(state)
}
}
})]
}
})
return [
blockNode('paragraph', 'p', 'inline*'),
Heading,
blockNode('section', 'section'),
blockNode('article', 'article', null, { isolating: true }),
blockNode('header', 'header', null, { isolating: true }),
blockNode('footer', 'footer', null, { isolating: true }),
blockNode('div', 'div'),
blockNode('center', 'center'),
blockNode('blockquote', 'blockquote'),
blockNode('pre', 'pre'),
blockNode('orderedList', 'ol', '(listItem | block)+'),
blockNode('bulletList', 'ul', '(listItem | block)+'),
blockNode('listItem', 'li', 'block+', { group: null }),
blockNode('table', 'table', '(colgroup | tableHead | tableBody | tableFoot | tableRow)+'),
blockNode('tableHead', 'thead', 'tableRow+', { group: null }),
blockNode('tableBody', 'tbody', 'tableRow+', { group: null }),
blockNode('tableFoot', 'tfoot', 'tableRow+', { group: null }),
blockNode('tableRow', 'tr', '(tableCell | tableHeader)+', { group: null }),
blockNode('tableCell', 'td', 'block*', { group: null }),
blockNode('tableHeader', 'th', 'block*', { group: null }),
blockNode('colgroup', 'colgroup', 'col*', { group: null }),
Node.create({
name: 'col',
atom: true,
addAttributes () {
return { htmlAttrs: { default: {} } }
},
parseHTML () {
return [{ tag: 'col', getAttrs: collectDomAttrs }]
},
renderHTML ({ node }) {
return ['col', node.attrs.htmlAttrs]
}
}),
ImageNode,
HrNode,
StyleNode,
EmptySpanNode,
SpanMark,
LinkMark,
toggleMark('bold', 'strong', [{ tag: 'strong' }, { tag: 'b' }, { style: 'font-weight=bold' }, { style: 'font-weight=700' }], 'Mod-b'),
toggleMark('italic', 'em', [{ tag: 'em' }, { tag: 'i' }, { style: 'font-style=italic' }], 'Mod-i'),
toggleMark('underline', 'u', [{ tag: 'u' }, { style: 'text-decoration=underline' }], 'Mod-u'),
toggleMark('strike', 's', [{ tag: 's' }, { tag: 'del' }, { tag: 'strike' }, { style: 'text-decoration=line-through' }], 'Mod-Shift-s'),
attrsMark('subscript', 'sub'),
attrsMark('superscript', 'sup'),
VariableHighlight
]
}
export default actionable(targetable(class extends HTMLElement {
static [target.static] = [
'textarea',
'editorElement',
'boldButton',
'italicButton',
'underlineButton',
'linkButton',
'linkTooltipTemplate'
]
async connectedCallback () {
if (!this.textarea || !this.editorElement) return
this.textarea.style.display = 'none'
this.adjustShortcutsForPlatform()
const tiptap = await loadTiptap()
const { Editor, Extension, Document, Text, HardBreak, UndoRedo, Gapcursor, Dropcursor } = tiptap
this.emailDocument = new DOMParser().parseFromString(this.textarea.value, 'text/html')
const shadow = this.editorElement.attachShadow({ mode: 'open' })
shadow.adoptedStyleSheets = [editorStylesheet]
this.emailDocument.head.querySelectorAll('style').forEach((style) => {
shadow.appendChild(style.cloneNode(true))
})
const container = document.createElement('div')
const bodyStyle = this.emailDocument.body.getAttribute('style')
if (bodyStyle) container.setAttribute('style', bodyStyle)
shadow.appendChild(container)
const LinkShortcut = Extension.create({
name: 'linkShortcut',
addKeyboardShortcuts: () => ({
'Mod-k': () => {
this.toggleLink()
return true
}
})
})
this.editor = new Editor({
element: container,
extensions: [
Document,
Text,
HardBreak,
UndoRedo,
Gapcursor,
Dropcursor,
...buildExtensions(tiptap),
LinkShortcut
],
content: this.emailDocument.body.innerHTML,
injectCSS: false,
editorProps: {
attributes: {
dir: 'auto'
},
handleDOMEvents: {
click: (_, event) => {
if (event.target.closest('a')) event.preventDefault()
return false
}
}
},
onUpdate: ({ editor }) => {
this.emailDocument.body.innerHTML = editor.getHTML()
this.textarea.value = this.emailDocument.documentElement.outerHTML
this.textarea.dispatchEvent(new Event('input', { bubbles: true }))
},
onSelectionUpdate: ({ editor }) => {
this.updateToolbarState()
this.handleLinkTooltip(editor)
},
onBlur: () => {
setTimeout(() => {
if (!this.linkTooltip.tooltip.contains(document.activeElement)) {
this.linkTooltip.hide()
}
}, 0)
}
})
this.linkTooltip = new LinkTooltip(this, this.editor, this.linkTooltipTemplate)
}
adjustShortcutsForPlatform () {
if ((navigator.userAgentData?.platform)?.toLowerCase()?.includes('mac')) {
this.querySelectorAll('.tooltip[data-tip]').forEach(tooltip => {
const tip = tooltip.getAttribute('data-tip')
if (tip && tip.includes('Ctrl')) {
tooltip.setAttribute('data-tip', tip.replace(/Ctrl/g, '⌘'))
}
})
}
}
bold (e) {
e.preventDefault()
this.editor.chain().focus().toggleBold().run()
this.updateToolbarState()
}
italic (e) {
e.preventDefault()
this.editor.chain().focus().toggleItalic().run()
this.updateToolbarState()
}
underline (e) {
e.preventDefault()
this.editor.chain().focus().toggleUnderline().run()
this.updateToolbarState()
}
linkSelection (e) {
e.preventDefault()
this.toggleLink()
this.updateToolbarState()
}
undo (e) {
e.preventDefault()
this.editor.chain().focus().undo().run()
this.updateToolbarState()
}
redo (e) {
e.preventDefault()
this.editor.chain().focus().redo().run()
this.updateToolbarState()
}
updateToolbarState () {
this.boldButton.classList.toggle('bg-base-200', this.editor.isActive('bold'))
this.italicButton.classList.toggle('bg-base-200', this.editor.isActive('italic'))
this.underlineButton.classList.toggle('bg-base-200', this.editor.isActive('underline'))
this.linkButton.classList.toggle('bg-base-200', this.editor.isActive('link'))
}
handleLinkTooltip (editor) {
const { from } = editor.state.selection
const mark = editor.state.doc.resolve(from).marks().find(m => m.type.name === 'link')
if (!mark) {
if (this.linkTooltip.isVisible()) this.linkTooltip.hide()
return
}
if (this.linkTooltip.isVisible() && this.linkTooltip.currentMark === mark) return
let linkStart = from
const start = editor.state.doc.resolve(from).start()
for (let i = from - 1; i >= start; i--) {
if (editor.state.doc.resolve(i).marks().some(m => m.eq(mark))) {
linkStart = i
} else {
break
}
}
this.linkTooltip.hide()
this.linkTooltip.show(mark.attrs.htmlAttrs?.href, linkStart > start ? linkStart - 1 : linkStart)
this.linkTooltip.currentMark = mark
}
toggleLink () {
if (this.editor.isActive('link')) {
this.linkTooltip.hide()
this.editor.chain().focus().extendMarkRange('link').unsetLink().run()
this.updateToolbarState()
} else {
const { from } = this.editor.state.selection
this.linkTooltip.hide()
this.linkTooltip.show(this.editor.getAttributes('link').htmlAttrs?.href, from, { focus: true })
}
}
insertVariable (e) {
const variable = e.target.closest('[data-variable]')?.dataset.variable
if (variable) {
const { from, to } = this.editor.state.selection
if (variable.includes('link') && from !== to) {
this.editor.chain().focus().setLink({ href: `{${variable}}` }).run()
} else {
this.editor.chain().focus().insertContent(`{${variable}}`).run()
}
}
}
disconnectedCallback () {
this.linkTooltip?.hide()
if (this.editor) {
this.editor.destroy()
}
}
}))
+1 -1
View File
@@ -35,7 +35,7 @@ function loadTiptap () {
}))
}
class LinkTooltip {
export class LinkTooltip {
constructor (container, editor, templateEl) {
this.container = container
this.editor = editor
+10 -18
View File
@@ -1,19 +1,5 @@
export default class extends HTMLElement {
connectedCallback () {
this.input.addEventListener('focus', () => {
if (this.title) {
this.title.classList.add('hidden', 'md:block')
this.input.classList.add('w-60')
}
})
this.input.addEventListener('blur', (e) => {
if (this.title && !e.target.value) {
this.title.classList.remove('hidden')
this.input.classList.remove('w-60')
}
})
this.button.addEventListener('click', (event) => {
if (!this.input.value && document.activeElement !== this.input) {
event.preventDefault()
@@ -21,16 +7,22 @@ export default class extends HTMLElement {
this.input.focus()
}
})
document.addEventListener('turbo:before-cache', this.onBeforeCache)
}
disconnectedCallback () {
document.removeEventListener('turbo:before-cache', this.onBeforeCache)
}
onBeforeCache = () => {
this.input.value = this.input.getAttribute('value') || ''
}
get input () {
return this.querySelector('input')
}
get title () {
return document.querySelector(this.dataset.title)
}
get button () {
return this.querySelector('button')
}
+12 -2
View File
@@ -147,7 +147,7 @@
@submit.prevent="submitStep"
/>
<button
v-if="!isFormVisible"
v-if="!isFormVisible && currentField"
id="expand_form_button"
class="btn btn-neutral flex text-white absolute bottom-0 w-full mb-3 expand-form-button text-base"
style="width: 96%; margin-left: 2%"
@@ -174,6 +174,7 @@
/>
</button>
<div
v-if="currentField"
v-show="isFormVisible"
id="form_container"
class="shadow-md bg-base-100 absolute bottom-0 w-full border-base-200 border p-4 rounded form-container overflow-hidden"
@@ -1172,7 +1173,11 @@ export default {
}
},
isAnonymousChecboxes () {
return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name && !e.required) && this.currentStepFields.length > 4
if (this.currentField) {
return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name && !e.required) && this.currentStepFields.length > 4
} else {
return false
}
},
isButtonDisabled () {
if (this.recalculateButtonDisabledKey) {
@@ -1570,6 +1575,7 @@ export default {
}
},
goToStep (stepIndex, scrollToArea = false, clickUpload = false) {
this.isInvite = false
this.currentStep = stepIndex
this.showFillAllRequiredFields = false
@@ -1594,6 +1600,10 @@ export default {
})
},
saveStep (formData) {
if (!formData && !this.$refs.form) {
return
}
const currentFieldUuids = this.currentStepFields.map((f) => f.uuid)
const currentFieldType = this.currentField.type
@@ -169,7 +169,7 @@
class="base-input !text-2xl w-full mt-6 text-center"
:required="field.required && !isInitialsStarted"
:aria-label="field.name || t('initials')"
:placeholder="`${t('type_initial_here')}...`"
:placeholder="`${t('type_initial_here')}${field.required ? '...' : ' (' + t('optional') + ')'}`"
type="text"
@focus="$emit('focus')"
@input="updateWrittenInitials"
@@ -293,7 +293,7 @@ export default {
if (!this.isDrawInitials) {
this.$nextTick(() => {
if (this.$refs.textInput) {
if (this.$refs.textInput && this.field.required === true) {
this.initTextInitial()
}
})
@@ -247,7 +247,7 @@
class="base-input !text-2xl w-full mt-6"
:required="field.required && !isSignatureStarted"
:aria-label="field.name || t('signature')"
:placeholder="`${t('type_signature_here')}...`"
:placeholder="`${t('type_signature_here')}${field.required ? '...' : ' (' + t('optional') + ')'}`"
type="text"
@input="updateWrittenSignature"
>
@@ -535,7 +535,7 @@ export default {
this.$nextTick(() => this.drawSignatureSrc())
} else if (this.isTextSignature) {
this.$nextTick(() => {
if (this.$refs.textInput) {
if (this.$refs.textInput && this.field.required === true) {
this.initTypedSignature()
}
})
+3 -1
View File
@@ -319,7 +319,7 @@ export default {
default: false
}
},
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to', 'add-custom-field', 'click-title'],
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to', 'add-custom-field', 'click-title', 'multi-select'],
data () {
return {
isContenteditable: false,
@@ -798,6 +798,8 @@ export default {
this.selectedAreasRef.value.splice(this.selectedAreasRef.value.indexOf(this.area), 1)
}
this.$emit('multi-select', e)
return
}
+13 -29
View File
@@ -3206,15 +3206,6 @@ export default {
e.preventDefault()
alert(this.t('please_draw_fields_to_prepare_the_document'))
} else {
const submitterWithoutFields =
this.template.submitters.find((submitter) => !this.template.fields.some((f) => f.submitter_uuid === submitter.uuid))
if (submitterWithoutFields) {
e.preventDefault()
alert(this.t('please_add_fields_for_the_submitter_name_or_remove_the_submitter_name_if_not_needed').replaceAll('{submitter_name}', submitterWithoutFields.name))
}
}
},
onSaveClick () {
@@ -3231,32 +3222,25 @@ export default {
if (!this.template.fields.length) {
alert(this.t('please_draw_fields_to_prepare_the_document'))
} else {
const submitterWithoutFields =
this.template.submitters.find((submitter) => !this.template.fields.some((f) => f.submitter_uuid === submitter.uuid))
this.isSaving = true
if (submitterWithoutFields) {
alert(this.t('please_add_fields_for_the_submitter_name_or_remove_the_submitter_name_if_not_needed').replaceAll('{submitter_name}', submitterWithoutFields.name))
} else {
this.isSaving = true
const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic)
const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic)
dynamicDocumentRefs.map((ref) => ref.update())
dynamicDocumentRefs.map((ref) => ref.update())
this.rebuildVariablesSchema({ disable: false })
this.rebuildVariablesSchema({ disable: false })
const dynamicDocumentSaves = dynamicDocumentRefs.map((ref) => ref.saveBody())
const dynamicDocumentSaves = dynamicDocumentRefs.map((ref) => ref.saveBody())
Promise.all([this.save({ force: true }), ...dynamicDocumentSaves]).then(() => {
if (this.withRevisions) {
this.captureRevision()
}
Promise.all([this.save({ force: true }), ...dynamicDocumentSaves]).then(() => {
if (this.withRevisions) {
this.captureRevision()
}
window.Turbo.visit(`/templates/${this.template.id}`)
}).finally(() => {
this.isSaving = false
})
}
window.Turbo.visit(`/templates/${this.template.id}`)
}).finally(() => {
this.isSaving = false
})
}
},
scrollToArea (area) {
@@ -98,11 +98,29 @@ dynamic-variable {
overflow-wrap: anywhere;
}`)
const DROP_ATTRS = [
'srcdoc', 'xlink:href', 'srcset', 'action', 'formaction', 'poster',
'background', 'data', 'cite', 'ping', 'longdesc', 'manifest', 'profile'
]
const SAFE_URL_REGEXP = /^(?:https?:\/\/|data:image\/|blob:|mailto:|tel:|\{|#)/i
function isSafeAttr (name, value) {
const lowerName = name.toLowerCase()
if (lowerName.startsWith('on') || DROP_ATTRS.includes(lowerName)) return false
if ((lowerName === 'href' || lowerName === 'src') && !SAFE_URL_REGEXP.test(value.trim())) return false
return true
}
function collectDomAttrs (dom) {
const attrs = {}
for (let i = 0; i < dom.attributes.length; i++) {
attrs[dom.attributes[i].name] = dom.attributes[i].value
const { name, value } = dom.attributes[i]
if (isSafeAttr(name, value)) attrs[name] = value
}
return { htmlAttrs: attrs }
@@ -646,9 +646,7 @@ export default {
return
}
const container = document.createElement('div')
container.innerHTML = clipboardHtml
const container = new DOMParser().parseFromString(clipboardHtml, 'text/html').body
const fieldNodes = [...container.querySelectorAll('dynamic-field[data-field][data-area]')]
@@ -830,6 +830,7 @@ export default {
} else if (format === 'percent') {
return `${number}%`
} else if (format === 'percent_space') {
// eslint-disable-next-line no-irregular-whitespace
return `${String(number).replace('.', ',')} %`
} else {
return number
+14
View File
@@ -57,6 +57,7 @@
@scroll-to="$emit('scroll-to', $event)"
@add-custom-field="$emit('add-custom-field', $event)"
@contextmenu="openAreaContextMenu($event, item.area, item.field)"
@multi-select="openMultiSelectContextMenu"
@click-title="closeContextMenu"
/>
<FieldArea
@@ -408,6 +409,12 @@ export default {
return
}
if (this.selectedAreasRef.value.length >= 2) {
this.openSelectionContextMenu(event)
return
}
event.preventDefault()
event.stopPropagation()
@@ -443,6 +450,13 @@ export default {
areas: this.selectedAreasRef.value
}
},
openMultiSelectContextMenu (event) {
if (this.selectedAreasRef.value.length >= 2) {
this.openSelectionContextMenu(event)
} else {
this.closeContextMenu()
}
},
handleSelectionCopy () {
this.$emit('copy-selected-areas')
@@ -9,6 +9,62 @@
@mousedown.stop
@pointerdown.stop
>
<label
v-if="requiredFields.length"
class="field-settings-required w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
@click.stop
>
<input
:checked="isAllRequired"
:indeterminate="isMixedRequired"
type="checkbox"
class="toggle toggle-xs"
:disabled="!editable"
@change="handleToggleRequired($event.target.checked)"
@click.stop
>
<span>{{ t('required') }}</span>
</label>
<label
v-if="readOnlyFields.length"
class="field-settings-read-only w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
@click.stop
>
<input
:checked="isAllReadOnly"
:indeterminate="isMixedReadOnly"
type="checkbox"
class="toggle toggle-xs"
:disabled="!editable"
@change="handleToggleReadOnly($event.target.checked)"
@click.stop
>
<span>{{ t('read_only') }}</span>
</label>
<hr
v-if="requiredFields.length || readOnlyFields.length"
class="my-1 border-neutral-200"
>
<button
v-if="showFont"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="openFontModal"
>
<IconTypography class="w-4 h-4" />
<span>{{ t('font') }}</span>
</button>
<button
v-if="showCondition"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="openConditionModal"
>
<IconRouteAltLeft class="w-4 h-4" />
<span>{{ t('condition') }}</span>
</button>
<hr
v-if="showFont || showCondition"
class="my-1 border-neutral-200"
>
<ContextSubmenu
:icon="IconLayoutAlignMiddle"
:label="t('align')"
@@ -61,26 +117,6 @@
<span>{{ t('height') }}</span>
</button>
</ContextSubmenu>
<hr
v-if="showFont || showCondition"
class="my-1 border-neutral-200"
>
<button
v-if="showFont"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="openFontModal"
>
<IconTypography class="w-4 h-4" />
<span>{{ t('font') }}</span>
</button>
<button
v-if="showCondition"
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
@click.stop="openConditionModal"
>
<IconRouteAltLeft class="w-4 h-4" />
<span>{{ t('condition') }}</span>
</button>
<hr class="my-1 border-neutral-200">
<button
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
@@ -192,6 +228,24 @@ export default {
return this.template.fields.find((f) => f.areas?.includes(area))
}).filter(Boolean)
},
requiredFields () {
return this.selectedFields.filter((f) => !['phone', 'stamp', 'verification', 'strikethrough', 'heading'].includes(f.type))
},
readOnlyFields () {
return this.selectedFields.filter((f) => ['text', 'number', 'radio', 'multiple', 'select'].includes(f.type))
},
isAllRequired () {
return this.requiredFields.every((f) => f.required)
},
isMixedRequired () {
return !this.isAllRequired && this.requiredFields.some((f) => f.required)
},
isAllReadOnly () {
return this.readOnlyFields.every((f) => f.readonly)
},
isMixedReadOnly () {
return !this.isAllReadOnly && this.readOnlyFields.some((f) => f.readonly)
},
isMac () {
return (navigator.userAgentData?.platform || navigator.platform)?.toLowerCase()?.includes('mac')
},
@@ -234,6 +288,16 @@ export default {
}
}
},
handleToggleRequired (value) {
this.requiredFields.forEach((field) => { field.required = value })
this.save()
},
handleToggleReadOnly (value) {
this.readOnlyFields.forEach((field) => { field.readonly = value })
this.save()
},
onKeyDown (event) {
if (event.key === 'Escape') {
event.preventDefault()
@@ -12,6 +12,7 @@ class ProcessSubmissionExpiredJob
return if submission.template&.archived_at?
return if submission.submitters.where.not(declined_at: nil).exists?
return if submission.completed_at?
return if params['expire_at'] && submission.expire_at&.to_i != params['expire_at']
WebhookUrls.enqueue_events(submission, 'submission.expired')
end
+50 -5
View File
@@ -13,7 +13,12 @@ class ProcessSubmitterCompletionJob
if params.key?('is_last')
params['is_last']
else
!submission.submitters.exists?(completed_at: nil) &&
viewer_uuids = submission.template_submitters.to_a.filter_map { |s| s['uuid'] if s['is_viewer'] }
incomplete = submission.submitters.where(completed_at: nil)
incomplete = incomplete.where.not(uuid: viewer_uuids) if viewer_uuids.present?
!incomplete.exists? &&
submitter.completed_at == submission.submitters.maximum(:completed_at)
end
@@ -33,7 +38,9 @@ class ProcessSubmitterCompletionJob
if !submission.completed_at && submission.submitters_order_preserved? && params['send_invitation_email'] != false &&
Submission.exists?(id: submission.id, completed_at: nil)
enqueue_next_submitter_request_notification(submitter)
next_submitters = enqueue_next_submitter_request_notification(submitter)
enqueue_next_submitter_viewer_notification(submission, next_submitters) unless is_last
end
enqueue_completed_webhooks(submitter, is_last:)
@@ -145,7 +152,7 @@ class ProcessSubmitterCompletionJob
return if configs.value['enabled'] == false
to = submitter.submission.submitters.reject { |e| e.preferences['send_email'] == false }
.sort_by(&:completed_at).select(&:email?).map(&:friendly_name)
.sort_by { |e| e.completed_at || Time.current }.select(&:email?).map(&:friendly_name)
return if to.blank?
@@ -165,9 +172,9 @@ class ProcessSubmitterCompletionJob
bcc.to_s.scan(User::EMAIL_REGEXP)
end
def enqueue_next_submitter_request_notification(submitter)
def enqueue_next_submitter_request_notification(submitter) # rubocop:disable Metrics/PerceivedComplexity
submission = submitter.submission
submitters_index = submission.submitters.index_by(&:uuid)
submitters_index = submission.submitters.reject(&:viewer?).index_by(&:uuid)
next_submitter_items =
if submission.template_submitters.any? { |s| s['order'] }
@@ -196,5 +203,43 @@ class ProcessSubmitterCompletionJob
next_submitters = submitters_index.values_at(*Array.wrap(next_submitter_items).pluck('uuid')).compact
Submitters.send_signature_requests(next_submitters)
next_submitters
end
def enqueue_next_submitter_viewer_notification(submission, next_submitters)
viewers = submission.submitters.select(&:viewer?)
return [] if viewers.blank?
next_submitter_uuids = next_submitters.to_set(&:uuid)
viewers_index = viewers.index_by(&:uuid)
next_viewers =
if submission.template_submitters.any? { |s| s['order'] }
next_orders = submission.template_submitters
.select { |s| next_submitter_uuids.include?(s['uuid']) }
.pluck('order')
submission.template_submitters.filter_map do |s|
viewers_index[s['uuid']] if next_orders.include?(s['order'])
end
else
preceding_submitter_uuid = nil
submission.template_submitters.filter_map do |template_submitter|
viewer = viewers_index[template_submitter['uuid']]
if viewer
viewer if next_submitter_uuids.include?(preceding_submitter_uuid)
else
preceding_submitter_uuid = template_submitter['uuid']
nil
end
end
end
Submitters.send_signature_requests(next_viewers)
end
end
@@ -19,7 +19,12 @@ class SendSubmitterInvitationEmailJob
return
end
mail = SubmitterMailer.invitation_email(submitter)
mail =
if submitter.viewer?
SubmitterMailer.invitation_view_email(submitter)
else
SubmitterMailer.invitation_email(submitter)
end
Submitters::ValidateSending.call(submitter, mail)
+50 -5
View File
@@ -45,6 +45,45 @@ class SubmitterMailer < ApplicationMailer
end
end
def invitation_view_email(submitter)
@current_account = submitter.submission.account
@submitter = submitter
if submitter.preferences['email_message_uuid']
@email_message = submitter.account.email_messages.find_by(uuid: submitter.preferences['email_message_uuid'])
end
template_submitters_index = @email_message.blank? ? build_submitter_preferences_index(@submitter) : {}
@body = @email_message&.normalized_body.presence ||
@submitter.template&.preferences&.dig('invitation_view_email_body').presence ||
template_submitters_index.dig(@submitter.uuid, 'request_email_body').presence
@subject = @email_message&.subject.presence ||
@submitter.template&.preferences&.dig('invitation_view_email_subject').presence ||
template_submitters_index.dig(@submitter.uuid, 'request_email_subject').presence
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_VIEW_INVITATION_EMAIL_KEY)
@body ||= fetch_config_email_body(@email_config, @submitter)
assign_message_metadata('submitter_view_invitation', @submitter)
reply_to = build_submitter_reply_to(@submitter, email_config: @email_config)
maybe_set_custom_domain(@submitter)
I18n.with_locale(@current_account.locale) do
subject = build_invite_subject(@subject, @email_config, submitter)
mail(
to: @submitter.friendly_name,
from: from_address_for_submitter(submitter),
subject:,
reply_to:
)
end
end
def completed_email(submitter, user, to: nil)
@current_account = submitter.submission.account
@submitter = submitter
@@ -53,8 +92,6 @@ class SubmitterMailer < ApplicationMailer
template_preferences = @submission.template&.preferences || {}
Submissions::EnsureResultGenerated.call(submitter)
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY)
add_completed_email_attachments!(
@@ -109,8 +146,6 @@ class SubmitterMailer < ApplicationMailer
template_preferences = @submitter.template&.preferences || {}
Submissions::EnsureResultGenerated.call(@submitter)
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY)
add_completed_email_attachments!(
@@ -172,7 +207,7 @@ class SubmitterMailer < ApplicationMailer
end
def add_completed_email_attachments!(submitter, with_audit_log: true, with_documents: true)
documents = with_documents ? Submitters.select_attachments_for_download(submitter) : []
documents = with_documents ? select_completed_documents(submitter) : []
filename_format = AccountConfig.find_or_initialize_by(account_id: submitter.account_id,
key: AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY)&.value
@@ -216,6 +251,8 @@ class SubmitterMailer < ApplicationMailer
def build_invite_subject(subject, email_config, submitter)
if email_config || subject
ReplaceEmailVariables.call(subject || email_config.value['subject'], submitter:)
elsif submitter.viewer?
I18n.t(:you_are_invited_to_view_a_document)
elsif submitter.with_signature_fields?
I18n.t(:you_are_invited_to_sign_a_document)
else
@@ -227,6 +264,14 @@ class SubmitterMailer < ApplicationMailer
submitter.template&.preferences&.dig('submitters').to_a.index_by { |e| e['uuid'] }
end
def select_completed_documents(submitter)
last_submitter = Submitter.where(submission_id: submitter.submission_id).completed.order(:completed_at).last
Submissions::EnsureResultGenerated.call(last_submitter)
Submitters.select_attachments_for_download(last_submitter)
end
def add_attachments_with_size_limit(submitter, storage_attachments, current_size, filename_format = nil)
total_size = current_size
+10
View File
@@ -22,6 +22,7 @@
#
class AccountConfig < ApplicationRecord
SUBMITTER_INVITATION_EMAIL_KEY = 'submitter_invitation_email'
SUBMITTER_VIEW_INVITATION_EMAIL_KEY = 'submitter_view_invitation_email'
SUBMITTER_INVITATION_REMINDER_EMAIL_KEY = 'submitter_invitation_reminder_email'
SUBMITTER_COMPLETED_EMAIL_KEY = 'submitter_completed_email'
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY = 'submitter_documents_copy_email'
@@ -47,6 +48,7 @@ class AccountConfig < ApplicationRecord
WITH_SIGNATURE_ID = 'with_signature_id'
WITH_FILE_LINKS_KEY = 'with_file_links'
WITH_SIGNATURE_ID_REASON_KEY = 'with_signature_id_reason'
WITH_SIGNATURE_ID_COMPLETED_AT_KEY = 'with_signature_id_completed_at'
RECIPIENT_FORM_FIELDS_KEY = 'recipient_form_fields'
WITH_AUDIT_VALUES_KEY = 'with_audit_values'
WITH_AUDIT_SENDER_KEY = 'with_audit_sender'
@@ -58,11 +60,13 @@ class AccountConfig < ApplicationRecord
COMBINE_PDF_RESULT_KEY = 'combine_pdf_result_key'
DOCUMENT_FILENAME_FORMAT_KEY = 'document_filename_format'
TEMPLATE_CUSTOM_FIELDS_KEY = 'template_custom_fields'
TEMPLATE_DATE_FORMATS_KEY = 'template_date_formats'
POLICY_LINKS_KEY = 'policy_links'
ENABLE_MCP_KEY = 'enable_mcp'
EMAIL_VARIABLES = {
SUBMITTER_INVITATION_EMAIL_KEY => %w[template.name submitter.link account.name].freeze,
SUBMITTER_VIEW_INVITATION_EMAIL_KEY => %w[template.name submitter.link account.name].freeze,
SUBMITTER_COMPLETED_EMAIL_KEY => %w[template.name submission.submitters submission.link].freeze,
SUBMITTER_INVITATION_REMINDER_EMAIL_KEY => %w[template.name submitter.link account.name].freeze,
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => %w[template.name documents.link account.name].freeze
@@ -75,6 +79,12 @@ class AccountConfig < ApplicationRecord
'body' => I18n.t(:submitter_invitation_email_sign_body)
}
},
SUBMITTER_VIEW_INVITATION_EMAIL_KEY => lambda {
{
'subject' => I18n.t(:you_are_invited_to_view_a_document),
'body' => I18n.t(:submitter_invitation_email_view_body)
}
},
SUBMITTER_INVITATION_REMINDER_EMAIL_KEY => lambda {
{
'subject' => I18n.t(:you_are_invited_to_sign_a_document),
+3 -3
View File
@@ -31,6 +31,7 @@
# index_submissions_on_account_id_and_id_pending (account_id,id) WHERE ((completed_at IS NULL) AND (archived_at IS NULL))
# index_submissions_on_account_id_and_template_id_and_id (account_id,template_id,id) WHERE (archived_at IS NULL)
# index_submissions_on_account_id_and_template_id_and_id_archived (account_id,template_id,id) WHERE (archived_at IS NOT NULL)
# index_submissions_on_created_at (created_at)
# index_submissions_on_created_by_user_id (created_by_user_id)
# index_submissions_on_slug (slug) UNIQUE
# index_submissions_on_template_id (template_id)
@@ -91,9 +92,8 @@ class Submission < ApplicationRecord
scope :active, -> { where(archived_at: nil) }
scope :archived, -> { where.not(archived_at: nil) }
scope :pending, lambda {
where(expire_at: nil).or(where(expire_at: Time.current..)).where(completed_at: nil)
}
scope :non_expired, -> { where(expire_at: nil).or(where(expire_at: Time.current..)) }
scope :pending, -> { non_expired.where(completed_at: nil) }
scope :completed, -> { where.not(completed_at: nil) }
scope :declined, lambda {
where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id]))
+6
View File
@@ -116,6 +116,12 @@ class Submitter < ApplicationRecord
end
end
def viewer?
return false if submission.template_submitters.blank?
submission.template_submitters.any? { |s| s['uuid'] == uuid && s['is_viewer'] }
end
private
def anonymize_email_events
@@ -57,6 +57,11 @@
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %>
</div>
<% end %>
<% if Docuseal.multitenant? && @encrypted_config.persisted? %>
<div class="flex justify-center pt-2">
<%= button_to t('reset_default'), settings_email_path(@encrypted_config), method: :delete, class: 'link', data: { turbo_confirm: t('are_you_sure_') } %>
</div>
<% end %>
</div>
<div class="w-0 md:w-52"></div>
</div>
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
<path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2" />
<path d="M9 15l2 2l4 -4" />
</svg>

After

Width:  |  Height:  |  Size: 437 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
<path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2" />
<path d="M12 17v.01" />
<path d="M12 14a1.5 1.5 0 1 0 -1.14 -2.474" />
</svg>

After

Width:  |  Height:  |  Size: 482 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M8 9h8" />
<path d="M8 13h6" />
<path d="M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12" />
</svg>

After

Width:  |  Height:  |  Size: 431 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M16 7h4" />
<path d="M16 16v1l2 2l.5 -.5m1.5 -2.5v-11c0 -1.121 -.879 -2 -2 -2s-2 .879 -2 2v7" />
<path d="M18 19h-13a2 2 0 1 1 0 -4h4a2 2 0 1 0 0 -4h-3" />
<path d="M3 3l18 18" />
</svg>

After

Width:  |  Height:  |  Size: 480 B

@@ -0,0 +1,53 @@
<div class="flex items-center px-2 py-2 border-b" style="height: 42px;">
<div class="flex items-center gap-1">
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('bold') %> (Ctrl+B)">
<button type="button" data-action="click:<%= editor_tag %>#bold" data-target="<%= editor_tag %>.boldButton" aria-label="<%= t('bold') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
<%= svg_icon('bold', class: 'w-4 h-4') %>
</button>
</div>
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('italic') %> (Ctrl+I)">
<button type="button" data-action="click:<%= editor_tag %>#italic" data-target="<%= editor_tag %>.italicButton" aria-label="<%= t('italic') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
<%= svg_icon('italic', class: 'w-4 h-4') %>
</button>
</div>
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('underline') %> (Ctrl+U)">
<button type="button" data-action="click:<%= editor_tag %>#underline" data-target="<%= editor_tag %>.underlineButton" aria-label="<%= t('underline') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
<%= svg_icon('underline', class: 'w-4 h-4') %>
</button>
</div>
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('link') %> (Ctrl+K)">
<button type="button" data-action="click:<%= editor_tag %>#linkSelection" data-target="<%= editor_tag %>.linkButton" aria-label="<%= t('link') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
<%= svg_icon('link', class: 'w-4 h-4') %>
</button>
</div>
</div>
<div class="mx-2 h-5 border-l border-base-content/20"></div>
<div class="flex items-center gap-1">
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('undo') %> (Ctrl+Z)">
<button type="button" data-action="click:<%= editor_tag %>#undo" data-target="<%= editor_tag %>.undoButton" aria-label="<%= t('undo') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
<%= svg_icon('arrow_back_up', class: 'w-4 h-4') %>
</button>
</div>
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('redo') %> (Ctrl+Shift+Z)">
<button type="button" data-action="click:<%= editor_tag %>#redo" data-target="<%= editor_tag %>.redoButton" aria-label="<%= t('redo') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
<%= svg_icon('arrow_forward_up', class: 'w-4 h-4') %>
</button>
</div>
</div>
<% if local_assigns[:variables]&.any? %>
<% variable_labels = { 'account.name' => t('variables.account_name'), 'submitter.link' => t('variables.submitter_link'), 'template.name' => t('variables.template_name'), 'submission.submitters' => t('variables.submission_submitters'), 'submission.link' => t('variables.submission_link'), 'documents.link' => t('variables.documents_link') } %>
<div class="dropdown dropdown-end ml-auto">
<label tabindex="0" class="flex items-center gap-1 text-sm px-2 py-1 rounded hover:bg-base-200 cursor-pointer">
<%= t('add_variable') %>
<%= svg_icon('chevron_down', class: 'w-3.5 h-3.5') %>
</label>
<div tabindex="0" class="dropdown-content right-0 top-full mt-1 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50">
<% local_assigns[:variables]&.each do |variable| %>
<button type="button" data-variable="<%= variable %>" data-action="click:<%= editor_tag %>#insertVariable" class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 text-left text-sm cursor-pointer whitespace-nowrap">
<%= variable_labels.fetch(variable, "{#{variable}}") %>
</button>
<% end %>
</div>
</div>
<% end %>
</div>
@@ -0,0 +1 @@
<%= render 'personalization_settings/markdown_editor', name:, value:, variables: local_assigns[:variables] %>
@@ -1,76 +1,18 @@
<% if value.to_s.start_with?('<html') %>
<autoresize-textarea>
<%= text_area_tag name, value, required: true, class: 'base-input w-full py-2 !rounded-2xl', dir: 'auto', style: 'max-height: 400px' %>
</autoresize-textarea>
<% else %>
<markdown-editor>
<template data-target="markdown-editor.linkTooltipTemplate">
<div class="hidden absolute flex bg-white border border-base-300 rounded-xl shadow p-1 gap-1 items-center z-50" contenteditable="false">
<input type="text" placeholder="<%= t('enter_a_url_or_variable_name') %>" class="rounded-lg border border-base-300 px-2 py-1 text-sm outline-none" style="field-sizing: content; min-width: 205px; max-width: 320px;" autocomplete="off">
<button type="button" data-role="link-save" class="flex items-center px-1 w-6 h-6 rounded hover:bg-success/10 cursor-pointer">
<%= svg_icon('check', class: 'w-4 h-4 text-success') %>
</button>
<button type="button" data-role="link-remove" class="flex items-center px-1 w-6 h-6 rounded hover:bg-error/10 cursor-pointer">
<%= svg_icon('x', class: 'w-4 h-4 text-error') %>
</button>
</div>
</template>
<div class="border border-base-content/20 rounded-2xl bg-white">
<div class="flex items-center px-2 py-2 border-b" style="height: 42px;">
<div class="flex items-center gap-1">
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('bold') %> (Ctrl+B)">
<button type="button" data-action="click:markdown-editor#bold" data-target="markdown-editor.boldButton" aria-label="<%= t('bold') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
<%= svg_icon('bold', class: 'w-4 h-4') %>
</button>
</div>
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('italic') %> (Ctrl+I)">
<button type="button" data-action="click:markdown-editor#italic" data-target="markdown-editor.italicButton" aria-label="<%= t('italic') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
<%= svg_icon('italic', class: 'w-4 h-4') %>
</button>
</div>
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('underline') %> (Ctrl+U)">
<button type="button" data-action="click:markdown-editor#underline" data-target="markdown-editor.underlineButton" aria-label="<%= t('underline') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
<%= svg_icon('underline', class: 'w-4 h-4') %>
</button>
</div>
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('link') %> (Ctrl+K)">
<button type="button" data-action="click:markdown-editor#linkSelection" data-target="markdown-editor.linkButton" aria-label="<%= t('link') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
<%= svg_icon('link', class: 'w-4 h-4') %>
</button>
</div>
</div>
<div class="mx-2 h-5 border-l border-base-content/20"></div>
<div class="flex items-center gap-1">
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('undo') %> (Ctrl+Z)">
<button type="button" data-action="click:markdown-editor#undo" data-target="markdown-editor.undoButton" aria-label="<%= t('undo') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
<%= svg_icon('arrow_back_up', class: 'w-4 h-4') %>
</button>
</div>
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('redo') %> (Ctrl+Shift+Z)">
<button type="button" data-action="click:markdown-editor#redo" data-target="markdown-editor.redoButton" aria-label="<%= t('redo') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
<%= svg_icon('arrow_forward_up', class: 'w-4 h-4') %>
</button>
</div>
</div>
<% if local_assigns[:variables]&.any? %>
<% variable_labels = { 'account.name' => t('variables.account_name'), 'submitter.link' => t('variables.submitter_link'), 'template.name' => t('variables.template_name'), 'submission.submitters' => t('variables.submission_submitters'), 'submission.link' => t('variables.submission_link'), 'documents.link' => t('variables.documents_link') } %>
<div class="dropdown dropdown-end ml-auto">
<label tabindex="0" class="flex items-center gap-1 text-sm px-2 py-1 rounded hover:bg-base-200 cursor-pointer">
<%= t('add_variable') %>
<%= svg_icon('chevron_down', class: 'w-3.5 h-3.5') %>
</label>
<div tabindex="0" class="dropdown-content right-0 top-full mt-1 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50">
<% local_assigns[:variables]&.each do |variable| %>
<button type="button" data-variable="<%= variable %>" data-action="click:markdown-editor#insertVariable" class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 text-left text-sm cursor-pointer whitespace-nowrap">
<%= variable_labels.fetch(variable, "{#{variable}}") %>
</button>
<% end %>
</div>
</div>
<% end %>
</div>
<div data-target="markdown-editor.editorElement"></div>
<markdown-editor>
<template data-target="markdown-editor.linkTooltipTemplate">
<div class="hidden absolute flex bg-white border border-base-300 rounded-xl shadow p-1 gap-1 items-center z-50" contenteditable="false">
<input type="text" placeholder="<%= t('enter_a_url_or_variable_name') %>" class="rounded-lg border border-base-300 px-2 py-1 text-sm outline-none" style="field-sizing: content; min-width: 205px; max-width: 320px;" autocomplete="off">
<button type="button" data-role="link-save" class="flex items-center px-1 w-6 h-6 rounded hover:bg-success/10 cursor-pointer">
<%= svg_icon('check', class: 'w-4 h-4 text-success') %>
</button>
<button type="button" data-role="link-remove" class="flex items-center px-1 w-6 h-6 rounded hover:bg-error/10 cursor-pointer">
<%= svg_icon('x', class: 'w-4 h-4 text-error') %>
</button>
</div>
<%= hidden_field_tag name, value, required: true, data: { target: 'markdown-editor.textarea' } %>
</markdown-editor>
<% end %>
</template>
<div class="border border-base-content/20 rounded-2xl bg-white">
<%= render 'personalization_settings/editor_toolbar', editor_tag: 'markdown-editor', variables: local_assigns[:variables] %>
<div data-target="markdown-editor.editorElement"></div>
</div>
<%= hidden_field_tag name, value, required: true, data: { target: 'markdown-editor.textarea' } %>
</markdown-editor>
+1 -1
View File
@@ -1,6 +1,6 @@
<a target="_blank" href="<%= Docuseal::GITHUB_URL %>" rel="noopener noreferrer nofollow" class="relative flex items-center rounded-full px-2 py-0.5 text-xs leading-4 mt-1 text-base-content border border-base-300 tooltip tooltip-bottom" data-tip="Give a star on GitHub">
<span class="flex items-center justify-between space-x-0.5 font-medium">
<%= svg_icon('start', class: 'h-3 w-3') %>
<span>17k</span>
<span>18k</span>
</span>
</a>
+1 -1
View File
@@ -75,7 +75,7 @@
<%= svg_icon('logout', class: 'w-5 h-5 flex-shrink-0 stroke-2 inline') %>
<span class="whitespace-nowrap"><%= t('sign_out') %></span>
</button>
<%= button_to '', destroy_user_session_path, method: :delete, data: { turbo: false }, form: { id: 'destroy_user_session_form' }, form_class: 'hidden' %>
<%= button_to '', destroy_user_session_path, method: :delete, form: { id: 'destroy_user_session_form', data: { turbo: false } }, form_class: 'hidden' %>
</li>
</ul>
</div>
+3 -3
View File
@@ -1,4 +1,4 @@
<form action="<%= url_for %>" method="get" class="items-center flex">
<form action="<%= url_for %>" method="get" class="items-center flex max-md:focus-within:flex-1 <%= 'max-md:flex-1' if params[:q].present? %>">
<% Submissions::Filter::ALLOWED_PARAMS.each do |key| %>
<% if params[key].present? %>
<input name="<%= key %>" value="<%= params[key] %>" class="hidden">
@@ -14,8 +14,8 @@
</a>
</div>
<% end %>
<search-input data-title="<%= local_assigns[:title_selector] || 'h1' %>" class="flex items-center">
<input id="search" name="q" value="<%= params[:q] %>" class="input text-lg pr-10 -mr-12 w-0 md:w-60 <%= 'pl-8 input-outlined w-60' if params[:q].present? %>" placeholder="<%= local_assigns[:placeholder] %>">
<search-input class="group flex items-center max-md:focus-within:flex-1 <%= 'max-md:flex-1' if params[:q].present? %>">
<input id="search" name="q" value="<%= params[:q] %>" enterkeyhint="search" class="input text-lg pr-10 -mr-12 w-0 md:w-60 max-md:group-focus-within:w-full <%= 'pl-8 input-outlined max-md:w-full' if params[:q].present? %>" placeholder="<%= local_assigns[:placeholder] %>">
<button type="submit" title="<%= t('search') %>" class="btn btn-ghost btn-circle">
<span class="enabled">
<%= svg_icon('search', class: 'w-6 h-6 stroke-2') %>
+6 -6
View File
@@ -12,12 +12,12 @@
<li>
<%= link_to t('account'), settings_account_path, class: 'text-base hover:bg-base-300' %>
</li>
<% if (!Docuseal.multitenant? || EncryptedConfig.exists?(key: EncryptedConfig::EMAIL_SMTP_KEY, account: current_account)) && can?(:read, EncryptedConfig.new(key: EncryptedConfig::EMAIL_SMTP_KEY, account: current_account)) && ENV['SMTP_ADDRESS'].blank? && true_user == current_user %>
<li>
<%= link_to t('email'), settings_email_index_path, class: 'text-base hover:bg-base-300' %>
</li>
<% end %>
<% unless Docuseal.multitenant? %>
<% if can?(:read, EncryptedConfig.new(key: EncryptedConfig::EMAIL_SMTP_KEY, account: current_account)) && ENV['SMTP_ADDRESS'].blank? && true_user == current_user %>
<li>
<%= link_to t('email'), settings_email_index_path, class: 'text-base hover:bg-base-300' %>
</li>
<% end %>
<% if can?(:read, EncryptedConfig.new(key: EncryptedConfig::FILES_STORAGE_KEY, account: current_account)) && true_user == current_user && ENV['S3_ATTACHMENTS_BUCKET'].blank? && ENV['GCS_BUCKET'].blank? && ENV['AZURE_CONTAINER'].blank? %>
<li>
<%= link_to t('storage'), settings_storage_index_path, class: 'text-base hover:bg-base-300' %>
@@ -132,7 +132,7 @@
<%= capture do %>
<div class="tooltip" data-tip="<%= t('ai_assistant') %>">
<a href="<%= Docuseal::CHATGPT_URL %>" target="_blank" class="btn btn-circle btn-primary btn-md">
<%= svg_icon('brand_openai', class: 'w-8 h-8') %>
<%= svg_icon('message', class: 'w-8 h-8') %>
</a>
</div>
<% end %>
@@ -92,7 +92,7 @@
<%= local_assigns[:variables_form] %>
<div>
<%= render('submitters_order', f:, template:) if can_send_emails %>
<%= render 'send_email', f:, template:, can_send_emails: %>
<%= render 'send_email', f:, template:, can_send_emails:, viewer_submitter_uuids: local_assigns[:viewer_submitter_uuids] %>
<% if has_phone_field %>
<%= render 'send_sms', f: %>
<% end %>
+1 -1
View File
@@ -70,7 +70,7 @@
<%= local_assigns[:variables_form] %>
<div>
<%= render('submitters_order', f:, template:) if can_send_emails %>
<%= render 'send_email', f:, template:, can_send_emails: %>
<%= render 'send_email', f:, template:, can_send_emails:, viewer_submitter_uuids: local_assigns[:viewer_submitter_uuids] %>
<%= render 'extra_fields', f: %>
</div>
<div class="form-control">
+26 -11
View File
@@ -38,26 +38,38 @@
<% end %>
</div>
<% config = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY) %>
<% view_config = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_VIEW_INVITATION_EMAIL_KEY) %>
<% config_body = (config.value['body_type'] == 'html' && config.value['html_body'].presence) || config.value['body'] %>
<% view_template_subject = template&.preferences&.dig('invitation_view_email_subject').presence %>
<% view_template_body = template&.preferences&.dig('invitation_view_email_body').presence %>
<% default_subject = template&.preferences&.dig('request_email_subject').presence || config.value['subject'] %>
<% default_body = template&.preferences&.dig('request_email_body').presence || config_body %>
<% is_edit_viewer = local_assigns[:submitter] && local_assigns[:viewer_submitter_uuids].include?(local_assigns[:submitter].uuid) %>
<div id="<%= message_field_id %>" class="card card-compact bg-base-300/40 hidden">
<div class="card-body">
<%= tag.input id: toggle_uuid = SecureRandom.uuid, value: '1', name: 'request_email_per_submitter', class: 'peer', type: 'checkbox', hidden: true, checked: local_assigns[:message_per_submitter] != false && template&.preferences&.dig('submitters').to_a.size > 1 %>
<%= tag.input id: toggle_uuid = SecureRandom.uuid, value: '1', name: 'request_email_per_submitter', class: 'peer', type: 'checkbox', hidden: true, checked: local_assigns[:message_per_submitter] != false && template_submitters.size < 11 && template&.preferences&.dig('submitters').to_a.size > 1 %>
<% if local_assigns[:viewer_submitter_uuids].present? && local_assigns[:submitter].blank? %>
<% (template_submitters.pluck('uuid') - local_assigns[:viewer_submitter_uuids].to_a).each do |signer_uuid| %>
<%= hidden_field_tag 'email_message_submitter_uuids[]', signer_uuid %>
<% end %>
<% end %>
<div class="peer-checked:hidden form-control space-y-2">
<div class="form-control">
<div class="flex justify-between">
<%= f.label :subject, t('subject'), class: 'label' %>
<% if template_submitters.size > 1 && template_submitters.size < 5 && local_assigns[:message_per_submitter] != false %>
<% if template_submitters.size > 1 && template_submitters.size < 11 && local_assigns[:message_per_submitter] != false %>
<label for="<%= toggle_uuid %>" class="label underline">
<%= t('edit_per_party') %>
</label>
<% end %>
</div>
<%= f.text_field :subject, value: local_assigns[:submitter_email_message]&.subject.presence || submitter_preferences_index.dig(local_assigns[:submitter]&.uuid, 'request_email_subject').presence || template&.preferences&.dig('request_email_subject').presence || config.value['subject'], required: true, class: '!text-sm base-input w-full', dir: 'auto' %>
<%= f.text_field :subject, value: local_assigns[:submitter_email_message]&.subject.presence || (is_edit_viewer ? view_template_subject : nil) || submitter_preferences_index.dig(local_assigns[:submitter]&.uuid, 'request_email_subject').presence || (is_edit_viewer ? view_config.value['subject'] : default_subject), required: true, class: '!text-sm base-input w-full', dir: 'auto' %>
</div>
<div class="form-control">
<%= f.label :message, t('body'), class: 'label' %>
<% body_variables = AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY] %>
<%= render 'personalization_settings/markdown_editor', name: f.field_name(:body), value: local_assigns[:submitter_email_message]&.normalized_body.presence || submitter_preferences_index.dig(local_assigns[:submitter]&.uuid, 'request_email_body').presence || template&.preferences&.dig('request_email_body').presence || config.value['body'], variables: body_variables %>
<% unless local_assigns.fetch(:disable_save_as_default_template_option, false) %>
<%= render 'personalization_settings/email_body_editor', name: f.field_name(:body), value: local_assigns[:submitter_email_message]&.normalized_body.presence || (is_edit_viewer ? view_template_body : nil) || submitter_preferences_index.dig(local_assigns[:submitter]&.uuid, 'request_email_body').presence || (is_edit_viewer ? view_config.value['body'] : default_body), variables: body_variables %>
<% if !local_assigns.fetch(:disable_save_as_default_template_option, false) && config.value['body_type'] != 'html' %>
<label for="<%= uuid = SecureRandom.uuid %>" class="flex items-center cursor-pointer">
<%= check_box_tag :save_message, id: uuid, class: 'base-checkbox', checked: false %>
<span class="label"><%= t('save_as_default_template_message') %></span>
@@ -66,32 +78,35 @@
</div>
<%= render 'submissions/message_fields' %>
</div>
<% if template_submitters.size > 1 && template_submitters.size < 5 && local_assigns[:message_per_submitter] != false %>
<% if template_submitters.size > 1 && template_submitters.size < 11 && local_assigns[:message_per_submitter] != false %>
<div class="hidden peer-checked:block form-control space-y-2">
<% uuid = SecureRandom.uuid %>
<% options = template_submitters.map { |e| [e['name'], "request_email_#{uuid}_#{e['uuid']}"] } %>
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="flex relative px-1">
<ul class="tabs w-full flex flex-nowrap">
<ul class="tabs w-full min-w-0 flex flex-nowrap">
<% options.each_with_index do |(label, val), index| %>
<div class="w-full">
<div class="w-full min-w-0 has-[:checked]:min-w-fit">
<%= f.radio_button :selected, val, checked: index.zero?, id: "#{val}_radio", data: { action: 'click:toggle-visible#trigger' }, class: 'hidden peer' %>
<%= f.label :selected, label, value: val, for: "#{val}_radio", class: 'tab w-full tab-lifted peer-checked:tab-active !bg-transparent' %>
<%= f.label :selected, value: val, for: "#{val}_radio", class: 'tab w-full tab-lifted peer-checked:tab-active !bg-transparent !px-2' do %>
<span class="truncate" title="<%= label %>"><%= label %></span>
<% end %>
</div>
<% end %>
</ul>
</toggle-visible>
<% template_submitters.each_with_index do |submitter, index| %>
<% is_viewer = local_assigns[:viewer_submitter_uuids].include?(submitter['uuid']) %>
<%= fields_for :submitter_preferences, nil, index: submitter['uuid'] do |ff| %>
<div id="request_email_<%= uuid %>_<%= submitter['uuid'] %>" class="<%= 'hidden' if index != 0 %>">
<div class="form-control">
<div class="flex justify-between">
<%= ff.label :subject, t('subject'), class: 'label' %>
</div>
<%= ff.text_field :subject, value: local_assigns[:submitter_email_message]&.subject.presence || submitter_preferences_index.dig(submitter['uuid'], 'request_email_subject').presence || template&.preferences&.dig('request_email_subject').presence || config.value['subject'], required: true, class: '!text-sm base-input w-full', dir: 'auto' %>
<%= ff.text_field :subject, value: local_assigns[:submitter_email_message]&.subject.presence || (is_viewer ? view_template_subject : nil) || submitter_preferences_index.dig(submitter['uuid'], 'request_email_subject').presence || (is_viewer ? view_config.value['subject'] : default_subject), required: true, class: '!text-sm base-input w-full', dir: 'auto' %>
</div>
<div class="form-control">
<%= ff.label :message, t('body'), class: 'label' %>
<%= render 'personalization_settings/markdown_editor', name: ff.field_name(:body), value: local_assigns[:submitter_email_message]&.normalized_body.presence || submitter_preferences_index.dig(submitter['uuid'], 'request_email_body').presence || template&.preferences&.dig('request_email_body').presence || config.value['body'], variables: body_variables %>
<%= render 'personalization_settings/email_body_editor', name: ff.field_name(:body), value: local_assigns[:submitter_email_message]&.normalized_body.presence || (is_viewer ? view_template_body : nil) || submitter_preferences_index.dig(submitter['uuid'], 'request_email_body').presence || (is_viewer ? view_config.value['body'] : default_body), variables: body_variables %>
</div>
</div>
<% end %>
+2 -1
View File
@@ -29,7 +29,8 @@
<div>
<% timezone = local_assigns[:with_submitter_timezone] ? (submitter.timezone || local_assigns[:timezone]) : local_assigns[:timezone] %>
<% time_format = local_assigns[:with_timestamp_seconds] ? :detailed : :long %>
<%= l(attachment.created_at.in_time_zone(timezone), format: time_format, locale: local_assigns[:locale]) %> <%= TimeUtils.timezone_abbr(timezone, attachment.created_at) %>
<% signature_timestamp = (local_assigns[:with_signature_id_completed_at] ? submitter.completed_at : nil) || attachment.created_at %>
<%= l(signature_timestamp.in_time_zone(timezone), format: time_format, locale: local_assigns[:locale]) %> <%= TimeUtils.timezone_abbr(timezone, signature_timestamp) %>
</div>
</div>
<% end %>
+4 -3
View File
@@ -1,6 +1,7 @@
<% require_phone_2fa = @template.preferences['require_phone_2fa'] == true %>
<% require_email_2fa = @template.preferences['require_email_2fa'] == true %>
<% prefillable_fields = @template.fields.select { |f| f['prefillable'] } %>
<% viewer_submitter_uuids = Set.new(@template.submitters.pluck('uuid')) - Set.new(@template.fields.to_a.pluck('submitter_uuid')) %>
<% default_tab = cookies.permanent[:add_recipients_tab].presence || 'email' %>
<% recipient_form_fields = Accounts.load_recipient_form_fields(current_account) if prefillable_fields.blank? %>
<% can_send_emails = Accounts.can_send_emails?(current_account) %>
@@ -26,18 +27,18 @@
<div class="px-5 mb-5 mt-4">
<% unless only_detailed %>
<div id="email" class="<%= 'hidden' if default_tab != 'email' %>">
<%= render 'email_form', template: @template, variables_form:, can_send_emails: %>
<%= render 'email_form', template: @template, variables_form:, can_send_emails:, viewer_submitter_uuids: %>
</div>
<div id="phone" class="<%= 'hidden' if default_tab != 'phone' %>">
<%= render 'phone_form', template: @template, variables_form: %>
</div>
<% end %>
<div id="detailed" class="<%= 'hidden' if !only_detailed && default_tab != 'detailed' %>">
<%= render 'detailed_form', template: @template, require_phone_2fa:, require_email_2fa:, prefillable_fields:, recipient_form_fields:, variables_form:, can_send_emails: %>
<%= render 'detailed_form', template: @template, require_phone_2fa:, require_email_2fa:, prefillable_fields:, recipient_form_fields:, variables_form:, can_send_emails:, viewer_submitter_uuids: %>
</div>
<% if with_list %>
<div id="list" class="hidden">
<%= render 'list_form', template: @template, can_send_emails: %>
<%= render 'list_form', template: @template, can_send_emails:, viewer_submitter_uuids: %>
</div>
<% end %>
<%= render 'submissions/error' %>
+22 -7
View File
@@ -2,12 +2,13 @@
<%= render 'submissions/preview_tags' %>
<% end %>
<% font_scale = 1040.0 / PdfUtils::US_LETTER_W %>
<% configs = AccountConfig.where(account_id: @submission.account_id, key: [AccountConfig::COMBINE_PDF_RESULT_KEY, AccountConfig::WITH_SIGNATURE_ID, AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY, AccountConfig::WITH_SIGNATURE_ID_REASON_KEY, AccountConfig::WITH_TIMESTAMP_SECONDS_KEY]) %>
<% configs = AccountConfig.where(account_id: @submission.account_id, key: [AccountConfig::COMBINE_PDF_RESULT_KEY, AccountConfig::WITH_SIGNATURE_ID, AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY, AccountConfig::WITH_SIGNATURE_ID_REASON_KEY, AccountConfig::WITH_TIMESTAMP_SECONDS_KEY, AccountConfig::WITH_SIGNATURE_ID_COMPLETED_AT_KEY]) %>
<% with_signature_id = configs.find { |e| e.key == AccountConfig::WITH_SIGNATURE_ID }&.value == true %>
<% is_combined_enabled = configs.find { |e| e.key == AccountConfig::COMBINE_PDF_RESULT_KEY }&.value == true && !@submission.template_fields&.any? { |f| f['type'] == 'verification' } %>
<% with_submitter_timezone = configs.find { |e| e.key == AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY }&.value == true %>
<% with_timestamp_seconds = configs.find { |e| e.key == AccountConfig::WITH_TIMESTAMP_SECONDS_KEY }&.value == true %>
<% with_signature_id_reason = configs.find { |e| e.key == AccountConfig::WITH_SIGNATURE_ID_REASON_KEY }&.value != false %>
<% with_signature_id_completed_at = configs.find { |e| e.key == AccountConfig::WITH_SIGNATURE_ID_COMPLETED_AT_KEY }&.value == true %>
<main style="max-width: 1600px" class="mx-auto pl-4">
<div class="flex justify-between py-1.5 items-center pr-4 sticky top-0 md:relative z-10 bg-base-100">
<a href="<%= signed_in? && @submission.account_id == current_account&.id && @submission.template ? template_path(@submission.template) : '/' %>" class="flex items-center space-x-3 py-1">
@@ -144,7 +145,7 @@
</span>
</span>
<% else %>
<%= render 'submissions/value', page_width: width, page_height: height, font_scale:, area:, field:, attachments_index:, value: mask.present? ? Array.wrap(value).map { |e| TextUtils.mask_value(e, mask) }.join(', ') : value, locale: @submission.account.locale, timezone: @submission.account.timezone, submitter:, with_signature_id:, with_submitter_timezone:, with_timestamp_seconds:, with_signature_id_reason: %>
<%= render 'submissions/value', page_width: width, page_height: height, font_scale:, area:, field:, attachments_index:, value: mask.present? ? Array.wrap(value).map { |e| TextUtils.mask_value(e, mask) }.join(', ') : value, locale: @submission.account.locale, timezone: @submission.account.timezone, submitter:, with_signature_id:, with_submitter_timezone:, with_timestamp_seconds:, with_signature_id_reason:, with_signature_id_completed_at: %>
<% end %>
<% elsif field['readonly'] != true && submitter && !submitter.completed_at? %>
<% submitters_order_index ||= (@submission.template_submitters || @submission.template.submitters).each_with_index.to_h { |s, i| [s['uuid'], i] } %>
@@ -178,7 +179,7 @@
<%= (@submission.template_submitters || @submission.template.submitters).find { |e| e['uuid'] == submitter&.uuid }&.dig('name') || "#{(index + 1).ordinalize} Submitter" %>
</span>
</div>
<% if signed_in? && can?(:update, @submission) && submitter && !submitter.completed_at? && !submitter.declined_at? && !@submission.archived_at? && !@submission.template&.archived_at? && !@submission.expired? && !submitter.start_form_submission_events.any? %>
<% if signed_in? && can?(:update, @submission) && submitter && !submitter.completed_at? && !@submission.completed_at? && !submitter.declined_at? && !@submission.archived_at? && !@submission.template&.archived_at? && !@submission.expired? && !submitter.start_form_submission_events.any? %>
<span class="tooltip tooltip-left" data-tip="<%= t('edit') %>">
<%= link_to edit_submitter_path(submitter), class: 'shrink-0 inline md:hidden md:group-hover:inline', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('pencil', class: 'w-5 h-5') %>
@@ -211,7 +212,11 @@
</div>
<% end %>
<div class="flex items-center space-x-1 mt-1">
<% if @submission.expire_at? && submitter && !submitter.completed_at? %>
<% if submitter&.viewer? %>
<%= svg_icon(submitter.opened_at? ? 'file_check' : 'file_unknown', class: 'w-5 h-5') %>
<% elsif submitter && !submitter.completed_at? && @submission.completed_at? %>
<%= svg_icon('writing_off', class: 'w-5 h-5') %>
<% elsif @submission.expire_at? && submitter && !submitter.completed_at? %>
<%= svg_icon('clock_exclamation', class: 'w-5 h-5') %>
<% else %>
<%= svg_icon('writing', class: 'w-5 h-5') %>
@@ -219,9 +224,19 @@
<span>
<% if submitter&.declined_at? %>
<%= t('declined_on_time', time: l(submitter.declined_at.in_time_zone(@submission.account.timezone), format: :short, locale: @submission.account.locale)) %>
<% elsif submitter&.viewer? %>
<% if submitter.opened_at? %>
<%= t('viewed_on_time', time: l(submitter.opened_at.in_time_zone(@submission.account.timezone), format: :short, locale: @submission.account.locale)) %>
<% elsif @submission.completed_at? %>
<%= t('not_viewed') %>
<% else %>
<%= t('not_viewed_yet') %>
<% end %>
<% elsif submitter %>
<% if submitter.completed_at? %>
<%= l(submitter.completed_at.in_time_zone(@submission.account.timezone), format: :long, locale: @submission.account.locale) %>
<% elsif @submission.completed_at? %>
<%= t('not_completed') %>
<% elsif @submission.expire_at? %>
<% if @submission.expired? %>
<%= t(:expired) %>
@@ -244,15 +259,15 @@
</span>
</div>
<% end %>
<% if signed_in? && submitter && submitter.email && !submitter.completed_at && !@submission.archived_at? && !@submission.template&.archived_at? && can?(:update, @submission) && (Docuseal.multitenant? || Accounts.can_send_emails?(current_account)) && !@submission.expired? && !submitter.declined_at? %>
<% if signed_in? && submitter && submitter.email && !submitter.completed_at && !@submission.completed_at? && !@submission.archived_at? && !@submission.template&.archived_at? && can?(:update, @submission) && (Docuseal.multitenant? || Accounts.can_send_emails?(current_account)) && !@submission.expired? && !submitter.declined_at? %>
<div class="mt-2 mb-1">
<%= button_to button_title(title: submitter.sent_at? ? t('re_send_email') : t('send_email'), disabled_with: t('sending')), submitter_send_email_index_path(submitter), class: 'btn btn-sm btn-primary w-full' %>
</div>
<% end %>
<% if signed_in? && submitter && submitter.phone && !submitter.completed_at && !@submission.archived_at? && !@submission.template&.archived_at? && can?(:update, @submission) && !@submission.expired? && !submitter.declined_at? %>
<% if signed_in? && submitter && submitter.phone && !submitter.completed_at && !@submission.completed_at? && !@submission.archived_at? && !@submission.template&.archived_at? && can?(:update, @submission) && !@submission.expired? && !submitter.declined_at? %>
<%= render 'submissions/send_sms_button', submitter: %>
<% end %>
<% if signed_in? && submitter && !submitter.completed_at? && !@submission.archived_at? && !@submission.template&.archived_at? && can?(:create, @submission) && !@submission.expired? && !submitter.declined_at? %>
<% if signed_in? && submitter && !submitter.viewer? && !submitter.completed_at? && !@submission.completed_at? && !@submission.archived_at? && !@submission.template&.archived_at? && can?(:create, @submission) && !@submission.expired? && !submitter.declined_at? %>
<div class="mt-2 mb-1">
<a class="btn btn-sm btn-primary w-full" target="_blank" href="<%= submit_form_path(slug: submitter.slug) %>">
<%= t('sign_in_person') %>
@@ -1,16 +1,16 @@
<% filter_params = params.permit(Submissions::Filter::ALLOWED_PARAMS).compact_blank %>
<div>
<%= link_to root_path, class: 'flex items-center' do %>
<%= link_to root_path, class: 'flex items-center mb-1 md:mb-0' do %>
<%= svg_icon('chevron_left', class: 'w-5 h-5') %>
<span style="margin-left: 3px"><%= t('back_to_active') %></span>
<% end %>
</div>
<div class="flex flex-col md:flex-row md:items-center mb-4 gap-3">
<div class="flex w-full justify-between items-center">
<div>
<div class="group/header flex w-full justify-between items-center">
<div class="max-md:group-has-[search-input:focus-within]/header:hidden">
<h1 class="text-2xl md:text-3xl font-bold md:block <%= 'hidden' if params[:q].present? %>"><%= t('submissions') %> <span class="badge badge-outline badge-lg align-middle"><%= t('archived') %></span></h1>
</div>
<div>
<div class="max-md:has-[search-input:focus-within]:grow <%= 'max-md:grow' if params[:q].present? %>">
<% if params[:q].present? || @pagy.pages > 1 || filter_params.present? %>
<%= render 'shared/search_input', placeholder: "#{t('search')}..." %>
<% end %>
@@ -1,8 +1,8 @@
<% filter_params = params.permit(Submissions::Filter::ALLOWED_PARAMS).compact_blank %>
<% is_show_tabs = (@pagy.count.nil? || @pagy.count >= 5) || params[:status].present? || filter_params.present? %>
<% if Docuseal.demo? %><%= render 'shared/demo_alert' %><% end %>
<div class="flex justify-between items-center w-full mb-4">
<div class="flex items-center flex-grow min-w-0">
<div class="group/header flex justify-between items-center w-full mb-4">
<div class="flex items-center flex-grow min-w-0 max-md:group-has-[search-input:focus-within]/header:hidden <%= 'max-md:hidden' if params[:q].present? %>">
<div class="mr-2">
<%= render 'dashboard/toggle_view', selected: 'submissions' %>
</div>
@@ -10,7 +10,7 @@
<%= t('submissions') %>
</h1>
</div>
<div class="flex space-x-2">
<div class="flex space-x-2 max-md:has-[search-input:focus-within]:grow <%= 'max-md:grow' if params[:q].present? %>">
<% if params[:q].present? || @pagy.pages > 1 || filter_params.present? %>
<%= render 'shared/search_input' %>
<% end %>
@@ -1,40 +1,40 @@
<% query_params = params.permit(:q).merge(filter_params) %>
<% if icon = { 'declined' => 'x_circle', 'expired' => 'clock_cancel', 'partially_completed' => 'clock_edit', 'sent' => 'send', 'opened' => 'mail_opened' }[params[:status]] %>
<div class="flex h-10 px-2 py-1 text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl w-full md:w-34 border-neutral-700">
<%= link_to submissions_filter_path('status', query_params.merge(path: url_for, with_remove: true)), data: { turbo_frame: 'modal' }, class: 'flex items-center space-x-1 w-full pr-1 md:max-w-[140px]' do %>
<div class="flex h-10 px-2 py-1 text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl w-full md:w-36 border-neutral-700">
<%= link_to submissions_filter_path('status', query_params.merge(path: url_for, with_remove: true)), data: { turbo_frame: 'modal' }, class: 'flex items-center space-x-1 flex-1 min-w-0 pr-1' do %>
<%= svg_icon(icon, class: 'w-5 h-5 shrink-0') %>
<span class="font-normal truncate"><%= t(params[:status]) %></span>
<% end %>
<%= link_to url_for(params: request.query_parameters.except('status')), class: 'rounded-lg ml-1 hover:bg-base-content hover:text-white' do %>
<%= link_to url_for(params: request.query_parameters.except('status')), class: 'rounded-lg ml-1 shrink-0 hover:bg-base-content hover:text-white' do %>
<%= svg_icon('x', class: 'w-5 h-5') %>
<% end %>
</div>
<% end %>
<% if params[:folder].present? %>
<div class="tooltip tooltip-bottom flex h-10 px-2 py-1 text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl w-full md:w-34 border-neutral-700" data-tip="<%= t('folder') %>">
<%= link_to submissions_filter_path('folder', query_params.merge(path: url_for, with_remove: true)), data: { turbo_frame: 'modal' }, class: 'flex items-center space-x-1 w-full pr-1 md:max-w-[140px]' do %>
<div class="tooltip tooltip-bottom flex h-10 px-2 py-1 text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl w-full md:w-36 border-neutral-700" data-tip="<%= t('folder') %>">
<%= link_to submissions_filter_path('folder', query_params.merge(path: url_for, with_remove: true)), data: { turbo_frame: 'modal' }, class: 'flex items-center space-x-1 flex-1 min-w-0 pr-1' do %>
<%= svg_icon('folder', class: 'w-5 h-5 shrink-0') %>
<span class="font-normal truncate"><%= params[:folder] %></span>
<% end %>
<%= link_to url_for(params: request.query_parameters.except('folder')), class: 'rounded-lg ml-1 hover:bg-base-content hover:text-white' do %>
<%= link_to url_for(params: request.query_parameters.except('folder')), class: 'rounded-lg ml-1 shrink-0 hover:bg-base-content hover:text-white' do %>
<%= svg_icon('x', class: 'w-5 h-5') %>
<% end %>
</div>
<% end %>
<% if params[:author].present? %>
<div class="tooltip tooltip-bottom flex h-10 px-2 py-1 text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl w-full md:w-34 border-neutral-700" data-tip="<%= t('author') %>">
<%= link_to submissions_filter_path('author', query_params.merge(path: url_for, with_remove: true)), data: { turbo_frame: 'modal' }, class: 'flex items-center space-x-1 w-full pr-1 md:max-w-[140px]' do %>
<div class="tooltip tooltip-bottom flex h-10 px-2 py-1 text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl w-full md:w-36 border-neutral-700" data-tip="<%= t('author') %>">
<%= link_to submissions_filter_path('author', query_params.merge(path: url_for, with_remove: true)), data: { turbo_frame: 'modal' }, class: 'flex items-center space-x-1 flex-1 min-w-0 pr-1' do %>
<%= svg_icon('user', class: 'w-5 h-5 shrink-0') %>
<span class="font-normal truncate"><%= current_account.users.accessible_by(current_ability).where(account: current_account).find_by(email: params[:author])&.full_name || 'NA' %></span>
<% end %>
<%= link_to url_for(params: request.query_parameters.except('author')), class: 'rounded-lg ml-1 hover:bg-base-content hover:text-white' do %>
<%= link_to url_for(params: request.query_parameters.except('author')), class: 'rounded-lg ml-1 shrink-0 hover:bg-base-content hover:text-white' do %>
<%= svg_icon('x', class: 'w-5 h-5') %>
<% end %>
</div>
<% end %>
<% if query_params[:completed_at_from].present? || query_params[:completed_at_to].present? %>
<div class="tooltip tooltip-bottom flex h-10 px-2 py-1 text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl w-full md:w-34 border-neutral-700" data-tip="<%= t('completed_at') %>">
<%= link_to submissions_filter_path('completed_at', query_params.merge(path: url_for, with_remove: true)), data: { turbo_frame: 'modal' }, class: 'flex items-center space-x-1 w-full pr-1 md:max-w-[140px]' do %>
<div class="tooltip tooltip-bottom flex h-10 px-2 py-1 text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl w-full md:w-36 border-neutral-700" data-tip="<%= t('completed_at') %>">
<%= link_to submissions_filter_path('completed_at', query_params.merge(path: url_for, with_remove: true)), data: { turbo_frame: 'modal' }, class: 'flex items-center space-x-1 flex-1 min-w-0 pr-1' do %>
<%= svg_icon('calendar_check', class: 'w-5 h-5 shrink-0') %>
<span class="flex flex-row md:flex-col font-normal text-left md:text-center md:text-xs">
<% if query_params[:completed_at_from] == query_params[:completed_at_to] %>
@@ -46,14 +46,14 @@
<% end %>
</span>
<% end %>
<%= link_to url_for(params: request.query_parameters.except('completed_at_from', 'completed_at_to')), class: 'rounded-lg ml-1 hover:bg-base-content hover:text-white' do %>
<%= link_to url_for(params: request.query_parameters.except('completed_at_from', 'completed_at_to')), class: 'rounded-lg ml-1 shrink-0 hover:bg-base-content hover:text-white' do %>
<%= svg_icon('x', class: 'w-5 h-5') %>
<% end %>
</div>
<% end %>
<% if query_params[:created_at_from].present? || query_params[:created_at_to].present? %>
<div class="tooltip tooltip-bottom flex h-10 px-2 py-1 text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl w-full md:w-34 border-neutral-700" data-tip="<%= t('created_at') %>">
<%= link_to submissions_filter_path('created_at', query_params.merge(path: url_for, with_remove: true)), data: { turbo_frame: 'modal' }, class: 'flex items-center space-x-1 w-full pr-1 md:max-w-[140px]' do %>
<div class="tooltip tooltip-bottom flex h-10 px-2 py-1 text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl w-full md:w-36 border-neutral-700" data-tip="<%= t('created_at') %>">
<%= link_to submissions_filter_path('created_at', query_params.merge(path: url_for, with_remove: true)), data: { turbo_frame: 'modal' }, class: 'flex items-center space-x-1 flex-1 min-w-0 pr-1' do %>
<%= svg_icon('calendar', class: 'w-5 h-5 shrink-0') %>
<span class="flex flex-row md:flex-col font-normal text-left md:text-center md:text-xs">
<% if query_params[:created_at_from] == query_params[:created_at_to] %>
@@ -65,7 +65,7 @@
<% end %>
</span>
<% end %>
<%= link_to url_for(params: request.query_parameters.except('created_at_to', 'created_at_from')), class: 'rounded-lg ml-1 hover:bg-base-content hover:text-white' do %>
<%= link_to url_for(params: request.query_parameters.except('created_at_to', 'created_at_from')), class: 'rounded-lg ml-1 shrink-0 hover:bg-base-content hover:text-white' do %>
<%= svg_icon('x', class: 'w-5 h-5') %>
<% end %>
</div>
+3 -2
View File
@@ -1,3 +1,4 @@
<% completed_at = @submitter.completed_at || @submitter.submission.completed_at %>
<main class="max-w-md mx-auto px-2 mt-12 mb-4">
<div class="space-y-6 mx-auto">
<div class="space-y-6">
@@ -12,7 +13,7 @@
<div>
<p dir="auto" class="text-lg font-bold mb-1"><%= @submitter.submission.name || @submitter.submission.template&.name %></p>
<p dir="auto" class="text-sm">
<%= t(@submitter.with_signature_fields? ? 'signed_on_time' : 'completed_on_time', time: l(@submitter.completed_at.to_date, format: :long)) %>
<%= t(@submitter.with_signature_fields? ? 'signed_on_time' : 'completed_on_time', time: l(completed_at.to_date, format: :long)) %>
</p>
</div>
</div>
@@ -29,7 +30,7 @@
<div class="py-2"></div>
<% end %>
<% end %>
<% if @submitter.completed_at > 30.minutes.ago || (current_user && current_user.account.submitters.exists?(id: @submitter.id)) %>
<% if completed_at > 30.minutes.ago || (current_user && current_user.account.submitters.exists?(id: @submitter.id)) %>
<download-button role="button" tabindex="0" aria-label="<%= t('download_documents') %>" data-src="<%= submit_form_documents_path(@submitter.slug) %>" class="base-button w-full">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6') %>
+11 -11
View File
@@ -23,7 +23,7 @@
<%= @submitter.submission.name || @submitter.submission.template&.name %>
</h1>
<div class="flex items-center gap-2 group" style="margin-left: 20px; flex-shrink: 0">
<% if @form_configs[:with_decline] %>
<% if @form_configs[:with_decline] && !@submitter.viewer? %>
<modal-button data-target="<%= decline_modal_id = SecureRandom.uuid %>" class="hidden group-has-[.complete-button]:flex">
<button type="button" class="btn btn-sm md:!px-5 px-2" aria-label="<%= t(:decline) %>">
<span class="hidden md:inline"><%= t(:decline) %></span>
@@ -34,11 +34,11 @@
</modal-button>
<% end %>
<span id="complete_button_container" class="peer contents"></span>
<% if @form_configs[:with_delegate] %>
<% if @form_configs[:with_delegate] && !@submitter.viewer? %>
<modal-button data-target="<%= delegate_modal_id = SecureRandom.uuid %>" class="hidden peer-empty:flex">
<button id="delegate_button" type="button" class="btn btn-sm !px-5"><%= t(:delegate) %></button>
</modal-button>
<% if @form_configs[:with_decline] %>
<% if @form_configs[:with_decline] && !@submitter.viewer? %>
<modal-button data-target="<%= decline_modal_id %>" class="hidden peer-empty:flex">
<button id="decline_button" type="button" class="btn btn-sm px-2" aria-label="<%= t(:decline) %>">
<%= svg_icon('x', class: 'w-5 h-5') %>
@@ -56,7 +56,7 @@
</download-button>
<% end %>
<% else %>
<% if @form_configs[:with_decline] %>
<% if @form_configs[:with_decline] && !@submitter.viewer? %>
<modal-button data-target="<%= decline_modal_id %>" class="hidden peer-empty:flex">
<button id="decline_button" type="button" class="btn btn-sm !px-5"><%= t(:decline) %></button>
</modal-button>
@@ -77,7 +77,7 @@
</div>
</header>
<scroll-buttons inert class="fixed right-5 top-2 hidden md:flex gap-1 z-50 ease-in-out opacity-0 -translate-y-10 group">
<% if @form_configs[:with_decline] %>
<% if @form_configs[:with_decline] && !@submitter.viewer? %>
<modal-button data-target="<%= decline_modal_id %>" class="hidden group-has-[.complete-button]:flex">
<button type="button" class="btn btn-sm px-2" aria-label="<%= t(:decline) %>">
<%= svg_icon('x', class: 'w-5 h-5') %>
@@ -85,7 +85,7 @@
</modal-button>
<% end %>
<span id="complete_button_container_scroll" class="peer contents"></span>
<% if @form_configs[:with_delegate] %>
<% if @form_configs[:with_delegate] && !@submitter.viewer? %>
<modal-button data-target="<%= delegate_modal_id %>" class="hidden peer-empty:flex">
<button id="delegate_button_mobile" type="button" class="btn btn-sm px-0" aria-label="<%= t(:delegate) %>">
<span class="min-[1366px]:inline hidden px-3">
@@ -96,7 +96,7 @@
</span>
</button>
</modal-button>
<% if @form_configs[:with_decline] %>
<% if @form_configs[:with_decline] && !@submitter.viewer? %>
<modal-button data-target="<%= decline_modal_id %>" class="hidden peer-empty:flex">
<button id="decline_button_mobile" type="button" class="btn btn-sm px-2" aria-label="<%= t(:decline) %>">
<%= svg_icon('x', class: 'w-5 h-5') %>
@@ -104,7 +104,7 @@
</modal-button>
<% end %>
<% else %>
<% if @form_configs[:with_decline] %>
<% if @form_configs[:with_decline] && !@submitter.viewer? %>
<modal-button data-target="<%= decline_modal_id %>" class="hidden peer-empty:flex">
<button id="decline_button_mobile" type="button" class="btn btn-sm px-0" aria-label="<%= t(:decline) %>">
<span class="min-[1366px]:inline hidden px-3">
@@ -152,7 +152,7 @@
<% next if field['conditions'].present? && values[field['uuid']].blank? && field['submitter_uuid'] != @submitter.uuid %>
<% next if field['conditions'].present? && field['submitter_uuid'] == @submitter.uuid %>
<% next if field.dig('preferences', 'formula').present? && field['submitter_uuid'] == @submitter.uuid %>
<%= render 'submissions/value', page_width: width, page_height: height, font_scale:, area:, field:, attachments_index: @attachments_index, value: field.dig('preferences', 'mask').present? ? TextUtils.mask_value(value, field.dig('preferences', 'mask')) : value, locale: @submitter.account.locale, timezone: @submitter.account.timezone, submitter: submitters_index[field['submitter_uuid']], with_signature_id: @form_configs[:with_signature_id], with_submitter_timezone: @form_configs[:with_submitter_timezone], with_timestamp_seconds: @form_configs[:with_timestamp_seconds], with_signature_id_reason: @form_configs[:with_signature_id_reason] %>
<%= render 'submissions/value', page_width: width, page_height: height, font_scale:, area:, field:, attachments_index: @attachments_index, value: field.dig('preferences', 'mask').present? ? TextUtils.mask_value(value, field.dig('preferences', 'mask')) : value, locale: @submitter.account.locale, timezone: @submitter.account.timezone, submitter: submitters_index[field['submitter_uuid']], with_signature_id: @form_configs[:with_signature_id], with_submitter_timezone: @form_configs[:with_submitter_timezone], with_timestamp_seconds: @form_configs[:with_timestamp_seconds], with_signature_id_reason: @form_configs[:with_signature_id_reason], with_signature_id_completed_at: @form_configs[:with_signature_id_completed_at] %>
<% end %>
</div>
</page-container>
@@ -177,12 +177,12 @@
</div>
</div>
</div>
<% if @form_configs[:with_decline] %>
<% if @form_configs[:with_decline] && !@submitter.viewer? %>
<%= render 'shared/html_modal', title: t(:decline), uuid: decline_modal_id do %>
<%= render 'submit_form/decline_form', submitter: @submitter %>
<% end %>
<% end %>
<% if @form_configs[:with_delegate] %>
<% if @form_configs[:with_delegate] && !@submitter.viewer? %>
<%= render 'shared/html_modal', title: t(:delegate), uuid: delegate_modal_id do %>
<%= render 'submit_form/delegate_form', submitter: @submitter %>
<% end %>
@@ -0,0 +1,14 @@
<% if @body.present? %>
<%= render 'custom_content', content: @body, submitter: @submitter %>
<% if !@body.match?(ReplaceEmailVariables::SUBMITTER_LINK) && !@body.match?(ReplaceEmailVariables::SUBMITTER_ID) && !@body.match?(ReplaceEmailVariables::SUBMISSION_LINK) && !@body.match?(ReplaceEmailVariables::TEMPLATE_ID) && !@submitter.submission.source.in?(%w[api embed]) %>
<p><%= link_to nil, submit_form_url(slug: @submitter.slug, t: SubmissionEvents.build_tracking_param(@submitter, 'click_email'), host: @custom_domain || ENV.fetch('EMAIL_HOST', Docuseal.default_url_options[:host])) %></p>
<% end %>
<% else %>
<p><%= t('hi_there') %>,</p>
<p><%= I18n.t(:you_have_been_invited_to_view_the_name, name: @submitter.submission.name || @submitter.submission.template&.name) %></p>
<p><%= link_to I18n.t(:view_document), submit_form_url(slug: @submitter.slug, t: SubmissionEvents.build_tracking_param(@submitter, 'click_email'), host: @custom_domain || ENV.fetch('EMAIL_HOST', Docuseal.default_url_options[:host])) %></p>
<p><%= t('please_contact_us_by_replying_to_this_email_if_you_have_any_questions') %></p>
<p>
<%= t('thanks') %>,<br><%= @current_account.name %>
</p>
<% end %>
+2 -1
View File
@@ -17,7 +17,8 @@
</submitter-item>
</div>
<div>
<%= render 'submissions/send_email', f:, template: @submitter.template, submitter: @submitter, resend_email: @submitter.sent_at?, submitter_email_message: @submitter_email_message, disable_save_as_default_template_option: true, message_per_submitter: false, can_send_emails: Accounts.can_send_emails?(current_account) %>
<% viewer_submitter_uuids = Set.new((@submitter.submission.template_submitters || @submitter.template.submitters).pluck('uuid')) - Set.new((@submitter.submission.template_fields || @submitter.template.fields).to_a.pluck('submitter_uuid')) %>
<%= render 'submissions/send_email', f:, template: @submitter.template, submitter: @submitter, resend_email: @submitter.sent_at?, submitter_email_message: @submitter_email_message, disable_save_as_default_template_option: true, message_per_submitter: false, can_send_emails: Accounts.can_send_emails?(current_account), viewer_submitter_uuids: %>
<%= render 'submissions/send_sms', f:, resend_sms: @submitter.sent_at? %>
</div>
<div class="form-control mt-4">
+3 -3
View File
@@ -5,7 +5,7 @@
<% end %>
</div>
<dashboard-dropzone>
<div class="relative flex justify-between items-center w-full mb-4">
<div class="group/header relative flex justify-between items-center w-full mb-4">
<%= form_for '', url: '', id: form_id = SecureRandom.uuid, method: :post, class: 'hidden', data: { target: 'dashboard-dropzone.form' }, html: { enctype: 'multipart/form-data' } do %>
<input name="form_id" value="<%= form_id %>">
<input name="folder_name" value="<%= @template_folder.full_name %>">
@@ -16,7 +16,7 @@
<% unless @template_folder.parent_folder %>
<%= render 'templates/dashboard_folder_dropzone', style: 'height: 137px' %>
<% end %>
<h1 class="text-2xl truncate md:text-3xl font-bold flex items-center flex-grow min-w-0 space-x-2 md:flex <%= 'hidden' if params[:q].present? %>">
<h1 class="text-2xl truncate md:text-3xl font-bold flex items-center flex-grow min-w-0 space-x-2 md:flex max-md:group-has-[search-input:focus-within]/header:hidden <%= 'hidden' if params[:q].present? %>">
<%= svg_icon('folder', class: 'w-9 h-9 flex-shrink-0') %>
<span class="peer truncate">
<%= @template_folder.name %>
@@ -29,7 +29,7 @@
</span>
<% end %>
</h1>
<div class="flex space-x-2">
<div class="flex space-x-2 max-md:has-[search-input:focus-within]:grow <%= 'max-md:grow' if params[:q].present? %>">
<% if params[:q].present? || @pagy.count.nil? || @pagy.count > 0 || @template_folders.present? %>
<%= render 'shared/search_input' %>
<% end %>
+2 -2
View File
@@ -51,7 +51,7 @@
<a href="<%= submission_path(submission) %>" class="text-lg break-all peer">
<%= submitter.name || submitter.email || submitter.phone %>
</a>
<% if !submitter.completed_at? && can?(:update, submission) && !submitter.start_form_submission_events.any? && !submission.archived_at? && !submission.expired? && !submitter.declined_at? %>
<% if !submitter.completed_at? && !submission.completed_at? && can?(:update, submission) && !submitter.start_form_submission_events.any? && !submission.archived_at? && !submission.expired? && !submitter.declined_at? %>
<span class="pl-0.5 tooltip tooltip-top md:opacity-0 md:hover:opacity-100 md:peer-hover:opacity-100" data-tip="<%= t('edit') %>">
<%= link_to edit_submitter_path(submitter), class: 'shrink-0', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('pencil', class: 'w-5 h-5') %>
@@ -144,7 +144,7 @@
<a href="<%= submission_path(submission) %>" class="text-lg break-all peer">
<%= submitter.name || submitter.email || submitter.phone %>
</a>
<% if !submitter.completed_at? && can?(:update, submission) && !submitter.start_form_submission_events.any? && !submission.archived_at? && !submission.expired? && !submitter.declined_at? %>
<% if !submitter.completed_at? && !submission.completed_at? && can?(:update, submission) && !submitter.start_form_submission_events.any? && !submission.archived_at? && !submission.expired? && !submitter.declined_at? %>
<span class="pl-0.5 tooltip tooltip-top md:opacity-0 md:hover:opacity-100 md:peer-hover:opacity-100" data-tip="<%= t('edit') %>">
<%= link_to edit_submitter_path(submitter), class: 'shrink-0', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('pencil', class: 'w-5 h-5') %>
+4 -1
View File
@@ -6,4 +6,7 @@
<%= button_to nil, user_configs_path, method: :post, params: { user_config: { key: UserConfig::SHOW_APP_TOUR, value: true } }, class: 'hidden', id: 'start_tour_button' %>
<% end %>
<% end %>
<template-builder class="grid" data-template="<%= @template_data.to_json %>" data-custom-fields="<%= (current_account.account_configs.find_or_initialize_by(key: AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY).value || []).to_json %>" data-with-sign-yourself-button="<%= !@template.archived_at? %>" data-with-fields-detection="true" data-with-send-button="<%= !@template.archived_at? && can?(:create, @template.submissions.new(account: current_account)) %>" data-with-revisions-menu="<%= @template.template_versions.exists? %>" data-locale="<%= I18n.locale %>" data-show-tour-start-form="<%= @show_tour_start_form %>"></template-builder>
<% account_configs = current_account.account_configs.where(key: [AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY, AccountConfig::TEMPLATE_DATE_FORMATS_KEY]).to_a %>
<% custom_fields = account_configs.find { |e| e.key == AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY }&.value || [] %>
<% date_formats = account_configs.find { |e| e.key == AccountConfig::TEMPLATE_DATE_FORMATS_KEY }&.value || [] %>
<template-builder class="grid" data-template="<%= @template_data.to_json %>" data-custom-fields="<%= custom_fields.to_json %>" data-date-formats="<%= date_formats.to_json %>" data-with-sign-yourself-button="<%= !@template.archived_at? %>" data-with-fields-detection="true" data-with-send-button="<%= !@template.archived_at? && can?(:create, @template.submissions.new(account: current_account)) %>" data-with-revisions-menu="<%= @template.template_versions.exists? %>" data-locale="<%= I18n.locale %>" data-show-tour-start-form="<%= @show_tour_start_form %>"></template-builder>
+4 -4
View File
@@ -3,15 +3,15 @@
<% is_show_tabs = @pagy.pages > 1 || params[:q].present? || params[:status].present? || filter_params.present? %>
<% if @pagy.count.nil? || !@pagy.count.zero? || params[:q].present? || params[:status].present? || filter_params.present? %>
<div class="<%= is_show_tabs ? 'mb-4' : 'mb-6' %>">
<div class="flex justify-between items-center md:items-end">
<div>
<div class="group/header flex justify-between items-center md:items-end">
<div class="max-md:group-has-[search-input:focus-within]/header:hidden">
<h2 class="text-3xl font-bold md:block <%= 'hidden' if params[:q].present? %>">
<%= t('submissions') %>
</h2>
</div>
<div class="flex justify-end space-x-2">
<div class="flex justify-end space-x-2 max-md:has-[search-input:focus-within]:grow <%= 'max-md:grow' if params[:q].present? %>">
<% if params[:q].present? || params[:status].present? || filter_params.present? || @pagy.pages > 1 %>
<%= render 'shared/search_input', title_selector: 'h2' %>
<%= render 'shared/search_input' %>
<% end %>
<%= 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') %>
+3 -3
View File
@@ -1,11 +1,11 @@
<div>
<%= link_to root_path, class: 'flex items-center' do %>
<%= link_to root_path, class: 'flex items-center mb-1 md:mb-0' do %>
<%= svg_icon('chevron_left', class: 'w-5 h-5') %>
<span style="margin-left: 3px"><%= t('back_to_active') %></span>
<% end %>
</div>
<div class="flex justify-between mb-4 items-center">
<div>
<div class="group/header flex justify-between mb-4 items-center">
<div class="max-md:group-has-[search-input:focus-within]/header:hidden">
<h1 class="text-2xl md:text-3xl font-bold md:block <%= 'hidden' if params[:q].present? %>"><%= t('document_templates_html') %> <span class="badge badge-outline badge-lg align-middle"><%= t('archived') %></span></h1>
</div>
<% if params[:q].present? || @pagy.pages > 1 %>
@@ -1,33 +1,33 @@
<% filter_params = params.permit(Submissions::Filter::ALLOWED_PARAMS).compact_blank %>
<% with_filters = @pagy.pages > 1 || params[:q].present? || filter_params.present? %>
<%= render 'templates/title', template: @template %>
<div class="flex flex-col md:flex-row md:items-center mb-6 gap-3">
<div class="flex w-full justify-between md:items-end items-end">
<div>
<div>
<%= link_to template_path(@template), class: 'flex items-center' do %>
<%= svg_icon('chevron_left', class: 'w-5 h-5') %>
<span style="margin-left: 3px"><%= t('back_to_active') %></span>
<% end %>
</div>
<h1 class="text-3xl font-bold md:block"><%= t('submissions') %> <span class="badge badge-outline badge-lg align-middle"><%= t('archived') %></span></h1>
<div>
<%= link_to template_path(@template), class: 'flex items-center mb-1 md:mb-0' do %>
<%= svg_icon('chevron_left', class: 'w-5 h-5') %>
<span style="margin-left: 3px"><%= t('back_to_active') %></span>
<% end %>
</div>
<div class="flex flex-col md:flex-row md:items-center mb-4 gap-3">
<div class="group/header flex w-full md:flex-1 min-w-0 justify-between items-center">
<div class="max-md:group-has-[search-input:focus-within]/header:hidden">
<h2 class="text-2xl md:text-3xl font-bold md:block"><%= t('submissions') %> <span class="badge badge-outline badge-lg align-middle"><%= t('archived') %></span></h2>
</div>
<div class="flex space-x-2 justify-end">
<div class="max-md:has-[search-input:focus-within]:grow <%= 'max-md:grow' if params[:q].present? %>">
<% if with_filters %>
<%= render 'shared/search_input' %>
<% end %>
<%= link_to new_template_submissions_export_path(@template, archived: true), class: 'btn btn-ghost text-base', data: { turbo_frame: 'modal' } do %>
</div>
</div>
<div class="flex flex-wrap justify-end items-center gap-2">
<%= render 'submissions_filters/applied_filters', filter_params: %>
<div class="shrink-0 md:order-first">
<%= link_to new_template_submissions_export_path(@template, archived: true), class: 'btn btn-ghost text-base h-10 min-h-10', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('download', class: 'w-6 h-6 stroke-2') %>
<span><%= t('export') %></span>
<% end %>
</div>
<%= render 'submissions_filters/filter_button', filter_params: %>
</div>
<% if with_filters %>
<div class="flex flex-col items-end md:flex-row gap-2">
<%= render 'submissions_filters/applied_filters', filter_params: %>
<%= render 'submissions_filters/filter_button', filter_params: %>
</div>
<% end %>
</div>
<% if @pagy.count.nil? || @pagy.count > 0 %>
<div class="space-y-4">
+1 -1
View File
@@ -26,7 +26,7 @@
<%= t('share_template_with_test_mode') %>
</span>
<submit-form data-on="change">
<%= f.check_box :value, class: 'toggle', checked: @template.template_sharings.exists?(account_id: current_account.testing_accounts) %>
<%= f.check_box :value, class: 'toggle', checked: @template.template_sharings.exists?(account_id: true_user.account.testing_accounts) %>
</submit-form>
</div>
<% end %>
+3 -3
View File
@@ -7,12 +7,12 @@
<button type="submit"></button>
<input id="dashboard_dropzone_input" name="files[]" type="file" multiple>
<% end %>
<div class="flex justify-between items-center w-full mb-4 relative">
<div class="group/header flex justify-between items-center w-full mb-4 relative">
<% unless show_dropzone %>
<%= render 'templates/dashboard_dropzone', style: 'height: 114px' %>
<% end %>
<%= render 'templates/dashboard_folder_dropzone', style: 'height: 114px' %>
<div class="flex items-center flex-grow min-w-0">
<div class="flex items-center flex-grow min-w-0 max-md:group-has-[search-input:focus-within]/header:hidden <%= 'max-md:hidden' if params[:q].present? %>">
<% if has_archived || @pagy.count.nil? || @pagy.count > 0 || @template_folders.present? %>
<div class="mr-2">
<%= render 'dashboard/toggle_view', selected: 'templates' %>
@@ -22,7 +22,7 @@
<%= t('document_templates_html') %>
</h1>
</div>
<div class="flex space-x-2">
<div class="flex space-x-2 max-md:has-[search-input:focus-within]:grow <%= 'max-md:grow' if params[:q].present? %>">
<% if params[:q].present? || @pagy.count.nil? || @pagy.count > 1 || @template_folders.present? %>
<%= render 'shared/search_input' %>
<% end %>
@@ -1,5 +1,6 @@
<% close_on_submit = local_assigns.fetch(:close_on_submit, true) %>
<% is_order_set = template.submitters.any? { |s| s['order'] } %>
<% field_submitter_uuids = Set.new(template.fields.pluck('submitter_uuid')) %>
<%= form_for template, url: template_recipients_path(template), method: :post, html: { autocomplete: 'off', class: 'mt-1', id: :submitters_form }, data: { close_on_submit: } do |f| %>
<% unless close_on_submit %>
<toggle-on-submit data-element-id="form_saved_alert"></toggle-on-submit>
@@ -10,6 +11,7 @@
<%= f.fields_for :submitters, item = Struct.new(:name, :uuid, :is_requester, :email, :invite_by_uuid, :invite_via_field_uuid, :optional_invite_by_uuid, :linked_to_uuid, :order, :option).new(*submitter.values_at('name', 'uuid', 'is_requester', 'email', 'invite_by_uuid', 'invite_via_field_uuid', 'optional_invite_by_uuid', 'linked_to_uuid', 'order')), index: do |ff| %>
<% item.option = item.is_requester.present? ? 'is_requester' : (item.email.present? ? 'email' : (item.linked_to_uuid.present? ? "linked_to_#{item.linked_to_uuid}" : (item.invite_by_uuid.present? ? "invite_by_#{item.invite_by_uuid}" : (item.optional_invite_by_uuid.present? ? "optional_invite_by_#{item.optional_invite_by_uuid}" : (item.invite_via_field_uuid.present? ? 'invite_via_field' : ''))))) %>
<%= ff.hidden_field :uuid %>
<% is_viewer_row = field_submitter_uuids.exclude?(submitter['uuid']) %>
<div class="form-control">
<div class="flex justify-between">
<%= ff.text_field :name, class: 'w-full outline-none border-transparent focus:border-transparent focus:ring-0 bg-base-100 px-1 peer mb-2', autocomplete: 'off', placeholder: "#{index + 1}#{(index + 1).ordinal} Party", required: true %>
@@ -21,6 +23,8 @@
<mount-on-click data-template-id="order_fields" class="link whitespace-nowrap text-sm mt-1 mr-1 block">
<%= t('edit_order') %>
</mount-on-click>
<% elsif is_viewer_row %>
<span class="inline-flex items-center justify-center h-6 px-2 rounded-full bg-base-200 text-base-content text-xs font-medium normal-case whitespace-nowrap"><%= t('view_only') %></span>
<% end %>
</div>
<% end %>
@@ -141,11 +145,13 @@
</div>
<% if template.submitters.size > 2 && !is_order_set %>
<template id="order_fields">
<% last_viewer_order = -1 %>
<% template.submitters.each_with_index do |submitter, index| %>
<% default_order = field_submitter_uuids.include?(submitter['uuid']) ? (last_viewer_order += 1) : [last_viewer_order, 0].max %>
<turbo-stream action="replace" target="order_<%= submitter['uuid'] %>">
<template>
<div id="order_<%= submitter['uuid'] %>">
<%= select_tag "template[submitters][#{index}][order]", options_for_select(template.submitters.map.with_index { |_, i| [(i + 1).ordinalize, i] }, submitter['order'].presence || index), class: 'select select-xs text-sm input-bordered bg-white pl-3.5' %>
<%= select_tag "template[submitters][#{index}][order]", options_for_select(template.submitters.map.with_index { |_, i| [(i + 1).ordinalize, i] }, default_order), class: 'select select-xs text-sm input-bordered bg-white pl-3.5' %>
</div>
</template>
</turbo-stream>
@@ -21,7 +21,7 @@
</div>
<div class="form-control">
<%= ff.label :completed_notification_email_body, t('email_body'), class: 'label' %>
<%= render 'personalization_settings/markdown_editor', name: ff.field_name(:completed_notification_email_body), value: ff.object.completed_notification_email_body, variables: AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY] %>
<%= render 'personalization_settings/email_body_editor', name: ff.field_name(:completed_notification_email_body), value: ff.object.completed_notification_email_body, variables: AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY], html_textarea: true %>
</div>
<% end %>
<% end %>
@@ -21,7 +21,7 @@
</div>
<div class="form-control">
<%= ff.label :documents_copy_email_body, t('email_body'), class: 'label' %>
<%= render 'personalization_settings/markdown_editor', name: ff.field_name(:documents_copy_email_body), value: ff.object.documents_copy_email_body, variables: AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY] %>
<%= render 'personalization_settings/email_body_editor', name: ff.field_name(:documents_copy_email_body), value: ff.object.documents_copy_email_body, variables: AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY], html_textarea: true %>
</div>
<% if can?(:manage, :reply_to) %>
<div class="form-control">
@@ -1,8 +1,11 @@
<div id="<%= AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY %>_form">
<% template_email_preferences_values = @template.preferences.values_at('request_email_subject', 'request_email_body').compact_blank %>
<% default_template_email_preferences_values = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY).value.values_at('subject', 'body') %>
<% viewer_default_email_preferences_values = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_VIEW_INVITATION_EMAIL_KEY).value.values_at('subject', 'body') %>
<% viewer_template_email_preferences_values = @template.preferences.values_at('invitation_view_email_subject', 'invitation_view_email_body').compact_blank.presence %>
<% field_submitter_uuids = Set.new(@template.fields.pluck('submitter_uuid')) %>
<% is_custom_template_email = template_email_preferences_values.present? %>
<% multiple_submitters = @template.submitters.size > 1 && @template.submitters.size < 5 %>
<% multiple_submitters = @template.submitters.size > 1 && @template.submitters.size < 11 %>
<% if is_custom_template_email || @template.preferences['submitters'].to_a.any? %>
<%= button_to nil, template_preferences_path(@template), id: 'submitter_invitation_email_reset_link', method: :delete, class: 'hidden', params: { config_key: AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY }, data: { turbo_confirm: t('are_you_sure_') }, form: { data: { close_on_submit: false } } %>
<% end %>
@@ -28,7 +31,7 @@
</div>
<div class="form-control">
<%= ff.label :request_email_body, t('email_body'), class: 'label' %>
<%= render 'personalization_settings/markdown_editor', name: ff.field_name(:request_email_body), value: ff.object.request_email_body, variables: AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY] %>
<%= render 'personalization_settings/email_body_editor', name: ff.field_name(:request_email_body), value: ff.object.request_email_body, variables: AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY], html_textarea: true %>
</div>
<% end %>
</div>
@@ -36,11 +39,13 @@
<div class="hidden peer-checked:block">
<% options = @template.submitters.map { |e| [e['name'], "request_email_#{e['uuid']}"] } %>
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="flex relative px-1">
<ul class="tabs w-full flex flex-nowrap mb-2">
<ul class="tabs w-full min-w-0 flex flex-nowrap mb-2">
<% options.each_with_index do |(label, val), index| %>
<div class="w-full">
<div class="w-full min-w-0 has-[:checked]:min-w-fit">
<%= f.radio_button :selected, val, checked: index.zero?, id: "#{val}_radio", data: { action: 'click:toggle-visible#trigger' }, class: 'hidden peer' %>
<%= f.label :selected, label, value: val, for: "#{val}_radio", class: 'tab w-full tab-lifted peer-checked:tab-active' %>
<%= f.label :selected, value: val, for: "#{val}_radio", class: 'tab w-full tab-lifted peer-checked:tab-active !px-2' do %>
<span class="truncate" title="<%= label %>"><%= label %></span>
<% end %>
</div>
<% end %>
</ul>
@@ -49,7 +54,7 @@
<div id="request_email_<%= submitter['uuid'] %>" class="<%= 'hidden' if index != 0 %>">
<% submitter_preferences = f.object.preferences['submitters'].to_a.find { |e| e['uuid'] == submitter['uuid'] } || {} %>
<% submitter_email_preferences_values = submitter_preferences.values_at('request_email_subject', 'request_email_body').compact_blank.presence %>
<% submitter_email_values = submitter_email_preferences_values || template_email_preferences_values.presence || default_template_email_preferences_values %>
<% submitter_email_values = field_submitter_uuids.include?(submitter['uuid']) ? (submitter_email_preferences_values || template_email_preferences_values.presence || default_template_email_preferences_values) : (viewer_template_email_preferences_values || submitter_email_preferences_values || viewer_default_email_preferences_values) %>
<%= hidden_field_tag 'template[preferences][submitters][][uuid]', submitter['uuid'] %>
<div class="form-control">
<div class="flex justify-between">
@@ -64,7 +69,7 @@
</div>
<div class="form-control">
<label class="label"><%= t('email_body') %></label>
<%= render 'personalization_settings/markdown_editor', name: 'template[preferences][submitters][][request_email_body]', value: submitter_email_values.last, variables: AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY] %>
<%= render 'personalization_settings/email_body_editor', name: 'template[preferences][submitters][][request_email_body]', value: submitter_email_values.last, variables: AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY], html_textarea: true %>
</div>
</div>
<% end %>
@@ -0,0 +1,34 @@
<div id="<%= AccountConfig::SUBMITTER_VIEW_INVITATION_EMAIL_KEY %>_form">
<% configs = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_VIEW_INVITATION_EMAIL_KEY).value %>
<% template_email_preferences_values = @template.preferences.values_at('invitation_view_email_subject', 'invitation_view_email_body').compact_blank.presence %>
<% is_custom_template_email = template_email_preferences_values.present? %>
<% if is_custom_template_email %>
<%= button_to nil, template_preferences_path(@template), id: 'submitter_view_invitation_email_reset_link', method: :delete, class: 'hidden', params: { config_key: AccountConfig::SUBMITTER_VIEW_INVITATION_EMAIL_KEY }, data: { turbo_confirm: t('are_you_sure_') }, form: { data: { close_on_submit: false } } %>
<% end %>
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-1', id: 'submitter_view_invitation_email_template_form' }, data: { close_on_submit: false } do |f| %>
<toggle-on-submit data-element-id="email_saved_alert_view"></toggle-on-submit>
<%= f.fields_for :preferences, Struct.new(:invitation_view_email_subject, :invitation_view_email_body).new(@template.preferences['invitation_view_email_subject'].presence || configs['subject'], @template.preferences['invitation_view_email_body'].presence || configs['body']) do |ff| %>
<div class="form-control">
<div class="flex justify-between">
<%= ff.label :invitation_view_email_subject, t('email_subject'), class: 'label' %>
<% if is_custom_template_email %>
<label for="submitter_view_invitation_email_reset_link" class="label underline">
<%= t('reset_default') %>
</label>
<% end %>
</div>
<%= ff.text_field :invitation_view_email_subject, required: true, class: 'base-input', dir: 'auto' %>
</div>
<div class="form-control">
<%= ff.label :invitation_view_email_body, t('email_body'), class: 'label' %>
<%= render 'personalization_settings/email_body_editor', name: ff.field_name(:invitation_view_email_body), value: ff.object.invitation_view_email_body, variables: AccountConfig::EMAIL_VARIABLES[AccountConfig::SUBMITTER_VIEW_INVITATION_EMAIL_KEY], html_textarea: true %>
</div>
<% end %>
<% end %>
<div class="form-control pt-2">
<%= button_tag button_title(title: t('save'), disabled_with: t('saving')), form: 'submitter_view_invitation_email_template_form', class: 'base-button' %>
<div class="flex justify-center">
<span id="email_saved_alert_view" class="text-sm invisible font-normal mt-0.5"><%= t('changes_have_been_saved') %></span>
</div>
</div>
</div>
+14 -2
View File
@@ -105,10 +105,22 @@
<div class="collapse-title text-xl font-medium">
<%= t('signature_request_email') %>
</div>
<div class="collapse-content">
<div class="collapse-content min-w-0">
<%= render 'templates_preferences/submitter_invitation_email_form' %>
</div>
</div>
<% field_submitter_uuids = Set.new(@template.fields.pluck('submitter_uuid')) %>
<% unless @template.submitters.all? { |s| field_submitter_uuids.include?(s['uuid']) } %>
<div class="collapse collapse-arrow join-item border border-base-300">
<input type="checkbox" name="accordion">
<div class="collapse-title text-xl font-medium">
<%= t('view_documents_email') %>
</div>
<div class="collapse-content">
<%= render 'templates_preferences/submitter_view_invitation_email_form' %>
</div>
</div>
<% end %>
<%= render 'templates_preferences/submitter_invitation_reminder_email_collapse' %>
<div class="collapse collapse-arrow join-item border border-base-300">
<input type="checkbox" name="accordion">
@@ -178,7 +190,7 @@
<%= t('share_template_with_test_mode') %>
</span>
<submit-form data-on="change" class="flex">
<%= f.check_box :value, class: 'toggle', checked: @template.template_sharings.exists?(account_id: current_account.testing_accounts) %>
<%= f.check_box :value, class: 'toggle', checked: @template.template_sharings.exists?(account_id: true_user.account.testing_accounts) %>
</submit-form>
</div>
<% end %>
+4 -4
View File
@@ -1,18 +1,18 @@
<div>
<%= link_to(@is_archived ? templates_shared_index_path : root_path, class: 'flex items-center') do %>
<%= link_to(@is_archived ? templates_shared_index_path : root_path, class: 'flex items-center mb-1 md:mb-0') do %>
<%= svg_icon('chevron_left', class: 'w-5 h-5') %>
<span style="margin-left: 3px"><%= @is_archived ? t('back_to_active') : t('home') %></span>
<% end %>
</div>
<div class="relative flex justify-between items-center w-full mb-4">
<h1 class="text-2xl truncate md:text-3xl font-bold flex items-center flex-grow min-w-0 space-x-2 md:flex <%= 'hidden' if params[:q].present? %>">
<div class="group/header relative flex justify-between items-center w-full mb-4">
<h1 class="text-2xl truncate md:text-3xl font-bold flex items-center flex-grow min-w-0 space-x-2 md:flex max-md:group-has-[search-input:focus-within]/header:hidden <%= 'hidden' if params[:q].present? %>">
<%= svg_icon('folder', class: 'w-9 h-9 flex-shrink-0') %>
<span class="truncate capitalize"><%= t('shared') %></span>
<% if @is_archived %>
<span class="badge badge-outline badge-lg align-middle"><%= t('archived') %></span>
<% end %>
</h1>
<div class="flex space-x-2">
<div class="flex space-x-2 max-md:has-[search-input:focus-within]:grow <%= 'max-md:grow' if params[:q].present? %>">
<% if params[:q].present? || @pagy.count.nil? || @pagy.count > 1 %>
<%= render 'shared/search_input' %>
<% end %>
+3
View File
@@ -146,6 +146,8 @@ Rails.application.configure do
current_user = controller.instance_variable_get(:@current_user)
os = DetectBrowserDevice.os(controller.request.user_agent) if ENV['MULTITENANT'] == 'true'
{
host: controller.request.host,
fwd: controller.request.remote_ip,
@@ -161,6 +163,7 @@ Rails.application.configure do
params[:submit_form_slug] ||
params[:template_slug]).to_s.first(5)
}.compact_blank,
**(os ? { os: } : {}),
uid: current_user.try(:id),
aid: current_user.try(:account_id),
rid: resource.try(:id),
+3
View File
@@ -0,0 +1,3 @@
# frozen_string_literal: true
Rack::Request.forwarded_priority = %i[x_forwarded]
+168
View File
@@ -92,16 +92,20 @@ en: &en
sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature: Sign documents with trusted certificate provided by DocuSeal. Your documents and data are never shared with DocuSeal. PDF checksum is provided to generate a trusted signature.
you_have_been_invited_to_submit_the_name_form: 'You have been invited to submit the "%{name}" form.'
you_have_been_invited_to_sign_the_name: 'You have been invited to sign the "%{name}".'
you_have_been_invited_to_view_the_name: 'You have been invited to view the "%{name}".'
alternatively_you_can_review_and_download_your_copy_using_the_link_below: "Alternatively, you can review and download your copy using the link below:"
please_check_the_copy_of_your_name_in_the_email_attachments: 'Please check the copy of your "%{name}" in the email attachments.'
awaiting_completion_by_the_other_party: "Awaiting completion by the other party"
review_and_sign: Review and Sign
view_document: View Document
review_and_submit: Review and Submit
please_contact_us_by_replying_to_this_email_if_you_have_any_questions: "Please contact us by replying to this email if you have any questions."
submitter_invitation_sms_body_sign: '{account.name} has invited you to sign a document: {submitter.link}'
submitter_invitation_sms_body_view: '{account.name} has invited you to view a document: {submitter.link}'
verification_code_sms_body: 'Verification code: {code}'
you_are_invited_to_submit_a_form: 'You are invited to submit a form'
you_are_invited_to_sign_a_document: 'You are invited to sign a document'
you_are_invited_to_view_a_document: 'You are invited to view a document'
you_are_invited_to_sign_documents: 'You are invited to sign documents'
your_document_copy: 'Your document copy'
name_has_been_completed_by_submitters: '"%{name}" has been completed by %{submitters}.'
@@ -115,6 +119,17 @@ en: &en
Please contact us by replying to this email if you have any questions.
Thanks,
{account.name}
submitter_invitation_email_view_body: |
Hi there,
You have been invited to view the "{template.name}".
[View Document]({submitter.link})
Please contact us by replying to this email if you have any questions.
Thanks,
{account.name}
submitter_completed_email_body: |
@@ -307,6 +322,8 @@ en: &en
invalid_timeserver: Invalid Timeserver
email_templates: Email Templates
signature_request_email: Signature request email
view_documents_email: View documents email
view_only: View only
signature_request_reminder_email: Signature request reminder email
signature_request_sms: Signature Request SMS
verification_code_sms: Verification Code SMS
@@ -569,6 +586,7 @@ en: &en
form_has_been_archived: Form has been archived.
form_has_been_expired: Form has been expired.
form_has_been_declined: Form has been declined.
form_is_view_only: Form is view only.
file_is_missing: File is missing
folder_name_has_been_updated: Folder name has been updated.
unable_to_rename_folder: Unable to rename folder.
@@ -653,6 +671,10 @@ en: &en
signers: Signers
not_invited_yet: Not invited yet
not_completed_yet: Not completed yet
not_completed: Not completed
not_viewed_yet: Not viewed yet
not_viewed: Not viewed
viewed_on_time: 'Viewed on %{time}'
declined_on_time: 'Declined on %{time}'
expire_on_time: 'Expire on %{time}'
sign_in_person: Sign In-person
@@ -877,6 +899,7 @@ en: &en
mobile: Mobile
tablet: Tablet
reset_default: Reset default
smtp_settings_have_been_reset: SMTP settings have been reset.
send_signature_request_email: Send signature request email
last_3_months: Last 3 months
last_6_months: Last 6 months
@@ -907,6 +930,7 @@ en: &en
please_verify_your_email_address_to_continue: Please verify your email address to continue.
verification_code_sent_click_link_or_enter_code: A verification code has been sent to your email. Click the email link or enter the one time code to confirm your email.
use_otp_code_to_verify_email_or_click_link_below_html: 'Use <b>%{code}</b> code to verify your email or click the link below:'
use_otp_code_to_verify_email_html: 'Use <b>%{code}</b> code to verify your email:'
verify_your_email: Verify your email
your_email_has_been_confirmed: Your email has been confirmed.
invalid_or_expired_verification_code: Invalid or expired verification code.
@@ -1151,15 +1175,19 @@ es: &es
thanks: Gracias
you_have_been_invited_to_submit_the_name_form: 'Has sido invitado/a a enviar el formulario "%{name}".'
you_have_been_invited_to_sign_the_name: 'Has sido invitado/a a firmar el "%{name}".'
you_have_been_invited_to_view_the_name: 'Has sido invitado/a a ver el "%{name}".'
alternatively_you_can_review_and_download_your_copy_using_the_link_below: "Alternativamente, puedes revisar y descargar tu copia usando el enlace a continuación:"
please_check_the_copy_of_your_name_in_the_email_attachments: 'Por favor, revisa la copia de tu "%{name}" en los archivos adjuntos del correo electrónico.'
review_and_sign: Revisar y Firmar
view_document: Ver documento
review_and_submit: Revisar y Enviar
please_contact_us_by_replying_to_this_email_if_you_have_any_questions: "Por favor, contáctanos respondiendo a este correo si tienes alguna pregunta."
submitter_invitation_sms_body_sign: '{account.name} te ha invitado a firmar un documento: {submitter.link}'
submitter_invitation_sms_body_view: '{account.name} te ha invitado a ver un documento: {submitter.link}'
verification_code_sms_body: 'Código de verificación: {code}'
you_are_invited_to_submit_a_form: 'Estás invitado/a a enviar un formulario'
you_are_invited_to_sign_a_document: 'Estás invitado/a a firmar un documento'
you_are_invited_to_view_a_document: 'Estás invitado/a a ver un documento'
you_are_invited_to_sign_documents: 'Estás invitado/a a firmar documentos'
your_document_copy: 'Tu copia del documento'
name_has_been_completed_by_submitters: '"%{name}" ha sido completado por %{submitters}.'
@@ -1173,6 +1201,17 @@ es: &es
Por favor, contáctanos respondiendo a este correo si tienes alguna pregunta.
Gracias,
{account.name}
submitter_invitation_email_view_body: |
Hola,
Has sido invitado/a a ver el "{template.name}".
[Ver documento]({submitter.link})
Por favor, contáctanos respondiendo a este correo si tienes alguna pregunta.
Gracias,
{account.name}
submitter_completed_email_body: |
@@ -1364,6 +1403,8 @@ es: &es
invalid_timeserver: Servidor de tiempo inválido
email_templates: Plantillas de correo electrónico
signature_request_email: Correo de solicitud de firma
view_documents_email: Correo de visualización de documentos
view_only: Solo lectura
signature_request_reminder_email: Correo de recordatorio de solicitud de firma
signature_request_sms: SMS de solicitud de firma
verification_code_sms: SMS de código de verificación
@@ -1626,6 +1667,7 @@ es: &es
form_has_been_archived: El formulario ha sido archivado.
form_has_been_expired: El formulario ha expirado.
form_has_been_declined: El formulario ha sido rechazado.
form_is_view_only: El formulario es de solo lectura.
file_is_missing: Falta el archivo
folder_name_has_been_updated: El nombre de la carpeta ha sido actualizado.
unable_to_rename_folder: No se pudo renombrar la carpeta.
@@ -1710,6 +1752,10 @@ es: &es
signers: Firmantes
not_invited_yet: Aún no invitado
not_completed_yet: Aún no completado
not_completed: No completado
not_viewed_yet: Aún no visto
not_viewed: No visto
viewed_on_time: 'Visto el %{time}'
declined_on_time: 'Rechazado el %{time}'
expire_on_time: 'Expira el %{time}'
sign_in_person: Firma en persona
@@ -1931,6 +1977,7 @@ es: &es
mobile: Móvil
tablet: Tableta
reset_default: Restablecer por defecto
smtp_settings_have_been_reset: La configuración SMTP ha sido restablecida.
send_signature_request_email: Enviar correo de solicitud de firma
last_3_months: Últimos 3 meses
last_6_months: Últimos 6 meses
@@ -1961,6 +2008,7 @@ es: &es
please_verify_your_email_address_to_continue: Por favor, verifica tu dirección de correo electrónico para continuar.
verification_code_sent_click_link_or_enter_code: Se ha enviado un código de verificación a tu correo. Puedes hacer clic en el enlace del correo o ingresar el código a continuación.
use_otp_code_to_verify_email_or_click_link_below_html: 'Usa el código <b>%{code}</b> para verificar tu correo electrónico o haz clic en el enlace a continuación:'
use_otp_code_to_verify_email_html: 'Usa el código <b>%{code}</b> para verificar tu correo electrónico:'
verify_your_email: Verificar tu correo electrónico
your_email_has_been_confirmed: Tu correo electrónico ha sido confirmado.
invalid_or_expired_verification_code: Código de verificación inválido o expirado.
@@ -2205,15 +2253,19 @@ it: &it
thanks: Grazie
you_have_been_invited_to_submit_the_name_form: 'Sei stato invitato a inviare il modulo "%{name}".'
you_have_been_invited_to_sign_the_name: 'Sei stato invitato a firmare il "%{name}".'
you_have_been_invited_to_view_the_name: 'Sei stato invitato a visualizzare il "%{name}".'
alternatively_you_can_review_and_download_your_copy_using_the_link_below: "In alternativa, puoi rivedere e scaricare la tua copia utilizzando il link qui sotto:"
please_check_the_copy_of_your_name_in_the_email_attachments: "Per favore, controlla la copia del tuo \"%{name}\" negli allegati dell'email."
review_and_sign: Rivedi e Firma
view_document: Visualizza documento
review_and_submit: Rivedi e Invia
please_contact_us_by_replying_to_this_email_if_you_have_any_questions: "Per favore, contattaci rispondendo a questa email se hai domande."
submitter_invitation_sms_body_sign: '{account.name} ti ha invitato a firmare un documento: {submitter.link}'
submitter_invitation_sms_body_view: '{account.name} ti ha invitato a visualizzare un documento: {submitter.link}'
verification_code_sms_body: 'Codice di verifica: {code}'
you_are_invited_to_submit_a_form: 'Sei stato invitato a inviare un modulo'
you_are_invited_to_sign_a_document: 'Sei stato invitato a firmare un documento'
you_are_invited_to_view_a_document: 'Sei stato invitato a visualizzare un documento'
you_are_invited_to_sign_documents: 'Sei stato invitato a firmare dei documenti'
your_document_copy: 'La tua copia del documento'
name_has_been_completed_by_submitters: '"%{name}" è stato completato da %{submitters}.'
@@ -2227,6 +2279,17 @@ it: &it
Per favore, contattaci rispondendo a questa email se hai domande.
Grazie,
{account.name}
submitter_invitation_email_view_body: |
Ciao,
Sei stato invitato a visualizzare il "{template.name}".
[Visualizza documento]({submitter.link})
Per favore, contattaci rispondendo a questa email se hai domande.
Grazie,
{account.name}
submitter_completed_email_body: |
@@ -2418,6 +2481,8 @@ it: &it
invalid_timeserver: Server di timestamp non valido
email_templates: Modelli email
signature_request_email: Email di richiesta di firma
view_documents_email: Email di visualizzazione documenti
view_only: Sola lettura
signature_request_reminder_email: Email di promemoria di richiesta di firma
signature_request_sms: SMS di richiesta di firma
verification_code_sms: SMS con codice di verifica
@@ -2680,6 +2745,7 @@ it: &it
form_has_been_archived: Il modulo è stato archiviato.
form_has_been_expired: Il modulo è scaduto.
form_has_been_declined: Il modulo è stato rifiutato.
form_is_view_only: Il modulo è di sola lettura.
file_is_missing: File mancante
folder_name_has_been_updated: Il nome della cartella è stato aggiornato.
unable_to_rename_folder: Impossibile rinominare la cartella.
@@ -2764,6 +2830,10 @@ it: &it
signers: Firmatari
not_invited_yet: Non ancora invitato
not_completed_yet: Non ancora completato
not_completed: Non completato
not_viewed_yet: Non ancora visualizzato
not_viewed: Non visualizzato
viewed_on_time: 'Visualizzato il %{time}'
declined_on_time: 'Rifiutato il %{time}'
expire_on_time: 'Scade il %{time}'
sign_in_person: Firma di persona
@@ -2985,6 +3055,7 @@ it: &it
mobile: Mobile
tablet: Tablet
reset_default: Reimposta predefinito
smtp_settings_have_been_reset: Le impostazioni SMTP sono state ripristinate.
send_signature_request_email: Invia email di richiesta firma
last_3_months: Ultimi 3 mesi
last_6_months: Ultimi 6 mesi
@@ -3015,6 +3086,7 @@ it: &it
please_verify_your_email_address_to_continue: Verifica il tuo indirizzo email per continuare.
verification_code_sent_click_link_or_enter_code: "È stato inviato un codice di verifica alla tua email. Puoi cliccare il link nell'email o inserire il codice qui sotto."
use_otp_code_to_verify_email_or_click_link_below_html: 'Usa il codice <b>%{code}</b> per verificare la tua email o clicca il link qui sotto:'
use_otp_code_to_verify_email_html: 'Usa il codice <b>%{code}</b> per verificare la tua email:'
verify_your_email: Verifica la tua email
your_email_has_been_confirmed: La tua email è stata confermata.
invalid_or_expired_verification_code: Codice di verifica non valido o scaduto.
@@ -3259,15 +3331,19 @@ fr: &fr
thanks: Merci
you_have_been_invited_to_submit_the_name_form: Vous avez été invité à soumettre le formulaire "%{name}".
you_have_been_invited_to_sign_the_name: Vous avez été invité à signer "%{name}".
you_have_been_invited_to_view_the_name: Vous avez été invité à consulter "%{name}".
alternatively_you_can_review_and_download_your_copy_using_the_link_below: 'Vous pouvez également consulter et télécharger votre exemplaire à laide du lien cidessous :'
please_check_the_copy_of_your_name_in_the_email_attachments: Veuillez vérifier la copie de votre "%{name}" dans les pièces jointes de lemail.
review_and_sign: Examiner et signer
view_document: Consulter le document
review_and_submit: Examiner et soumettre
please_contact_us_by_replying_to_this_email_if_you_have_any_questions: Veuillez nous contacter en répondant à cet email si vous avez des questions.
submitter_invitation_sms_body_sign: "{account.name} vous a invité à signer un document : {submitter.link}"
submitter_invitation_sms_body_view: "{account.name} vous a invité à consulter un document : {submitter.link}"
verification_code_sms_body: 'Code de vérification : {code}'
you_are_invited_to_submit_a_form: Vous êtes invité à soumettre un formulaire
you_are_invited_to_sign_a_document: Vous êtes invité à signer un document
you_are_invited_to_view_a_document: Vous êtes invité à consulter un document
you_are_invited_to_sign_documents: Vous êtes invité à signer des documents
your_document_copy: Votre copie de document
name_has_been_completed_by_submitters: '"%{name}" a été complété par %{submitters}.'
@@ -3281,6 +3357,17 @@ fr: &fr
Veuillez nous contacter en répondant à cet email si vous avez des questions.
Merci,
{account.name}
submitter_invitation_email_view_body: |
Bonjour,
Vous avez été invité à consulter "{template.name}".
[Consulter le document]({submitter.link})
Veuillez nous contacter en répondant à cet e-mail si vous avez des questions.
Merci,
{account.name}
submitter_completed_email_body: |
@@ -3472,6 +3559,8 @@ fr: &fr
invalid_timeserver: Serveur dhorodatage invalide
email_templates: Modèles demail
signature_request_email: Email de demande de signature
view_documents_email: E-mail de consultation des documents
view_only: Lecture seule
signature_request_reminder_email: Email de rappel de demande de signature
signature_request_sms: SMS de demande de signature
verification_code_sms: SMS de code de vérification
@@ -3734,6 +3823,7 @@ fr: &fr
form_has_been_archived: Le formulaire a été archivé.
form_has_been_expired: Le formulaire a expiré.
form_has_been_declined: Le formulaire a été refusé.
form_is_view_only: Le formulaire est en lecture seule.
file_is_missing: Fichier manquant
folder_name_has_been_updated: Le nom du dossier a été mis à jour.
unable_to_rename_folder: Impossible de renommer le dossier.
@@ -3818,6 +3908,10 @@ fr: &fr
signers: Signataires
not_invited_yet: Pas encore invité
not_completed_yet: Pas encore terminé
not_completed: Non terminé
not_viewed_yet: Pas encore consulté
not_viewed: Non consulté
viewed_on_time: Consulté le %{time}
declined_on_time: Refusé le %{time}
expire_on_time: Expire le %{time}
sign_in_person: Signer en personne
@@ -4035,6 +4129,7 @@ fr: &fr
mobile: Mobile
tablet: Tablette
reset_default: Réinitialiser par défaut
smtp_settings_have_been_reset: Les paramètres SMTP ont été réinitialisés.
send_signature_request_email: Envoyer un e-mail de demande de signature
last_month: Mois dernier
last_3_months: 3 derniers mois
@@ -4066,6 +4161,7 @@ fr: &fr
please_verify_your_email_address_to_continue: "Veuillez vérifier votre adresse e-mail pour continuer."
verification_code_sent_click_link_or_enter_code: "Un code de vérification a été envoyé à votre adresse e-mail. Vous pouvez cliquer sur le lien dans l'e-mail ou saisir le code ci-dessous."
use_otp_code_to_verify_email_or_click_link_below_html: "Utilisez le code <b>%{code}</b> pour vérifier votre e-mail ou cliquez sur le lien ci-dessous :"
use_otp_code_to_verify_email_html: "Utilisez le code <b>%{code}</b> pour vérifier votre e-mail :"
verify_your_email: "Vérifier votre e-mail"
your_email_has_been_confirmed: "Votre adresse e-mail a été confirmée."
invalid_or_expired_verification_code: "Code de vérification invalide ou expiré."
@@ -4310,15 +4406,19 @@ pt: &pt
thanks: Obrigado
you_have_been_invited_to_submit_the_name_form: 'Você foi convidado a submeter o formulário "%{name}".'
you_have_been_invited_to_sign_the_name: 'Você foi convidado a assinar "%{name}".'
you_have_been_invited_to_view_the_name: 'Você foi convidado a visualizar "%{name}".'
alternatively_you_can_review_and_download_your_copy_using_the_link_below: 'Você pode revisar e baixar sua cópia usando o link abaixo:'
please_check_the_copy_of_your_name_in_the_email_attachments: 'Por favor, verifique a cópia de "%{name}" nos anexos do e-mail.'
review_and_sign: Revisar e assinar
view_document: Visualizar documento
review_and_submit: Revisar e submeter
please_contact_us_by_replying_to_this_email_if_you_have_any_questions: 'Por favor, entre em contato conosco respondendo a este e-mail se você tiver alguma dúvida.'
submitter_invitation_sms_body_sign: '{account.name} convidou você para assinar um documento: {submitter.link}'
submitter_invitation_sms_body_view: '{account.name} convidou você para visualizar um documento: {submitter.link}'
verification_code_sms_body: 'Código de verificação: {code}'
you_are_invited_to_submit_a_form: Você foi convidado a submeter um formulário
you_are_invited_to_sign_a_document: Você foi convidado a assinar um documento
you_are_invited_to_view_a_document: Você foi convidado a visualizar um documento
you_are_invited_to_sign_documents: Você foi convidado a assinar documentos
your_document_copy: Sua cópia do documento
name_has_been_completed_by_submitters: '"%{name}" foi concluído por %{submitters}.'
@@ -4332,6 +4432,17 @@ pt: &pt
Por favor, entre em contato conosco respondendo a este e-mail se você tiver alguma dúvida.
Obrigado,
{account.name}
submitter_invitation_email_view_body: |
Olá,
Você foi convidado a visualizar "{template.name}".
[Visualizar documento]({submitter.link})
Por favor, entre em contato conosco respondendo a este e-mail se você tiver alguma dúvida.
Obrigado,
{account.name}
submitter_completed_email_body: |
@@ -4523,6 +4634,8 @@ pt: &pt
invalid_timeserver: Servidor de carimbo de tempo inválido
email_templates: Modelos de e-mail
signature_request_email: E-mail de solicitação de assinatura
view_documents_email: E-mail de visualização de documentos
view_only: Somente leitura
signature_request_reminder_email: E-mail de lembrete de solicitação de assinatura
signature_request_sms: SMS de solicitação de assinatura
verification_code_sms: SMS com código de verificação
@@ -4785,6 +4898,7 @@ pt: &pt
form_has_been_archived: O formulário foi arquivado.
form_has_been_expired: O formulário expirou.
form_has_been_declined: O formulário foi recusado.
form_is_view_only: O formulário é somente leitura.
file_is_missing: O arquivo está ausente
folder_name_has_been_updated: O nome da pasta foi atualizado.
unable_to_rename_folder: Não foi possível renomear a pasta.
@@ -4869,6 +4983,10 @@ pt: &pt
signers: Signatários
not_invited_yet: Ainda não convidado
not_completed_yet: Ainda não concluído
not_completed: Não concluído
not_viewed_yet: Ainda não visualizado
not_viewed: Não visualizado
viewed_on_time: 'Visualizado em %{time}'
declined_on_time: 'Recusado em %{time}'
expire_on_time: 'Expira em %{time}'
sign_in_person: Assinar pessoalmente
@@ -5090,6 +5208,7 @@ pt: &pt
mobile: Celular
tablet: Tablet
reset_default: Redefinir para padrão
smtp_settings_have_been_reset: As configurações SMTP foram redefinidas.
send_signature_request_email: Enviar e-mail de solicitação de assinatura
last_3_months: Últimos 3 meses
last_6_months: Últimos 6 meses
@@ -5120,6 +5239,7 @@ pt: &pt
please_verify_your_email_address_to_continue: Verifique seu endereço de e-mail para continuar.
verification_code_sent_click_link_or_enter_code: Um código de verificação foi enviado para seu e-mail. Você pode clicar no link do e-mail ou inserir o código abaixo.
use_otp_code_to_verify_email_or_click_link_below_html: 'Use o código <b>%{code}</b> para verificar seu e-mail ou clique no link abaixo:'
use_otp_code_to_verify_email_html: 'Use o código <b>%{code}</b> para verificar seu e-mail:'
verify_your_email: Verificar seu e-mail
your_email_has_been_confirmed: Seu e-mail foi confirmado.
invalid_or_expired_verification_code: Código de verificação inválido ou expirado.
@@ -5362,16 +5482,20 @@ de: &de
sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature: Unterzeichnen Sie Dokumente mit einem vertrauenswürdigen Zertifikat von DocuSeal. Ihre Dokumente und Daten werden niemals mit DocuSeal geteilt. Eine PDF-Prüfsumme wird bereitgestellt, um eine vertrauenswürdige Signatur zu generieren.
you_have_been_invited_to_submit_the_name_form: 'Sie wurden eingeladen, das Formular "%{name}" einzureichen.'
you_have_been_invited_to_sign_the_name: 'Sie wurden eingeladen, "%{name}" zu unterschreiben.'
you_have_been_invited_to_view_the_name: 'Sie wurden eingeladen, "%{name}" anzusehen.'
alternatively_you_can_review_and_download_your_copy_using_the_link_below: 'Alternativ können Sie Ihre Kopie über den untenstehenden Link ansehen und herunterladen:'
please_check_the_copy_of_your_name_in_the_email_attachments: 'Bitte prüfen Sie die Kopie von "%{name}" im E-Mail-Anhang.'
awaiting_completion_by_the_other_party: "Warten auf die Fertigstellung durch die andere Partei"
review_and_sign: Prüfen und unterschreiben
view_document: Dokument ansehen
review_and_submit: Prüfen und einreichen
please_contact_us_by_replying_to_this_email_if_you_have_any_questions: 'Bitte kontaktieren Sie uns, indem Sie auf diese E-Mail antworten, falls Sie Fragen haben.'
submitter_invitation_sms_body_sign: '{account.name} hat Sie eingeladen, ein Dokument zu unterschreiben: {submitter.link}'
submitter_invitation_sms_body_view: '{account.name} hat Sie eingeladen, ein Dokument anzusehen: {submitter.link}'
verification_code_sms_body: 'Verifizierungscode: {code}'
you_are_invited_to_submit_a_form: Sie sind eingeladen, ein Formular einzureichen
you_are_invited_to_sign_a_document: Sie sind eingeladen, ein Dokument zu unterschreiben
you_are_invited_to_view_a_document: Sie sind eingeladen, ein Dokument anzusehen
you_are_invited_to_sign_documents: Sie sind eingeladen, Dokumente zu unterschreiben
your_document_copy: Ihre Dokumentkopie
name_has_been_completed_by_submitters: '"%{name}" wurde von %{submitters} abgeschlossen.'
@@ -5385,6 +5509,17 @@ de: &de
Bitte kontaktieren Sie uns, indem Sie auf diese E-Mail antworten, falls Sie Fragen haben.
Danke,
{account.name}
submitter_invitation_email_view_body: |
Hallo,
Sie wurden eingeladen, "{template.name}" anzusehen.
[Dokument ansehen]({submitter.link})
Bitte kontaktieren Sie uns, indem Sie auf diese E-Mail antworten, falls Sie Fragen haben.
Danke,
{account.name}
submitter_completed_email_body: |
@@ -5577,6 +5712,8 @@ de: &de
invalid_timeserver: Ungültiger Zeitstempelserver
email_templates: E-Mail-Vorlagen
signature_request_email: E-Mail für Signaturanfrage
view_documents_email: E-Mail zur Dokumentenansicht
view_only: Nur Ansicht
signature_request_reminder_email: E-Mail-Erinnerung für Signaturanfrage
signature_request_sms: SMS für Signaturanfrage
verification_code_sms: SMS mit Verifizierungscode
@@ -5839,6 +5976,7 @@ de: &de
form_has_been_archived: Das Formular wurde archiviert.
form_has_been_expired: Das Formular ist abgelaufen.
form_has_been_declined: Das Formular wurde abgelehnt.
form_is_view_only: Das Formular ist schreibgeschützt.
file_is_missing: Datei fehlt
folder_name_has_been_updated: Der Ordnername wurde aktualisiert.
unable_to_rename_folder: Der Ordner konnte nicht umbenannt werden.
@@ -5923,6 +6061,10 @@ de: &de
signers: Unterzeichner
not_invited_yet: Noch nicht eingeladen
not_completed_yet: Noch nicht abgeschlossen
not_completed: Nicht abgeschlossen
not_viewed_yet: Noch nicht angesehen
not_viewed: Nicht angesehen
viewed_on_time: 'Angesehen am %{time}'
declined_on_time: 'Abgelehnt am %{time}'
expire_on_time: 'Läuft ab am %{time}'
sign_in_person: Vor Ort unterschreiben
@@ -6144,6 +6286,7 @@ de: &de
mobile: Mobil
tablet: Tablet
reset_default: Standard zurücksetzen
smtp_settings_have_been_reset: Die SMTP-Einstellungen wurden zurückgesetzt.
send_signature_request_email: Signaturanfrage-E-Mail senden
last_3_months: Letzte 3 Monate
last_6_months: Letzte 6 Monate
@@ -6174,6 +6317,7 @@ de: &de
please_verify_your_email_address_to_continue: Bitte bestätigen Sie Ihre E-Mail-Adresse, um fortzufahren.
verification_code_sent_click_link_or_enter_code: Ein Verifizierungscode wurde an Ihre E-Mail gesendet. Sie können auf den Link in der E-Mail klicken oder den Code unten eingeben.
use_otp_code_to_verify_email_or_click_link_below_html: 'Verwenden Sie den Code <b>%{code}</b>, um Ihre E-Mail zu bestätigen, oder klicken Sie auf den Link unten:'
use_otp_code_to_verify_email_html: 'Verwenden Sie den Code <b>%{code}</b>, um Ihre E-Mail zu bestätigen:'
verify_your_email: E-Mail bestätigen
your_email_has_been_confirmed: Ihre E-Mail-Adresse wurde bestätigt.
invalid_or_expired_verification_code: Ungültiger oder abgelaufener Verifizierungscode.
@@ -6821,16 +6965,20 @@ nl: &nl
: Onderteken documenten met een vertrouwd certificaat geleverd door DocuSeal. Uw documenten en gegevens worden nooit gedeeld met DocuSeal. PDF-checksum wordt verstrekt om een vertrouwde handtekening te genereren.
you_have_been_invited_to_submit_the_name_form: U bent uitgenodigd om het formulier "%{name}" in te dienen.
you_have_been_invited_to_sign_the_name: U bent uitgenodigd om "%{name}" te ondertekenen.
you_have_been_invited_to_view_the_name: U bent uitgenodigd om "%{name}" te bekijken.
alternatively_you_can_review_and_download_your_copy_using_the_link_below: 'U kunt uw exemplaar ook bekijken en downloaden via de onderstaande link:'
please_check_the_copy_of_your_name_in_the_email_attachments: Controleer de kopie van uw "%{name}" in de e-mailbijlagen.
awaiting_completion_by_the_other_party: In afwachting van voltooiing door de andere partij
review_and_sign: Bekijken en ondertekenen
view_document: Document bekijken
review_and_submit: Bekijken en indienen
please_contact_us_by_replying_to_this_email_if_you_have_any_questions: Neem contact met ons op door op deze e-mail te antwoorden als u vragen heeft.
submitter_invitation_sms_body_sign: "{account.name} heeft u uitgenodigd om een document te ondertekenen: {submitter.link}"
submitter_invitation_sms_body_view: "{account.name} heeft u uitgenodigd om een document te bekijken: {submitter.link}"
verification_code_sms_body: 'Verificatiecode: {code}'
you_are_invited_to_submit_a_form: U bent uitgenodigd om een formulier in te dienen
you_are_invited_to_sign_a_document: U bent uitgenodigd om een document te ondertekenen
you_are_invited_to_view_a_document: U bent uitgenodigd om een document te bekijken
you_are_invited_to_sign_documents: U bent uitgenodigd om documenten te ondertekenen
your_document_copy: Uw documentkopie
name_has_been_completed_by_submitters: '"%{name}" is voltooid door %{submitters}.'
@@ -6844,6 +6992,17 @@ nl: &nl
Neem contact met ons op door op deze e-mail te antwoorden als u vragen heeft.
Bedankt,
{account.name}
submitter_invitation_email_view_body: |
Hallo,
U bent uitgenodigd om "{template.name}" te bekijken.
[Document bekijken]({submitter.link})
Neem contact met ons op door op deze e-mail te antwoorden als u vragen heeft.
Bedankt,
{account.name}
submitter_completed_email_body: |
@@ -7036,6 +7195,8 @@ nl: &nl
invalid_timeserver: Ongeldige tijdserver
email_templates: E-mailsjablonen
signature_request_email: E-mail voor handtekeningverzoek
view_documents_email: E-mail voor documentweergave
view_only: Alleen bekijken
signature_request_reminder_email: E-mailherinnering voor handtekeningverzoek
signature_request_sms: SMS voor handtekeningverzoek
verification_code_sms: Verificatiecode-SMS
@@ -7298,6 +7459,7 @@ nl: &nl
form_has_been_archived: Formulier is gearchiveerd.
form_has_been_expired: Formulier is verlopen.
form_has_been_declined: Formulier is geweigerd.
form_is_view_only: Formulier is alleen-lezen.
file_is_missing: Bestand ontbreekt
folder_name_has_been_updated: Mapnaam is bijgewerkt.
unable_to_rename_folder: Kan map niet hernoemen.
@@ -7382,6 +7544,10 @@ nl: &nl
signers: Ondertekenaars
not_invited_yet: Nog niet uitgenodigd
not_completed_yet: Nog niet voltooid
not_completed: Niet voltooid
not_viewed_yet: Nog niet bekeken
not_viewed: Niet bekeken
viewed_on_time: Bekeken op %{time}
declined_on_time: Geweigerd op %{time}
expire_on_time: Verloopt op %{time}
sign_in_person: In persoon ondertekenen
@@ -7599,6 +7765,7 @@ nl: &nl
mobile: Mobiel
tablet: Tablet
reset_default: Standaard herstellen
smtp_settings_have_been_reset: De SMTP-instellingen zijn hersteld.
send_signature_request_email: E-mail met handtekeningaanvraag verzenden
last_3_months: Afgelopen 3 maanden
last_6_months: Afgelopen 6 maanden
@@ -7629,6 +7796,7 @@ nl: &nl
please_verify_your_email_address_to_continue: Bevestig uw e-mailadres om door te gaan.
verification_code_sent_click_link_or_enter_code: Er is een verificatiecode naar uw e-mail gestuurd. U kunt op de link in de e-mail klikken of de code hieronder invoeren.
use_otp_code_to_verify_email_or_click_link_below_html: 'Gebruik code <b>%{code}</b> om uw e-mail te verifiëren of klik op de link hieronder:'
use_otp_code_to_verify_email_html: 'Gebruik code <b>%{code}</b> om uw e-mail te verifiëren:'
verify_your_email: Uw e-mail verifiëren
your_email_has_been_confirmed: Uw e-mailadres is bevestigd.
invalid_or_expired_verification_code: Ongeldige of verlopen verificatiecode.
+2 -2
View File
@@ -29,7 +29,7 @@ Rails.application.routes.draw do
resources :submitter_email_clicks, only: %i[create]
resources :submitter_form_views, only: %i[create]
resources :submitters, only: %i[index show update]
resources :submissions, only: %i[index show create destroy] do
resources :submissions, only: %i[index show create update destroy] do
resources :documents, only: %i[index], controller: 'submission_documents'
collection do
resources :init, only: %i[create], controller: 'submissions'
@@ -189,7 +189,7 @@ Rails.application.routes.draw do
resources :api, only: %i[index create], controller: 'api_settings'
resource :reveal_access_token, only: %i[show create], controller: 'reveal_access_token'
end
resources :email, only: %i[index create], controller: 'email_smtp_settings'
resources :email, only: %i[index create destroy], controller: 'email_smtp_settings'
resources :sso, only: %i[index], controller: 'sso_settings'
resources :notifications, only: %i[index create], controller: 'notifications_settings'
resource :esign, only: %i[show create new update destroy], controller: 'esign_settings'
@@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddSubmissionCreatedAtIndex < ActiveRecord::Migration[8.1]
def change
add_index :submissions, :created_at, if_not_exists: true
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.1].define(version: 2026_07_01_165617) do
ActiveRecord::Schema[8.1].define(version: 2026_07_07_055354) do
# These are extensions that must be enabled in order to support this database
enable_extension "btree_gin"
enable_extension "pg_catalog.plpgsql"
@@ -379,6 +379,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_07_01_165617) do
t.index ["account_id", "id"], name: "index_submissions_on_account_id_and_id_pending", where: "((completed_at IS NULL) AND (archived_at IS NULL))"
t.index ["account_id", "template_id", "id"], name: "index_submissions_on_account_id_and_template_id_and_id", where: "(archived_at IS NULL)"
t.index ["account_id", "template_id", "id"], name: "index_submissions_on_account_id_and_template_id_and_id_archived", where: "(archived_at IS NOT NULL)"
t.index ["created_at"], name: "index_submissions_on_created_at"
t.index ["created_by_user_id"], name: "index_submissions_on_created_by_user_id"
t.index ["slug"], name: "index_submissions_on_slug", unique: true
t.index ["template_id"], name: "index_submissions_on_template_id"
+56
View File
@@ -26,6 +26,45 @@ module DetectBrowserDevice
Silk
/ix
WINDOWS_USER_AGENT_REGEXP = /
Windows|
Win64 |
Win32 |
WOW64
/ix
ANDROID_USER_AGENT_REGEXP = /
Android|
Silk |
Kindle
/ix
IOS_USER_AGENT_REGEXP = /
iPhone|
iPad |
iPod |
iOS
/ix
MACOS_USER_AGENT_REGEXP = /
Macintosh |
Mac\ OS\ X |
MacIntel
/ix
LINUX_USER_AGENT_REGEXP = /
Linux |
X11 |
CrOS |
Ubuntu |
Fedora |
FreeBSD|
OpenBSD|
NetBSD
/ix
SDK_USER_AGENT_REGEXP = /\ADocuSeal (?<sdk>Ruby|Python|PHP|Java|C#|JS|Go|CLI) v/i
def call(user_agent)
return if user_agent.blank?
@@ -34,4 +73,21 @@ module DetectBrowserDevice
'desktop'
end
def os(user_agent)
return if user_agent.blank?
sdk = user_agent[SDK_USER_AGENT_REGEXP, :sdk]
return sdk.downcase if sdk
case user_agent
when WINDOWS_USER_AGENT_REGEXP then 'windows'
when ANDROID_USER_AGENT_REGEXP then 'android'
when IOS_USER_AGENT_REGEXP then 'ios'
when MACOS_USER_AGENT_REGEXP then 'macos'
when LINUX_USER_AGENT_REGEXP then 'linux'
else 'other'
end
end
end
+5
View File
@@ -8,9 +8,14 @@ module EmailMessages
ASSET_REGEXP = Regexp.union(STYLE_REGEXP, BASE64_REGEXP)
ASSET_PREFIX = '[[asset:'
PLACEHOLDER_REGEXP = /\[\[asset:(\h{40})\]\]/
HTML_MIME_TYPES = ['text/html', 'application/xhtml+xml'].freeze
module_function
def html_body?(content)
content.present? && HTML_MIME_TYPES.include?(Marcel::MimeType.for(content.dup))
end
def find_or_create_for_account_user(account, user, subject, body)
subject = I18n.t(:you_are_invited_to_sign_a_document) if subject.blank?
+8 -3
View File
@@ -146,10 +146,14 @@ module Leptonica
end
def build_pix(image)
buffer = image.write_to_memory
raise LeptonicaError, 'Failed to read image' if buffer.bytesize != image.width * image.height * 4
pix = checked(pixCreate(image.width, image.height, 32), 'Failed to read image')
pixSetSpp(pix, 3)
pixGetData(pix).put_bytes(0, image.write_to_memory)
pixGetData(pix).put_bytes(0, buffer)
raise LeptonicaError, 'Failed to read image' unless pixEndianByteSwap(pix).zero?
@@ -159,9 +163,10 @@ module Leptonica
def load_image(image_data)
image = ImageUtils.load_vips(image_data)
image = image.colourspace(:srgb) if image.interpretation != :srgb
image = image.cast(:uchar) if image.format != :uchar
image = image.bandjoin(255) unless image.has_alpha?
image = image.colourspace(:srgb) if image.interpretation != :srgb
image = image.extract_band(0, n: 4) if image.bands > 4
image = image.bandjoin([255] * (4 - image.bands)) if image.bands < 4
image
end

Some files were not shown because too many files have changed in this diff Show More