Compare commits

..
32 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
69 changed files with 948 additions and 711 deletions

No files matched your search

+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)
+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
+1
View File
@@ -164,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,
+19 -1
View File
@@ -106,11 +106,29 @@ img.ProseMirror-separator {
}
`)
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 }
+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')
}
+5
View File
@@ -1575,6 +1575,7 @@ export default {
}
},
goToStep (stepIndex, scrollToArea = false, clickUpload = false) {
this.isInvite = false
this.currentStep = stepIndex
this.showFillAllRequiredFields = false
@@ -1599,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
}
@@ -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]')]
+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()
+2
View File
@@ -48,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'
@@ -59,6 +60,7 @@ 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'
+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

+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') %>
+1 -1
View File
@@ -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 %>
@@ -47,7 +47,7 @@
<% 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_submitters.size < 5 && 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 %>
@@ -57,7 +57,7 @@
<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>
@@ -78,16 +78,18 @@
</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>
+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 %>
+3 -2
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] } %>
@@ -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>
+1 -1
View File
@@ -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>
+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 %>
+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 %>
@@ -5,7 +5,7 @@
<% 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 %>
@@ -39,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>
@@ -105,7 +105,7 @@
<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>
@@ -190,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]
+7
View File
@@ -930,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.
@@ -2007,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.
@@ -3084,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.
@@ -4158,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é."
@@ -5235,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.
@@ -6312,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.
@@ -7790,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.
+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
+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
-66
View File
@@ -1,66 +0,0 @@
# frozen_string_literal: true
module Mcp
module HandleRequest
TOOLS = [
Mcp::Tools::SearchTemplates,
Mcp::Tools::LoadTemplate,
Mcp::Tools::CreateTemplate,
Mcp::Tools::SendDocuments,
Mcp::Tools::SearchDocuments
].freeze
TOOLS_SCHEMA = TOOLS.map { |t| t::SCHEMA }
TOOLS_INDEX = TOOLS.index_by { |t| t::SCHEMA[:name] }
module_function
# rubocop:disable Metrics/MethodLength
def call(body, current_user, current_ability)
case body['method']
when 'initialize'
{
jsonrpc: '2.0',
id: body['id'],
result: {
protocolVersion: '2025-11-25',
serverInfo: {
name: 'DocuSeal',
version: Docuseal.version.to_s
},
capabilities: {
tools: {
listChanged: false
}
}
}
}
when 'notifications/initialized'
nil
when 'ping'
{ jsonrpc: '2.0', id: body['id'], result: {} }
when 'tools/list'
{ jsonrpc: '2.0', id: body['id'], result: { tools: TOOLS_SCHEMA } }
when 'tools/call'
tool = TOOLS_INDEX[body.dig('params', 'name')]
raise "Unknown tool: #{body.dig('params', 'name')}" unless tool
result = tool.call(body.dig('params', 'arguments') || {}, current_user, current_ability)
{ jsonrpc: '2.0', id: body['id'], result: }
else
{
jsonrpc: '2.0',
id: body['id'],
error: {
code: -32_601,
message: "Method not found: #{body['method']}"
}
}
end
end
# rubocop:enable Metrics/MethodLength
end
end
-102
View File
@@ -1,102 +0,0 @@
# frozen_string_literal: true
module Mcp
module Tools
module CreateTemplate
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
module_function
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
def call(arguments, current_user, current_ability)
current_ability.authorize!(:create, Template.new(account_id: current_user.account_id, author: current_user))
account = current_user.account
template = Template.new(
account:,
author: current_user,
folder: account.default_template_folder,
source: :mcp,
name: arguments['name'].to_s.presence || 'New Template',
fields: [],
schema: []
)
if arguments['url'].present?
tempfile = Tempfile.new
tempfile.binmode
tempfile.write(DownloadUtils.call(arguments['url'], validate: true).body)
tempfile.rewind
filename = File.basename(URI.decode_www_form_component(arguments['url']))
file = ActionDispatch::Http::UploadedFile.new(
tempfile:,
filename:,
type: Marcel::MimeType.for(tempfile)
)
template.name = arguments['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)
{
content: [
{
type: 'text',
text: {
id: template.id,
name: template.name,
edit_url: Rails.application.routes.url_helpers.edit_template_url(template,
**Docuseal.default_url_options)
}.to_json
}
]
}
end
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
end
end
end
-67
View File
@@ -1,67 +0,0 @@
# frozen_string_literal: true
module Mcp
module Tools
module LoadTemplate
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
module_function
def call(arguments, _current_user, current_ability)
template = Template.accessible_by(current_ability).find_by(id: arguments['template_id'])
if !template || !current_ability.can?(:read, template)
return { content: [{ type: 'text', text: 'Template not found' }], isError: true }
end
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
{
content: [
{
type: 'text',
text: {
id: template.id,
name: template.name,
roles: roles,
fields: fields
}.to_json
}
]
}
end
end
end
end
-65
View File
@@ -1,65 +0,0 @@
# frozen_string_literal: true
module Mcp
module Tools
module SearchDocuments
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
module_function
def call(arguments, current_user, current_ability)
submissions = Submissions.search(current_user, Submission.accessible_by(current_ability).active,
arguments['q'], search_template: true)
limit = arguments.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|
url = Rails.application.routes.url_helpers.submission_url(
submission.id, **Docuseal.default_url_options
)
{
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: url
}
end
{ content: [{ type: 'text', text: data.to_json }] }
end
end
end
end
-53
View File
@@ -1,53 +0,0 @@
# frozen_string_literal: true
module Mcp
module Tools
module SearchTemplates
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
module_function
def call(arguments, current_user, current_ability)
templates = Templates.search(current_user, Template.accessible_by(current_ability).active, arguments['q'])
limit = arguments.fetch('limit', 10).to_i
limit = 10 if limit <= 0
limit = [limit, 100].min
templates = templates.order(id: :desc).limit(limit)
{
content: [
{
type: 'text',
text: templates.map { |t| { id: t.id, name: t.name } }.to_json
}
]
}
end
end
end
end
-140
View File
@@ -1,140 +0,0 @@
# frozen_string_literal: true
module Mcp
module Tools
module SendDocuments
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
module_function
# rubocop:disable Metrics
def call(arguments, current_user, current_ability)
template = Template.accessible_by(current_ability).find_by(id: arguments['template_id'])
if !template || !current_ability.can?(:read, template)
return { content: [{ type: 'text', text: 'Template not found' }], isError: true }
end
if template.archived_at?
return { content: [{ type: 'text', text: 'Template has been archived' }], isError: true }
end
current_ability.authorize!(:create, Submission.new(template:, account_id: current_user.account_id))
return { content: [{ type: 'text', text: 'Template has no fields' }], isError: true } if template.fields.blank?
submitters = (arguments['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:,
user: current_user,
source: :mcp,
submitters_order: template.preferences['submitters_order'].presence || 'random',
submissions_attrs: { submitters: },
params: { 'send_email' => true, 'submitters' => submitters }
)
if submissions.blank?
return { content: [{ type: 'text', text: 'No valid submitters provided' }], isError: true }
end
WebhookUrls.enqueue_events(submissions, 'submission.created')
Submissions.send_signature_requests(submissions)
SearchEntries.enqueue_reindex(submissions)
submission = submissions.first
{
content: [
{
type: 'text',
text: {
id: submission.id,
status: 'pending'
}.to_json
}
]
}
rescue Submissions::CreateFromSubmitters::BaseError => e
{ content: [{ type: 'text', text: e.message }], isError: true }
end
# rubocop:enable Metrics
end
end
end
+3 -1
View File
@@ -35,7 +35,9 @@ module ReplaceEmailVariables
text = replace(text, TEMPLATE_NAME, html_escape:) do
(submitter.template || submitter.submission.template || submitter.submission).name
end
text = replace(text, SUBMISSION_NAME, html_escape:) { submitter.submission.name }
text = replace(text, SUBMISSION_NAME, html_escape:) do
submitter.submission.name.presence || submitter.submission.template&.name
end
text = replace(text, TEMPLATE_ID, html_escape:) { submitter.template.id }
text = replace(text, SUBMITTER_ID, html_escape:) { submitter.id }
text = replace(text, SUBMITTER_SLUG, html_escape:) { submitter.slug }
+17 -8
View File
@@ -157,24 +157,21 @@ module Submissions
submissions.each_with_index do |submission, index|
delay_seconds = (delay + index).seconds if delay
template_submitters = submission.template_submitters
submitters_index = submission.submitters.reject(&:completed_at?).index_by(&:uuid)
if template_submitters.any? { |s| s['order'] }
min_order = template_submitters.map.with_index { |s, i| s['order'] || i }.min
first_submitters = template_submitters.filter_map do |s|
submitters_index[s['uuid']] if s['order'] == min_order
end
if submission.template_submitters.any? { |s| s['order'] }
first_submitters = find_first_order_submitters(submission, submitters_index)
Submitters.send_signature_requests(first_submitters, delay_seconds:)
elsif submission.submitters_order_preserved?
first_submitter = template_submitters.filter_map { |s| submitters_index[s['uuid']] }.find { |s| !s.viewer? }
first_submitter = find_first_submitter(submission, submitters_index)
if first_submitter
first_viewers = find_first_viewers(first_submitter, submitters_index)
Submitters.send_signature_requests([first_submitter, *first_viewers], delay_seconds:)
elsif submission.submitters.all?(&:viewer?)
Submitters.send_signature_requests(submitters_index.values, delay_seconds:)
end
else
Submitters.send_signature_requests(submitters_index.values, delay_seconds:)
@@ -182,6 +179,18 @@ module Submissions
end
end
def find_first_order_submitters(submission, submitters_index)
template_submitters = submission.template_submitters
min_order = template_submitters.map.with_index { |s, i| s['order'] || i }.min
template_submitters.filter_map { |s| submitters_index[s['uuid']] if s['order'] == min_order }
end
def find_first_submitter(submission, submitters_index)
submission.template_submitters.filter_map { |s| submitters_index[s['uuid']] }.find { |s| !s.viewer? }
end
def find_first_viewers(first_submitter, submitters_index)
first_submitter.submission.template_submitters.each_with_object([]) do |s, viewers|
submitter = submitters_index[s['uuid']]
@@ -18,7 +18,8 @@ module Submissions
AccountConfig::WITH_TIMESTAMP_SECONDS_KEY,
AccountConfig::ROTATE_INCREMENTAL_PDF_KEY,
AccountConfig::WITH_FILE_LINKS_KEY,
AccountConfig::WITH_SIGNATURE_ID_REASON_KEY])
AccountConfig::WITH_SIGNATURE_ID_REASON_KEY,
AccountConfig::WITH_SIGNATURE_ID_COMPLETED_AT_KEY])
with_signature_id = configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID }&.value == true
with_file_links = configs.find { |c| c.key == AccountConfig::WITH_FILE_LINKS_KEY }&.value == true
@@ -28,6 +29,8 @@ module Submissions
with_timestamp_seconds = configs.find { |c| c.key == AccountConfig::WITH_TIMESTAMP_SECONDS_KEY }&.value == true
with_signature_id_reason =
configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID_REASON_KEY }&.value != false
with_signature_id_completed_at =
configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID_COMPLETED_AT_KEY }&.value == true
file_links_expire_at = Accounts.link_expires_at(submission.account) if with_file_links
@@ -45,7 +48,7 @@ module Submissions
with_signature_id:, is_flatten:, with_headings: index.zero?,
with_submitter_timezone:, with_file_links:,
file_links_expire_at:, with_signature_id_reason:,
with_timestamp_seconds:)
with_timestamp_seconds:, with_signature_id_completed_at:)
end
template = submission.template
+14 -6
View File
@@ -140,7 +140,8 @@ module Submissions
AccountConfig::WITH_TIMESTAMP_SECONDS_KEY,
AccountConfig::ROTATE_INCREMENTAL_PDF_KEY,
AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY,
AccountConfig::WITH_SIGNATURE_ID_REASON_KEY])
AccountConfig::WITH_SIGNATURE_ID_REASON_KEY,
AccountConfig::WITH_SIGNATURE_ID_COMPLETED_AT_KEY])
with_signature_id = configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID }&.value == true
is_flatten = configs.find { |c| c.key == AccountConfig::FLATTEN_RESULT_PDF_KEY }&.value != false
@@ -150,6 +151,8 @@ module Submissions
with_file_links = configs.find { |c| c.key == AccountConfig::WITH_FILE_LINKS_KEY }&.value == true
with_signature_id_reason =
configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID_REASON_KEY }&.value != false
with_signature_id_completed_at =
configs.find { |c| c.key == AccountConfig::WITH_SIGNATURE_ID_COMPLETED_AT_KEY }&.value == true
file_links_expire_at = Accounts.link_expires_at(submitter.account) if with_file_links
@@ -201,12 +204,14 @@ module Submissions
with_file_links:,
with_timestamp_seconds:,
with_signature_id_reason:,
with_signature_id_completed_at:,
file_links_expire_at:)
end
def fill_submitter_fields(submitter, account, pdfs_index, with_signature_id:, is_flatten:, with_headings: nil,
with_submitter_timezone: false, with_signature_id_reason: true,
with_timestamp_seconds: false, with_file_links: nil,
with_timestamp_seconds: false, with_signature_id_completed_at: false,
with_file_links: nil,
file_links_expire_at: Accounts.link_expires_at(account))
cell_layouters = Hash.new do |hash, valign|
hash[valign] = HexaPDF::Layout::TextLayouter.new(text_valign: valign.to_sym, text_align: :center)
@@ -337,14 +342,17 @@ module Submissions
time_format = with_timestamp_seconds ? :detailed : :long
signature_timestamp =
(with_signature_id_completed_at ? submitter.completed_at : nil) || attachment.created_at
if with_signature_id_reason || field.dig('preferences', 'reasons').present?
"#{"#{I18n.t('reason')}: " if reason_value}#{reason_value || I18n.t('digitally_signed_by')} " \
"#{submitter.name}#{" <#{submitter.email}>" if submitter.email.present?}\n" \
"#{I18n.l(attachment.created_at.in_time_zone(timezone), format: time_format)} " \
"#{TimeUtils.timezone_abbr(timezone, attachment.created_at)}"
"#{I18n.l(signature_timestamp.in_time_zone(timezone), format: time_format)} " \
"#{TimeUtils.timezone_abbr(timezone, signature_timestamp)}"
else
"#{I18n.l(attachment.created_at.in_time_zone(timezone), format: time_format)} " \
"#{TimeUtils.timezone_abbr(timezone, attachment.created_at)}"
"#{I18n.l(signature_timestamp.in_time_zone(timezone), format: time_format)} " \
"#{TimeUtils.timezone_abbr(timezone, signature_timestamp)}"
end
end
+2 -2
View File
@@ -213,9 +213,9 @@ module Submitters
def build_document_filename(submitter, blob, filename_format)
return blob.filename.to_s if filename_format.blank?
filename = ReplaceEmailVariables.call(filename_format, submitter:)
filename = filename_format.gsub('{document.name}', blob.filename.base)
filename = ReplaceEmailVariables.call(filename, submitter:)
filename = filename.gsub('{document.name}', blob.filename.base)
filename = filename.gsub(' - {submission.status}') do
if submitter.submission.completed_at?
status =
+5 -1
View File
@@ -18,6 +18,7 @@ module Submitters
AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY,
AccountConfig::WITH_TIMESTAMP_SECONDS_KEY,
AccountConfig::WITH_SIGNATURE_ID_REASON_KEY,
AccountConfig::WITH_SIGNATURE_ID_COMPLETED_AT_KEY,
*(Docuseal.multitenant? ? [] : [AccountConfig::POLICY_LINKS_KEY])].freeze
module_function
@@ -40,6 +41,8 @@ module Submitters
with_submitter_timezone = find_safe_value(configs, AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY) == true
with_timestamp_seconds = find_safe_value(configs, AccountConfig::WITH_TIMESTAMP_SECONDS_KEY) == true
with_signature_id_reason = find_safe_value(configs, AccountConfig::WITH_SIGNATURE_ID_REASON_KEY) != false
with_signature_id_completed_at =
find_safe_value(configs, AccountConfig::WITH_SIGNATURE_ID_COMPLETED_AT_KEY) == true
with_field_labels = find_safe_value(configs, AccountConfig::WITH_FIELD_LABELS_KEY) != false
policy_links = find_safe_value(configs, AccountConfig::POLICY_LINKS_KEY)
@@ -47,7 +50,8 @@ module Submitters
reuse_signature:, with_decline:, with_delegate:, with_partial_download:,
policy_links:, enforce_signing_order:, completed_message:,
require_signing_reason:, prefill_signature:, with_submitter_timezone:,
with_signature_id_reason:, with_signature_id:, with_field_labels:, with_timestamp_seconds: }
with_signature_id_reason:, with_signature_id:, with_field_labels:, with_timestamp_seconds:,
with_signature_id_completed_at: }
keys.each do |key|
attrs[key.to_sym] = configs.find { |e| e.key == key.to_s }&.value
+13 -5
View File
@@ -286,7 +286,10 @@ module Submitters
attachments_index =
if has_document_conditions
Submissions.filtered_conditions_schema(submission).index_by { |i| i['attachment_uuid'] }
submitters_values = merge_submitters_values(submitter)
Submissions.filtered_conditions_schema(submission, values: submitters_values)
.index_by { |i| i['attachment_uuid'] }
end
submission.template_fields.each do |field|
@@ -295,8 +298,7 @@ module Submitters
required_field_uuids_acc.add(field['uuid']) if required_field_uuids_acc && required_editable_field?(field)
if has_document_conditions && !check_field_areas_attachments(field, attachments_index)
submitter.values.delete(field['uuid'])
required_field_uuids_acc&.delete(field['uuid'])
delete_field_value!(field, submitter, submitters_values, required_field_uuids_acc)
end
if has_other_submitters && !submitters_values &&
@@ -305,14 +307,19 @@ module Submitters
end
unless check_field_conditions(submitters_values || submitter.values, field, submission.fields_uuid_index)
submitter.values.delete(field['uuid'])
required_field_uuids_acc&.delete(field['uuid'])
delete_field_value!(field, submitter, submitters_values, required_field_uuids_acc)
end
end
submitter.values
end
def delete_field_value!(field, submitter, submitters_values = nil, required_field_uuids_acc = nil)
submitter.values.delete(field['uuid'])
submitters_values&.delete(field['uuid'])
required_field_uuids_acc&.delete(field['uuid'])
end
def submission_has_document_conditions?(submission)
(submission.template_schema || submission.template.schema).any? { |e| e['conditions'].present? }
end
@@ -331,6 +338,7 @@ module Submitters
def merge_submitters_values(submitter)
submitter.submission.submitters
.reject { |sub| sub.uuid == submitter.uuid }
.reduce({}) { |acc, sub| acc.merge(sub.values) }
.merge(submitter.values)
end
-1
View File
@@ -59,7 +59,6 @@ module Templates
.select(1).arel.exists
Template.where(id: TemplateSharing.where(account_id: shared_account_ids).select(:template_id))
.where(account_id: account.linked_account_account.account_id)
.where.not(exists_access)
end
+13 -6
View File
@@ -4,9 +4,9 @@ module Templates
module SerializeForApi
SERIALIZE_PARAMS = {
only: %w[
id archived_at fields name preferences schema
slug source submitters created_at updated_at
author_id external_id folder_id shared_link
id name slug schema submitters fields variables_schema
preferences source created_at updated_at archived_at author_id
external_id folder_id shared_link
],
methods: %i[application_key folder_name],
include: { author: { only: %i[id email first_name last_name] } }
@@ -22,7 +22,16 @@ module Templates
preview_image_attachments ||= preload_preview_image_attachments(schema_documents, dynamic_documents)
json['documents'] = template.schema.filter_map do |item|
json['variables_schema'] ||= {}
json['documents'] = build_documents_array(template, schema_documents, dynamic_documents,
preview_image_attachments, expires_at)
json
end
def build_documents_array(template, schema_documents, dynamic_documents, preview_image_attachments, expires_at)
template.schema.filter_map do |item|
if item['dynamic']
dynamic_document = dynamic_documents.find { |e| e.uuid == item['attachment_uuid'] }
@@ -44,8 +53,6 @@ module Templates
'filename' => attachment.filename
}
end
json
end
def preload_dynamic_documents(template)
+1 -1
View File
@@ -5,7 +5,7 @@ FactoryBot.define do
account
author factory: %i[user]
name { Faker::Book.title }
name { Faker::Book.unique.title }
trait :with_templates do
after(:create) do |template_folder|
+1 -1
View File
@@ -5,7 +5,7 @@ FactoryBot.define do
account
author factory: %i[user]
name { Faker::Book.title }
name { Faker::Book.unique.title }
transient do
submitter_count { 1 }
+1
View File
@@ -65,6 +65,7 @@ RSpec.configure do |config|
config.before do
Sidekiq::Worker.clear_all
Faker::UniqueGenerator.clear
end
config.before do |example|
+1
View File
@@ -230,6 +230,7 @@ describe 'Templates API' do
name: 'sample-document'
}
],
variables_schema: template.variables_schema || {},
shared_link: template.shared_link,
author_id: author.id,
archived_at: nil,