Merge from docusealco/wip
This commit is contained in:
@@ -30,7 +30,9 @@ jobs:
|
|||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Create .version file
|
- 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
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
|
|||||||
+4
-4
@@ -149,7 +149,7 @@ GEM
|
|||||||
crack (1.0.1)
|
crack (1.0.1)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
rexml
|
rexml
|
||||||
crass (1.0.6)
|
crass (1.0.7)
|
||||||
csv (3.3.5)
|
csv (3.3.5)
|
||||||
csv-safe (3.3.1)
|
csv-safe (3.3.1)
|
||||||
csv (~> 3.0)
|
csv (~> 3.0)
|
||||||
@@ -293,7 +293,7 @@ GEM
|
|||||||
activesupport (>= 4)
|
activesupport (>= 4)
|
||||||
railties (>= 4)
|
railties (>= 4)
|
||||||
request_store (~> 1.0)
|
request_store (~> 1.0)
|
||||||
loofah (2.25.1)
|
loofah (2.25.2)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.12.0)
|
nokogiri (>= 1.12.0)
|
||||||
mail (2.9.0)
|
mail (2.9.0)
|
||||||
@@ -404,8 +404,8 @@ GEM
|
|||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
nokogiri (>= 1.6)
|
nokogiri (>= 1.6)
|
||||||
rails-html-sanitizer (1.7.0)
|
rails-html-sanitizer (1.7.1)
|
||||||
loofah (~> 2.25)
|
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)
|
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)
|
rails-i18n (8.1.0)
|
||||||
i18n (>= 0.7, < 2)
|
i18n (>= 0.7, < 2)
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ class EsignSettingsController < ApplicationController
|
|||||||
prepend_before_action :maybe_redirect_com, only: %i[show]
|
prepend_before_action :maybe_redirect_com, only: %i[show]
|
||||||
|
|
||||||
before_action :load_encrypted_config
|
before_action :load_encrypted_config
|
||||||
authorize_resource :encrypted_config, parent: false, only: %i[new create]
|
authorize_resource :encrypted_config, parent: false
|
||||||
authorize_resource :encrypted_config, only: %i[update destroy show]
|
|
||||||
|
|
||||||
def show
|
def show
|
||||||
cert_data = @encrypted_config.value || {}
|
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
|
||||||
@@ -1,58 +1,44 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class McpController < ActionController::API
|
class McpController < ActionController::Metal
|
||||||
before_action :authenticate_user!
|
TOOL_CONTROLLERS = {
|
||||||
before_action :verify_mcp_enabled!
|
'search_templates' => Mcp::SearchTemplatesController,
|
||||||
|
'load_template' => Mcp::LoadTemplateController,
|
||||||
|
'create_template' => Mcp::CreateTemplateController,
|
||||||
|
'send_documents' => Mcp::SendDocumentsController,
|
||||||
|
'search_documents' => Mcp::SearchDocumentsController
|
||||||
|
}.freeze
|
||||||
|
|
||||||
before_action do
|
TOOLS = TOOL_CONTROLLERS.map { |_, controller| controller::SCHEMA }.freeze
|
||||||
authorize!(:manage, :mcp)
|
|
||||||
end
|
|
||||||
|
|
||||||
def call
|
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 = 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
|
action =
|
||||||
render json: result
|
case body&.dig('method')
|
||||||
else
|
when 'initialize' then :initialize_request
|
||||||
head :accepted
|
when 'notifications/initialized' then :initialized_notification
|
||||||
end
|
when 'ping' then :ping
|
||||||
rescue CanCan::AccessDenied
|
when 'tools/list' then :tools_list
|
||||||
render json: { jsonrpc: '2.0', id: nil, error: { code: -32_603, message: 'Forbidden' } }, status: :forbidden
|
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
|
rescue JSON::ParserError
|
||||||
render json: { jsonrpc: '2.0', id: nil, error: { code: -32_700, message: 'Parse error' } }, status: :bad_request
|
request.request_parameters = {}
|
||||||
end
|
|
||||||
|
|
||||||
private
|
Mcp::ProtocolController.dispatch(:parse_error, request, response)
|
||||||
|
|
||||||
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
|
end
|
||||||
|
|||||||
@@ -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) {
|
function collectDomAttrs (dom) {
|
||||||
const attrs = {}
|
const attrs = {}
|
||||||
|
|
||||||
for (let i = 0; i < dom.attributes.length; i++) {
|
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 }
|
return { htmlAttrs: attrs }
|
||||||
|
|||||||
@@ -1,19 +1,5 @@
|
|||||||
export default class extends HTMLElement {
|
export default class extends HTMLElement {
|
||||||
connectedCallback () {
|
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) => {
|
this.button.addEventListener('click', (event) => {
|
||||||
if (!this.input.value && document.activeElement !== this.input) {
|
if (!this.input.value && document.activeElement !== this.input) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -21,16 +7,22 @@ export default class extends HTMLElement {
|
|||||||
this.input.focus()
|
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 () {
|
get input () {
|
||||||
return this.querySelector('input')
|
return this.querySelector('input')
|
||||||
}
|
}
|
||||||
|
|
||||||
get title () {
|
|
||||||
return document.querySelector(this.dataset.title)
|
|
||||||
}
|
|
||||||
|
|
||||||
get button () {
|
get button () {
|
||||||
return this.querySelector('button')
|
return this.querySelector('button')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,7 +169,7 @@
|
|||||||
class="base-input !text-2xl w-full mt-6 text-center"
|
class="base-input !text-2xl w-full mt-6 text-center"
|
||||||
:required="field.required && !isInitialsStarted"
|
:required="field.required && !isInitialsStarted"
|
||||||
:aria-label="field.name || t('initials')"
|
:aria-label="field.name || t('initials')"
|
||||||
:placeholder="`${t('type_initial_here')}...`"
|
:placeholder="`${t('type_initial_here')}${field.required ? '...' : ' (' + t('optional') + ')'}`"
|
||||||
type="text"
|
type="text"
|
||||||
@focus="$emit('focus')"
|
@focus="$emit('focus')"
|
||||||
@input="updateWrittenInitials"
|
@input="updateWrittenInitials"
|
||||||
@@ -293,7 +293,7 @@ export default {
|
|||||||
|
|
||||||
if (!this.isDrawInitials) {
|
if (!this.isDrawInitials) {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
if (this.$refs.textInput) {
|
if (this.$refs.textInput && this.field.required === true) {
|
||||||
this.initTextInitial()
|
this.initTextInitial()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -247,7 +247,7 @@
|
|||||||
class="base-input !text-2xl w-full mt-6"
|
class="base-input !text-2xl w-full mt-6"
|
||||||
:required="field.required && !isSignatureStarted"
|
:required="field.required && !isSignatureStarted"
|
||||||
:aria-label="field.name || t('signature')"
|
:aria-label="field.name || t('signature')"
|
||||||
:placeholder="`${t('type_signature_here')}...`"
|
:placeholder="`${t('type_signature_here')}${field.required ? '...' : ' (' + t('optional') + ')'}`"
|
||||||
type="text"
|
type="text"
|
||||||
@input="updateWrittenSignature"
|
@input="updateWrittenSignature"
|
||||||
>
|
>
|
||||||
@@ -535,7 +535,7 @@ export default {
|
|||||||
this.$nextTick(() => this.drawSignatureSrc())
|
this.$nextTick(() => this.drawSignatureSrc())
|
||||||
} else if (this.isTextSignature) {
|
} else if (this.isTextSignature) {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
if (this.$refs.textInput) {
|
if (this.$refs.textInput && this.field.required === true) {
|
||||||
this.initTypedSignature()
|
this.initTypedSignature()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -98,11 +98,29 @@ dynamic-variable {
|
|||||||
overflow-wrap: anywhere;
|
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) {
|
function collectDomAttrs (dom) {
|
||||||
const attrs = {}
|
const attrs = {}
|
||||||
|
|
||||||
for (let i = 0; i < dom.attributes.length; i++) {
|
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 }
|
return { htmlAttrs: attrs }
|
||||||
|
|||||||
@@ -646,9 +646,7 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = document.createElement('div')
|
const container = new DOMParser().parseFromString(clipboardHtml, 'text/html').body
|
||||||
|
|
||||||
container.innerHTML = clipboardHtml
|
|
||||||
|
|
||||||
const fieldNodes = [...container.querySelectorAll('dynamic-field[data-field][data-area]')]
|
const fieldNodes = [...container.querySelectorAll('dynamic-field[data-field][data-area]')]
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
<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">
|
<span class="flex items-center justify-between space-x-0.5 font-medium">
|
||||||
<%= svg_icon('start', class: 'h-3 w-3') %>
|
<%= svg_icon('start', class: 'h-3 w-3') %>
|
||||||
<span>17k</span>
|
<span>18k</span>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -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| %>
|
<% Submissions::Filter::ALLOWED_PARAMS.each do |key| %>
|
||||||
<% if params[key].present? %>
|
<% if params[key].present? %>
|
||||||
<input name="<%= key %>" value="<%= params[key] %>" class="hidden">
|
<input name="<%= key %>" value="<%= params[key] %>" class="hidden">
|
||||||
@@ -14,8 +14,8 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<search-input data-title="<%= local_assigns[:title_selector] || 'h1' %>" class="flex items-center">
|
<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] %>" 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] %>">
|
<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">
|
<button type="submit" title="<%= t('search') %>" class="btn btn-ghost btn-circle">
|
||||||
<span class="enabled">
|
<span class="enabled">
|
||||||
<%= svg_icon('search', class: 'w-6 h-6 stroke-2') %>
|
<%= svg_icon('search', class: 'w-6 h-6 stroke-2') %>
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
<% filter_params = params.permit(Submissions::Filter::ALLOWED_PARAMS).compact_blank %>
|
<% filter_params = params.permit(Submissions::Filter::ALLOWED_PARAMS).compact_blank %>
|
||||||
<div>
|
<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') %>
|
<%= svg_icon('chevron_left', class: 'w-5 h-5') %>
|
||||||
<span style="margin-left: 3px"><%= t('back_to_active') %></span>
|
<span style="margin-left: 3px"><%= t('back_to_active') %></span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col md:flex-row md:items-center mb-4 gap-3">
|
<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 class="group/header flex w-full justify-between items-center">
|
||||||
<div>
|
<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>
|
<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>
|
<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? %>
|
<% if params[:q].present? || @pagy.pages > 1 || filter_params.present? %>
|
||||||
<%= render 'shared/search_input', placeholder: "#{t('search')}..." %>
|
<%= render 'shared/search_input', placeholder: "#{t('search')}..." %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<% filter_params = params.permit(Submissions::Filter::ALLOWED_PARAMS).compact_blank %>
|
<% 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? %>
|
<% is_show_tabs = (@pagy.count.nil? || @pagy.count >= 5) || params[:status].present? || filter_params.present? %>
|
||||||
<% if Docuseal.demo? %><%= render 'shared/demo_alert' %><% end %>
|
<% if Docuseal.demo? %><%= render 'shared/demo_alert' %><% end %>
|
||||||
<div class="flex justify-between items-center w-full mb-4">
|
<div class="group/header flex justify-between items-center w-full mb-4">
|
||||||
<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? %>">
|
||||||
<div class="mr-2">
|
<div class="mr-2">
|
||||||
<%= render 'dashboard/toggle_view', selected: 'submissions' %>
|
<%= render 'dashboard/toggle_view', selected: 'submissions' %>
|
||||||
</div>
|
</div>
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
<%= t('submissions') %>
|
<%= t('submissions') %>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</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? %>
|
<% if params[:q].present? || @pagy.pages > 1 || filter_params.present? %>
|
||||||
<%= render 'shared/search_input' %>
|
<%= render 'shared/search_input' %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,40 +1,40 @@
|
|||||||
<% query_params = params.permit(:q).merge(filter_params) %>
|
<% 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]] %>
|
<% 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">
|
<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 w-full pr-1 md:max-w-[140px]' do %>
|
<%= 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') %>
|
<%= svg_icon(icon, class: 'w-5 h-5 shrink-0') %>
|
||||||
<span class="font-normal truncate"><%= t(params[:status]) %></span>
|
<span class="font-normal truncate"><%= t(params[:status]) %></span>
|
||||||
<% end %>
|
<% 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') %>
|
<%= svg_icon('x', class: 'w-5 h-5') %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if params[:folder].present? %>
|
<% 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') %>">
|
<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 w-full pr-1 md:max-w-[140px]' do %>
|
<%= 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') %>
|
<%= svg_icon('folder', class: 'w-5 h-5 shrink-0') %>
|
||||||
<span class="font-normal truncate"><%= params[:folder] %></span>
|
<span class="font-normal truncate"><%= params[:folder] %></span>
|
||||||
<% end %>
|
<% 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') %>
|
<%= svg_icon('x', class: 'w-5 h-5') %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if params[:author].present? %>
|
<% 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') %>">
|
<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 w-full pr-1 md:max-w-[140px]' do %>
|
<%= 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') %>
|
<%= 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>
|
<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 %>
|
<% 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') %>
|
<%= svg_icon('x', class: 'w-5 h-5') %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if query_params[:completed_at_from].present? || query_params[:completed_at_to].present? %>
|
<% 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') %>">
|
<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 w-full pr-1 md:max-w-[140px]' do %>
|
<%= 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') %>
|
<%= 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">
|
<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] %>
|
<% if query_params[:completed_at_from] == query_params[:completed_at_to] %>
|
||||||
@@ -46,14 +46,14 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</span>
|
</span>
|
||||||
<% end %>
|
<% 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') %>
|
<%= svg_icon('x', class: 'w-5 h-5') %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if query_params[:created_at_from].present? || query_params[:created_at_to].present? %>
|
<% 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') %>">
|
<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 w-full pr-1 md:max-w-[140px]' do %>
|
<%= 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') %>
|
<%= 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">
|
<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] %>
|
<% if query_params[:created_at_from] == query_params[:created_at_to] %>
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</span>
|
</span>
|
||||||
<% end %>
|
<% 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') %>
|
<%= svg_icon('x', class: 'w-5 h-5') %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<dashboard-dropzone>
|
<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 %>
|
<%= 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="form_id" value="<%= form_id %>">
|
||||||
<input name="folder_name" value="<%= @template_folder.full_name %>">
|
<input name="folder_name" value="<%= @template_folder.full_name %>">
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
<% unless @template_folder.parent_folder %>
|
<% unless @template_folder.parent_folder %>
|
||||||
<%= render 'templates/dashboard_folder_dropzone', style: 'height: 137px' %>
|
<%= render 'templates/dashboard_folder_dropzone', style: 'height: 137px' %>
|
||||||
<% end %>
|
<% 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') %>
|
<%= svg_icon('folder', class: 'w-9 h-9 flex-shrink-0') %>
|
||||||
<span class="peer truncate">
|
<span class="peer truncate">
|
||||||
<%= @template_folder.name %>
|
<%= @template_folder.name %>
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</h1>
|
</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? %>
|
<% if params[:q].present? || @pagy.count.nil? || @pagy.count > 0 || @template_folders.present? %>
|
||||||
<%= render 'shared/search_input' %>
|
<%= render 'shared/search_input' %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -3,15 +3,15 @@
|
|||||||
<% is_show_tabs = @pagy.pages > 1 || params[:q].present? || params[:status].present? || filter_params.present? %>
|
<% 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? %>
|
<% 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="<%= is_show_tabs ? 'mb-4' : 'mb-6' %>">
|
||||||
<div class="flex justify-between items-center md:items-end">
|
<div class="group/header flex justify-between items-center md:items-end">
|
||||||
<div>
|
<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? %>">
|
<h2 class="text-3xl font-bold md:block <%= 'hidden' if params[:q].present? %>">
|
||||||
<%= t('submissions') %>
|
<%= t('submissions') %>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</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 %>
|
<% 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 %>
|
<% 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 %>
|
<%= 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') %>
|
<%= svg_icon('download', class: 'w-6 h-6 stroke-2') %>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<div>
|
<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') %>
|
<%= svg_icon('chevron_left', class: 'w-5 h-5') %>
|
||||||
<span style="margin-left: 3px"><%= t('back_to_active') %></span>
|
<span style="margin-left: 3px"><%= t('back_to_active') %></span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between mb-4 items-center">
|
<div class="group/header flex justify-between mb-4 items-center">
|
||||||
<div>
|
<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>
|
<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>
|
</div>
|
||||||
<% if params[:q].present? || @pagy.pages > 1 %>
|
<% if params[:q].present? || @pagy.pages > 1 %>
|
||||||
|
|||||||
@@ -1,33 +1,33 @@
|
|||||||
<% filter_params = params.permit(Submissions::Filter::ALLOWED_PARAMS).compact_blank %>
|
<% filter_params = params.permit(Submissions::Filter::ALLOWED_PARAMS).compact_blank %>
|
||||||
<% with_filters = @pagy.pages > 1 || params[:q].present? || filter_params.present? %>
|
<% with_filters = @pagy.pages > 1 || params[:q].present? || filter_params.present? %>
|
||||||
<%= render 'templates/title', template: @template %>
|
<%= render 'templates/title', template: @template %>
|
||||||
<div class="flex flex-col md:flex-row md:items-center mb-6 gap-3">
|
<div>
|
||||||
<div class="flex w-full justify-between md:items-end items-end">
|
<%= link_to template_path(@template), class: 'flex items-center mb-1 md:mb-0' do %>
|
||||||
<div>
|
<%= svg_icon('chevron_left', class: 'w-5 h-5') %>
|
||||||
<div>
|
<span style="margin-left: 3px"><%= t('back_to_active') %></span>
|
||||||
<%= link_to template_path(@template), class: 'flex items-center' do %>
|
<% end %>
|
||||||
<%= svg_icon('chevron_left', class: 'w-5 h-5') %>
|
</div>
|
||||||
<span style="margin-left: 3px"><%= t('back_to_active') %></span>
|
<div class="flex flex-col md:flex-row md:items-center mb-4 gap-3">
|
||||||
<% end %>
|
<div class="group/header flex w-full md:flex-1 min-w-0 justify-between items-center">
|
||||||
</div>
|
<div class="max-md:group-has-[search-input:focus-within]/header:hidden">
|
||||||
<h1 class="text-3xl font-bold md:block"><%= t('submissions') %> <span class="badge badge-outline badge-lg align-middle"><%= t('archived') %></span></h1>
|
<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>
|
||||||
<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 %>
|
<% if with_filters %>
|
||||||
<%= render 'shared/search_input' %>
|
<%= render 'shared/search_input' %>
|
||||||
<% end %>
|
<% 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') %>
|
<%= svg_icon('download', class: 'w-6 h-6 stroke-2') %>
|
||||||
<span><%= t('export') %></span>
|
<span><%= t('export') %></span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
<%= render 'submissions_filters/filter_button', filter_params: %>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<% if @pagy.count.nil? || @pagy.count > 0 %>
|
<% if @pagy.count.nil? || @pagy.count > 0 %>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
|||||||
@@ -7,12 +7,12 @@
|
|||||||
<button type="submit"></button>
|
<button type="submit"></button>
|
||||||
<input id="dashboard_dropzone_input" name="files[]" type="file" multiple>
|
<input id="dashboard_dropzone_input" name="files[]" type="file" multiple>
|
||||||
<% end %>
|
<% 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 %>
|
<% unless show_dropzone %>
|
||||||
<%= render 'templates/dashboard_dropzone', style: 'height: 114px' %>
|
<%= render 'templates/dashboard_dropzone', style: 'height: 114px' %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= render 'templates/dashboard_folder_dropzone', style: 'height: 114px' %>
|
<%= 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? %>
|
<% if has_archived || @pagy.count.nil? || @pagy.count > 0 || @template_folders.present? %>
|
||||||
<div class="mr-2">
|
<div class="mr-2">
|
||||||
<%= render 'dashboard/toggle_view', selected: 'templates' %>
|
<%= render 'dashboard/toggle_view', selected: 'templates' %>
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<%= t('document_templates_html') %>
|
<%= t('document_templates_html') %>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</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? %>
|
<% if params[:q].present? || @pagy.count.nil? || @pagy.count > 1 || @template_folders.present? %>
|
||||||
<%= render 'shared/search_input' %>
|
<%= render 'shared/search_input' %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
<div>
|
<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') %>
|
<%= svg_icon('chevron_left', class: 'w-5 h-5') %>
|
||||||
<span style="margin-left: 3px"><%= @is_archived ? t('back_to_active') : t('home') %></span>
|
<span style="margin-left: 3px"><%= @is_archived ? t('back_to_active') : t('home') %></span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
||||||
<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') %>
|
<%= svg_icon('folder', class: 'w-9 h-9 flex-shrink-0') %>
|
||||||
<span class="truncate capitalize"><%= t('shared') %></span>
|
<span class="truncate capitalize"><%= t('shared') %></span>
|
||||||
<% if @is_archived %>
|
<% if @is_archived %>
|
||||||
<span class="badge badge-outline badge-lg align-middle"><%= t('archived') %></span>
|
<span class="badge badge-outline badge-lg align-middle"><%= t('archived') %></span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</h1>
|
</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 %>
|
<% if params[:q].present? || @pagy.count.nil? || @pagy.count > 1 %>
|
||||||
<%= render 'shared/search_input' %>
|
<%= render 'shared/search_input' %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -146,6 +146,8 @@ Rails.application.configure do
|
|||||||
|
|
||||||
current_user = controller.instance_variable_get(:@current_user)
|
current_user = controller.instance_variable_get(:@current_user)
|
||||||
|
|
||||||
|
os = DetectBrowserDevice.os(controller.request.user_agent) if ENV['MULTITENANT'] == 'true'
|
||||||
|
|
||||||
{
|
{
|
||||||
host: controller.request.host,
|
host: controller.request.host,
|
||||||
fwd: controller.request.remote_ip,
|
fwd: controller.request.remote_ip,
|
||||||
@@ -161,6 +163,7 @@ Rails.application.configure do
|
|||||||
params[:submit_form_slug] ||
|
params[:submit_form_slug] ||
|
||||||
params[:template_slug]).to_s.first(5)
|
params[:template_slug]).to_s.first(5)
|
||||||
}.compact_blank,
|
}.compact_blank,
|
||||||
|
**(os ? { os: } : {}),
|
||||||
uid: current_user.try(:id),
|
uid: current_user.try(:id),
|
||||||
aid: current_user.try(:account_id),
|
aid: current_user.try(:account_id),
|
||||||
rid: resource.try(:id),
|
rid: resource.try(:id),
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
Rack::Request.forwarded_priority = %i[x_forwarded]
|
||||||
@@ -930,6 +930,7 @@ en: &en
|
|||||||
please_verify_your_email_address_to_continue: Please verify your email address to continue.
|
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.
|
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_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
|
verify_your_email: Verify your email
|
||||||
your_email_has_been_confirmed: Your email has been confirmed.
|
your_email_has_been_confirmed: Your email has been confirmed.
|
||||||
invalid_or_expired_verification_code: Invalid or expired verification code.
|
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.
|
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.
|
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_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
|
verify_your_email: Verificar tu correo electrónico
|
||||||
your_email_has_been_confirmed: Tu correo electrónico ha sido confirmado.
|
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.
|
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.
|
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."
|
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_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
|
verify_your_email: Verifica la tua email
|
||||||
your_email_has_been_confirmed: La tua email è stata confermata.
|
your_email_has_been_confirmed: La tua email è stata confermata.
|
||||||
invalid_or_expired_verification_code: Codice di verifica non valido o scaduto.
|
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."
|
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."
|
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_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"
|
verify_your_email: "Vérifier votre e-mail"
|
||||||
your_email_has_been_confirmed: "Votre adresse e-mail a été confirmée."
|
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é."
|
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.
|
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.
|
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_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
|
verify_your_email: Verificar seu e-mail
|
||||||
your_email_has_been_confirmed: Seu e-mail foi confirmado.
|
your_email_has_been_confirmed: Seu e-mail foi confirmado.
|
||||||
invalid_or_expired_verification_code: Código de verificação inválido ou expirado.
|
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.
|
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.
|
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_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
|
verify_your_email: E-Mail bestätigen
|
||||||
your_email_has_been_confirmed: Ihre E-Mail-Adresse wurde bestätigt.
|
your_email_has_been_confirmed: Ihre E-Mail-Adresse wurde bestätigt.
|
||||||
invalid_or_expired_verification_code: Ungültiger oder abgelaufener Verifizierungscode.
|
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.
|
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.
|
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_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
|
verify_your_email: Uw e-mail verifiëren
|
||||||
your_email_has_been_confirmed: Uw e-mailadres is bevestigd.
|
your_email_has_been_confirmed: Uw e-mailadres is bevestigd.
|
||||||
invalid_or_expired_verification_code: Ongeldige of verlopen verificatiecode.
|
invalid_or_expired_verification_code: Ongeldige of verlopen verificatiecode.
|
||||||
|
|||||||
@@ -26,6 +26,45 @@ module DetectBrowserDevice
|
|||||||
Silk
|
Silk
|
||||||
/ix
|
/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)
|
def call(user_agent)
|
||||||
return if user_agent.blank?
|
return if user_agent.blank?
|
||||||
|
|
||||||
@@ -34,4 +73,21 @@ module DetectBrowserDevice
|
|||||||
|
|
||||||
'desktop'
|
'desktop'
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
+8
-3
@@ -146,10 +146,14 @@ module Leptonica
|
|||||||
end
|
end
|
||||||
|
|
||||||
def build_pix(image)
|
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')
|
pix = checked(pixCreate(image.width, image.height, 32), 'Failed to read image')
|
||||||
|
|
||||||
pixSetSpp(pix, 3)
|
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?
|
raise LeptonicaError, 'Failed to read image' unless pixEndianByteSwap(pix).zero?
|
||||||
|
|
||||||
@@ -159,9 +163,10 @@ module Leptonica
|
|||||||
def load_image(image_data)
|
def load_image(image_data)
|
||||||
image = ImageUtils.load_vips(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.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
|
image
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -5,7 +5,7 @@ FactoryBot.define do
|
|||||||
account
|
account
|
||||||
|
|
||||||
author factory: %i[user]
|
author factory: %i[user]
|
||||||
name { Faker::Book.title }
|
name { Faker::Book.unique.title }
|
||||||
|
|
||||||
trait :with_templates do
|
trait :with_templates do
|
||||||
after(:create) do |template_folder|
|
after(:create) do |template_folder|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ FactoryBot.define do
|
|||||||
account
|
account
|
||||||
|
|
||||||
author factory: %i[user]
|
author factory: %i[user]
|
||||||
name { Faker::Book.title }
|
name { Faker::Book.unique.title }
|
||||||
|
|
||||||
transient do
|
transient do
|
||||||
submitter_count { 1 }
|
submitter_count { 1 }
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ RSpec.configure do |config|
|
|||||||
|
|
||||||
config.before do
|
config.before do
|
||||||
Sidekiq::Worker.clear_all
|
Sidekiq::Worker.clear_all
|
||||||
|
Faker::UniqueGenerator.clear
|
||||||
end
|
end
|
||||||
|
|
||||||
config.before do |example|
|
config.before do |example|
|
||||||
|
|||||||
Reference in New Issue
Block a user