Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2d8b85549 | |||
| 4d68783b82 | |||
| b05c3b2030 | |||
| 784ae9aa4b | |||
| 03b5956cbc | |||
| 2876c9a18e | |||
| c4b7be3925 | |||
| e2ffbdac1d | |||
| 0d197bb2eb | |||
| 55089bee7f | |||
| b96fe74db1 | |||
| 2fc1ba3037 | |||
| c67d37207e | |||
| dba50cd1bf | |||
| edf2c0d9ed | |||
| 35f7c36f28 | |||
| 3a24ecac63 | |||
| 50ec7682b5 | |||
| 3667993caf | |||
| 68e8cd1188 | |||
| 3eebf6d2cf | |||
| 34d0e00910 | |||
| b8ab7190d2 | |||
| 59fe8a7917 | |||
| d56070827b | |||
| 74dbd4bed3 | |||
| 807e82b21e | |||
| 66862e93c8 | |||
| 270461ad3b | |||
| 3edd1ab32d | |||
| e77998e53c | |||
| 1421bc2bce | |||
| cbbbfdda80 | |||
| 2d510081be | |||
| 704893ee1d | |||
| 88612913a6 | |||
| ce6823ed38 | |||
| 9074d23b3c | |||
| 76d0e76fd5 | |||
| 5ec8e4ccc7 | |||
| b8c29324b8 | |||
| 972f38242e | |||
| 7ce6c29f4d | |||
| 2c6ddd7f90 | |||
| ae50b2e323 | |||
| 152cbc11a6 | |||
| 3a406de655 | |||
| 2e31cda08c | |||
| 094b7f47bd | |||
| 99ac349ecc | |||
| 8830e03cf3 | |||
| 706f3d6d65 | |||
| 12b49f5fa7 | |||
| 85327760b9 | |||
| c94ed9c3d4 | |||
| bc50735d2a | |||
| d05e125fa9 | |||
| 48d595ca81 | |||
| a156de5e75 | |||
| a7003bf4d0 | |||
| 5fe75c84ff | |||
| 4549a04fc5 | |||
| 0ea6b912a3 | |||
| 9b86fa3d40 | |||
| 374f66c59f | |||
| da43d881f1 | |||
| 9cbfa3a0ba | |||
| 77a925f33b | |||
| 3f7d7beedd | |||
| 40d91df732 | |||
| c8e783a210 | |||
| 61fc24d35d | |||
| 73734b0de7 | |||
| 09bd831d54 | |||
| 2c9f1b999e | |||
| 6a3a185310 | |||
| 7d381b7aee | |||
| bba5626dea | |||
| bdf5e58929 | |||
| 035168230e | |||
| 28168f082d | |||
| a29e09ed2f | |||
| e88874e757 | |||
| b6fb7c6977 | |||
| 8a60f17d13 | |||
| e1d860a42c | |||
| 56da326709 | |||
| 672008f8af |
@@ -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
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
action_text-trix (2.1.18)
|
||||
action_text-trix (2.1.19)
|
||||
railties
|
||||
actioncable (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
actioncable (8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activestorage (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
actionmailbox (8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
activejob (= 8.1.3.1)
|
||||
activerecord (= 8.1.3.1)
|
||||
activestorage (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
actionview (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
actionmailer (8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
actionview (= 8.1.3.1)
|
||||
activejob (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (8.1.3)
|
||||
actionview (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
actionpack (8.1.3.1)
|
||||
actionview (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
nokogiri (>= 1.8.5)
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
@@ -33,36 +33,36 @@ GEM
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.1.3)
|
||||
actiontext (8.1.3.1)
|
||||
action_text-trix (~> 2.1.15)
|
||||
actionpack (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activestorage (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
actionpack (= 8.1.3.1)
|
||||
activerecord (= 8.1.3.1)
|
||||
activestorage (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
actionview (8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
activejob (8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
activerecord (8.1.3)
|
||||
activemodel (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
activemodel (8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
activerecord (8.1.3.1)
|
||||
activemodel (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
activestorage (8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
activejob (= 8.1.3.1)
|
||||
activerecord (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.1.3)
|
||||
activesupport (8.1.3.1)
|
||||
base64
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
@@ -144,12 +144,12 @@ GEM
|
||||
cldr-plurals-runtime-rb (1.1.0)
|
||||
cmdparse (3.0.7)
|
||||
coderay (1.1.3)
|
||||
concurrent-ruby (1.3.7)
|
||||
concurrent-ruby (1.3.8)
|
||||
connection_pool (3.0.2)
|
||||
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)
|
||||
@@ -179,7 +179,7 @@ GEM
|
||||
dotenv (3.2.0)
|
||||
drb (2.2.3)
|
||||
email_typo (0.2.3)
|
||||
erb (6.0.4)
|
||||
erb (6.0.6)
|
||||
erb_lint (0.9.0)
|
||||
activesupport
|
||||
better_html (>= 2.0.1)
|
||||
@@ -217,7 +217,7 @@ GEM
|
||||
foreman (0.90.0)
|
||||
thor (~> 1.4)
|
||||
geom2d (0.4.1)
|
||||
globalid (1.3.0)
|
||||
globalid (1.4.0)
|
||||
activesupport (>= 6.1)
|
||||
google-apis-core (1.0.2)
|
||||
addressable (~> 2.8, >= 2.8.7)
|
||||
@@ -262,7 +262,7 @@ GEM
|
||||
geom2d (~> 0.4, >= 0.4.1)
|
||||
openssl (>= 2.2.1)
|
||||
strscan (>= 3.1.2)
|
||||
i18n (1.14.8)
|
||||
i18n (1.15.2)
|
||||
concurrent-ruby (~> 1.0)
|
||||
io-console (0.8.2)
|
||||
irb (1.18.0)
|
||||
@@ -271,7 +271,7 @@ GEM
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.19.9)
|
||||
json (2.21.1)
|
||||
jwt (3.2.0)
|
||||
base64
|
||||
language_server-protocol (3.17.0.5)
|
||||
@@ -293,27 +293,27 @@ 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)
|
||||
mail (2.9.1)
|
||||
logger
|
||||
mini_mime (>= 0.1.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
marcel (1.1.0)
|
||||
marcel (1.2.1)
|
||||
matrix (0.4.3)
|
||||
method_source (1.1.0)
|
||||
mini_mime (1.1.5)
|
||||
minitest (6.0.6)
|
||||
drb (~> 2.0)
|
||||
prism (~> 1.5)
|
||||
msgpack (1.8.0)
|
||||
msgpack (1.8.4)
|
||||
multi_json (1.19.1)
|
||||
net-http (0.9.1)
|
||||
uri (>= 0.11.1)
|
||||
net-imap (0.6.4.1)
|
||||
net-imap (0.6.6)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -344,7 +344,7 @@ GEM
|
||||
orm_adapter (0.5.0)
|
||||
os (1.1.4)
|
||||
package_json (0.2.0)
|
||||
pagy (43.4.4)
|
||||
pagy (43.6.1)
|
||||
json
|
||||
uri
|
||||
yaml
|
||||
@@ -357,7 +357,7 @@ GEM
|
||||
pg (1.6.3-arm64-darwin)
|
||||
pg (1.6.3-x86_64-linux)
|
||||
pg (1.6.3-x86_64-linux-musl)
|
||||
pp (0.6.3)
|
||||
pp (0.6.4)
|
||||
prettyprint
|
||||
pretender (1.0.0)
|
||||
actionpack (>= 7.2)
|
||||
@@ -369,9 +369,6 @@ GEM
|
||||
reline (>= 0.6.0)
|
||||
pry-rails (0.3.11)
|
||||
pry (>= 0.13.0)
|
||||
psych (5.3.1)
|
||||
date
|
||||
stringio
|
||||
public_suffix (7.0.5)
|
||||
puma (8.0.2)
|
||||
nio4r (~> 2.0)
|
||||
@@ -386,33 +383,33 @@ GEM
|
||||
rack (>= 1.3)
|
||||
rackup (2.3.1)
|
||||
rack (>= 3)
|
||||
rails (8.1.3)
|
||||
actioncable (= 8.1.3)
|
||||
actionmailbox (= 8.1.3)
|
||||
actionmailer (= 8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
actiontext (= 8.1.3)
|
||||
actionview (= 8.1.3)
|
||||
activejob (= 8.1.3)
|
||||
activemodel (= 8.1.3)
|
||||
activerecord (= 8.1.3)
|
||||
activestorage (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
rails (8.1.3.1)
|
||||
actioncable (= 8.1.3.1)
|
||||
actionmailbox (= 8.1.3.1)
|
||||
actionmailer (= 8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
actiontext (= 8.1.3.1)
|
||||
actionview (= 8.1.3.1)
|
||||
activejob (= 8.1.3.1)
|
||||
activemodel (= 8.1.3.1)
|
||||
activerecord (= 8.1.3.1)
|
||||
activestorage (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.1.3)
|
||||
railties (= 8.1.3.1)
|
||||
rails-dom-testing (2.3.0)
|
||||
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)
|
||||
railties (>= 8.0.0, < 9)
|
||||
railties (8.1.3)
|
||||
actionpack (= 8.1.3)
|
||||
activesupport (= 8.1.3)
|
||||
railties (8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
@@ -421,9 +418,14 @@ GEM
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.4.2)
|
||||
rdoc (7.2.0)
|
||||
rbs (4.1.0)
|
||||
logger
|
||||
prism (>= 1.6.0)
|
||||
tsort
|
||||
rdoc (8.0.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
prism (>= 1.6.0)
|
||||
rbs (>= 4.0.0)
|
||||
tsort
|
||||
redis-client (0.29.0)
|
||||
connection_pool
|
||||
@@ -525,12 +527,11 @@ GEM
|
||||
simplecov-html (0.13.2)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
smart_properties (1.17.0)
|
||||
sqlite3 (2.9.2-aarch64-linux-gnu)
|
||||
sqlite3 (2.9.2-aarch64-linux-musl)
|
||||
sqlite3 (2.9.2-arm64-darwin)
|
||||
sqlite3 (2.9.2-x86_64-linux-gnu)
|
||||
sqlite3 (2.9.2-x86_64-linux-musl)
|
||||
stringio (3.2.0)
|
||||
sqlite3 (2.9.5-aarch64-linux-gnu)
|
||||
sqlite3 (2.9.5-aarch64-linux-musl)
|
||||
sqlite3 (2.9.5-arm64-darwin)
|
||||
sqlite3 (2.9.5-x86_64-linux-gnu)
|
||||
sqlite3 (2.9.5-x86_64-linux-musl)
|
||||
strip_attributes (2.0.1)
|
||||
activemodel (>= 3.0, < 9.0)
|
||||
strscan (3.1.8)
|
||||
@@ -570,14 +571,14 @@ GEM
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
webrick (1.9.2)
|
||||
websocket-driver (0.8.0)
|
||||
websocket-driver (0.8.2)
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
yaml (0.4.0)
|
||||
zeitwerk (2.7.5)
|
||||
zeitwerk (2.8.2)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
|
||||
@@ -37,7 +37,7 @@ class AccountConfigsController < ApplicationController
|
||||
end
|
||||
|
||||
def destroy
|
||||
raise InvalidKey unless allowed_keys.include?(@account_config.key)
|
||||
raise InvalidKey unless allowed_destroy_keys.include?(@account_config.key)
|
||||
|
||||
@account_config.destroy!
|
||||
|
||||
@@ -50,6 +50,10 @@ class AccountConfigsController < ApplicationController
|
||||
ALLOWED_KEYS
|
||||
end
|
||||
|
||||
def allowed_destroy_keys
|
||||
ALLOWED_KEYS
|
||||
end
|
||||
|
||||
def load_account_config
|
||||
raise InvalidKey unless allowed_keys.include?(account_config_params[:key])
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ module Api
|
||||
private
|
||||
|
||||
def build_completed_documents(submission, merge: false)
|
||||
last_submitter = submission.submitters.max_by(&:completed_at)
|
||||
last_submitter = submission.submitters.select(&:completed_at?).max_by(&:completed_at)
|
||||
|
||||
if merge
|
||||
if submission.merged_document_attachment.blank?
|
||||
|
||||
@@ -7,7 +7,7 @@ module Api
|
||||
TEMPLATE_COLUMNS = %i[id name external_id created_at updated_at folder_id submitters].freeze
|
||||
|
||||
load_and_authorize_resource :template, only: :create
|
||||
load_and_authorize_resource :submission, only: %i[show index destroy]
|
||||
load_and_authorize_resource :submission, only: %i[show index update destroy]
|
||||
|
||||
before_action only: :create do
|
||||
authorize!(:create, Submission)
|
||||
@@ -80,8 +80,9 @@ module Api
|
||||
Submissions.send_signature_requests(submissions)
|
||||
|
||||
submissions.each do |submission|
|
||||
if submission.submitters.all?(&:completed_at?) && Submissions.maybe_update_completed_at(submission)
|
||||
last_submitter = submission.submitters.max_by(&:completed_at)
|
||||
if submission.submitters.all? { |s| s.viewer? || s.completed_at? } &&
|
||||
Submissions.maybe_update_completed_at(submission)
|
||||
last_submitter = submission.submitters.reject(&:viewer?).max_by(&:completed_at)
|
||||
end
|
||||
|
||||
submission.submitters.each do |submitter|
|
||||
@@ -103,6 +104,25 @@ module Api
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def update
|
||||
@submission = assign_submission_attrs(@submission, submission_params)
|
||||
|
||||
@submission.save!
|
||||
|
||||
if @submission.saved_change_to_archived_at? && @submission.archived_at?
|
||||
WebhookUrls.enqueue_events(@submission, 'submission.archived')
|
||||
end
|
||||
|
||||
if @submission.saved_change_to_expire_at? && @submission.expire_at?
|
||||
ProcessSubmissionExpiredJob.perform_at(@submission.expire_at, 'submission_id' => @submission.id,
|
||||
'expire_at' => @submission.expire_at.to_i)
|
||||
end
|
||||
|
||||
SearchEntries.enqueue_reindex(@submission) if @submission.saved_change_to_name?
|
||||
|
||||
render json: Submissions::SerializeForApi.call(@submission, nil, params, with_events: false)
|
||||
end
|
||||
|
||||
def destroy
|
||||
if params[:permanently].in?(['true', true])
|
||||
@submission.destroy!
|
||||
@@ -117,6 +137,25 @@ module Api
|
||||
|
||||
private
|
||||
|
||||
def assign_submission_attrs(submission, attrs)
|
||||
archived = attrs.key?(:archived) ? attrs[:archived] : attrs[:archived_at]
|
||||
|
||||
if archived.in?([true, false, 'true', 'false']) && current_ability.can?(:destroy, submission)
|
||||
submission.archived_at = archived.in?(Submitters::TRUE_VALUES) ? Time.current : nil
|
||||
end
|
||||
|
||||
submission.name = attrs[:name] if attrs.key?(:name)
|
||||
submission.expire_at = attrs[:expire_at].presence if attrs.key?(:expire_at)
|
||||
|
||||
submission
|
||||
end
|
||||
|
||||
def submission_params
|
||||
submission_params = params.key?(:submission) ? params.require(:submission) : params
|
||||
|
||||
submission_params.permit(:name, :expire_at, :archived, :archived_at)
|
||||
end
|
||||
|
||||
def maybe_return_template_error
|
||||
return render json: { error: 'Template not found' }, status: :unprocessable_content if @template.nil?
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ module Api
|
||||
private
|
||||
|
||||
def maybe_return_submitter_error
|
||||
if @submitter.completed_at?
|
||||
if @submitter.completed_at? || @submitter.submission.completed_at?
|
||||
return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ module Api
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def index
|
||||
@templates = Templates.shared(current_user) if params[:shared].in?(['true', true])
|
||||
|
||||
templates = filter_templates(@templates, params)
|
||||
|
||||
templates = paginate(templates.preload(:author, folder: :parent_folder))
|
||||
@@ -54,7 +56,7 @@ module Api
|
||||
|
||||
@template.update!(template_params)
|
||||
|
||||
SearchEntries.enqueue_reindex(@template)
|
||||
SearchEntries.enqueue_reindex(@template) if @template.saved_change_to_name?
|
||||
|
||||
WebhookUrls.enqueue_events(@template, 'template.updated')
|
||||
|
||||
@@ -115,7 +117,13 @@ module Api
|
||||
end
|
||||
|
||||
def filter_templates(templates, params)
|
||||
templates = Templates.search(current_user, templates, params[:q])
|
||||
templates =
|
||||
if params[:shared].in?(['true', true])
|
||||
Templates.search_shared(current_user, templates, params[:q])
|
||||
else
|
||||
Templates.search(current_user, templates, params[:q])
|
||||
end
|
||||
|
||||
templates = params[:archived].in?(['true', true]) ? templates.archived : templates.active
|
||||
templates = templates.where(external_id: params[:application_key]) if params[:application_key].present?
|
||||
templates = templates.where(external_id: params[:external_id]) if params[:external_id].present?
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
class EmailSmtpSettingsController < ApplicationController
|
||||
before_action :load_encrypted_config
|
||||
authorize_resource :encrypted_config, only: :index
|
||||
authorize_resource :encrypted_config, parent: false, only: :create
|
||||
authorize_resource :encrypted_config, parent: false, only: %i[create destroy]
|
||||
|
||||
def index; end
|
||||
|
||||
@@ -23,6 +23,12 @@ class EmailSmtpSettingsController < ApplicationController
|
||||
render :index, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def destroy
|
||||
@encrypted_config.destroy!
|
||||
|
||||
redirect_to settings_email_index_path, notice: I18n.t('smtp_settings_have_been_reset')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_encrypted_config
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -20,6 +20,8 @@ class MfaSetupController < ApplicationController
|
||||
|
||||
redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_configured')
|
||||
else
|
||||
RateLimit.call("mfa-setup-otp-#{current_user.id}", limit: 5, ttl: 5.minutes, enabled: true)
|
||||
|
||||
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name)
|
||||
|
||||
@error_message = I18n.t('code_is_invalid')
|
||||
@@ -34,6 +36,8 @@ class MfaSetupController < ApplicationController
|
||||
|
||||
redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_removed')
|
||||
else
|
||||
RateLimit.call("mfa-setup-otp-#{current_user.id}", limit: 5, ttl: 5.minutes, enabled: true)
|
||||
|
||||
@error_message = I18n.t('code_is_invalid')
|
||||
|
||||
render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/edit'), status: :unprocessable_content
|
||||
|
||||
@@ -16,18 +16,16 @@ class SendSubmissionEmailController < ApplicationController
|
||||
@submitter =
|
||||
Submitter.completed.where(submission: template.submissions).find_by(email: params[:email].to_s.downcase)
|
||||
elsif params[:submission_slug]
|
||||
submission = Submission.find_by(slug: params[:submission_slug])
|
||||
submission = Submission.find_by!(slug: params[:submission_slug])
|
||||
|
||||
if submission
|
||||
@submitter = Submitter.completed.find_by(submission: submission, email: params[:email].to_s.downcase)
|
||||
end
|
||||
@submitter = submission.submitters.order(:completed_at).find_by(email: params[:email].to_s.downcase)
|
||||
|
||||
return redirect_to submissions_preview_completed_path(params[:submission_slug], status: :error) unless @submitter
|
||||
else
|
||||
@submitter = Submitter.completed.find_by!(slug: params[:submitter_slug])
|
||||
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||
end
|
||||
|
||||
if @submitter
|
||||
if @submitter && completed_submitter?(@submitter)
|
||||
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
|
||||
|
||||
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter)
|
||||
@@ -41,6 +39,10 @@ class SendSubmissionEmailController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def completed_submitter?(submitter)
|
||||
submitter.completed_at? || (submitter.viewer? && submitter.submission.completed_at?)
|
||||
end
|
||||
|
||||
def can_send?(submitter)
|
||||
return false if submitter.account.archived_at?
|
||||
return false if EmailEvent.exists?(tag: :submitter_documents_copy, email: submitter.email, emailable: submitter,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SettingsController < ApplicationController
|
||||
skip_authorization_check
|
||||
|
||||
def index; end
|
||||
end
|
||||
@@ -93,9 +93,12 @@ class StartFormController < ApplicationController
|
||||
|
||||
SearchEntries.enqueue_reindex(submitter)
|
||||
|
||||
return unless submitter.submission.expire_at?
|
||||
expire_at = submitter.submission.expire_at
|
||||
|
||||
ProcessSubmissionExpiredJob.perform_at(submitter.submission.expire_at, 'submission_id' => submitter.submission_id)
|
||||
return unless expire_at
|
||||
|
||||
ProcessSubmissionExpiredJob.perform_at(expire_at, 'submission_id' => submitter.submission_id,
|
||||
'expire_at' => expire_at.to_i)
|
||||
end
|
||||
|
||||
def load_resubmit_submitter
|
||||
@@ -138,8 +141,7 @@ class StartFormController < ApplicationController
|
||||
|
||||
submitter ||=
|
||||
Submitter
|
||||
.where(submission: template.submissions.where(expire_at: Time.current..)
|
||||
.or(template.submissions.where(expire_at: nil)).where(archived_at: nil))
|
||||
.where(submission: template.submissions.non_expired.active)
|
||||
.order(id: :desc)
|
||||
.where(declined_at: nil)
|
||||
.where(external_id: nil)
|
||||
@@ -147,6 +149,8 @@ class StartFormController < ApplicationController
|
||||
.then { |rel| params[:resubmit].present? || params[:selfsign].present? ? rel.where(completed_at: nil) : rel }
|
||||
.find_or_initialize_by(find_params)
|
||||
|
||||
submitter = Submitter.new(find_params) if submitter.submission&.completed_at? && submitter.viewer?
|
||||
|
||||
submitter.name = required_params['name'] if submitter.new_record?
|
||||
|
||||
unless @resubmit_submitter
|
||||
|
||||
@@ -24,5 +24,7 @@ class SubmissionEventsController < ApplicationController
|
||||
|
||||
load_and_authorize_resource :submission
|
||||
|
||||
def index; end
|
||||
def index
|
||||
render :index, layout: 'plain'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -33,6 +33,8 @@ class SubmissionsController < ApplicationController
|
||||
|
||||
def new
|
||||
authorize!(:new, Submission)
|
||||
|
||||
render :new, layout: 'plain'
|
||||
end
|
||||
|
||||
def create
|
||||
@@ -87,6 +89,8 @@ class SubmissionsController < ApplicationController
|
||||
private
|
||||
|
||||
def create_submissions(template, submissions_params, params)
|
||||
normalize_message_submitter_uuids!(params)
|
||||
|
||||
submissions_attrs = submissions_params[:submission].to_h.values
|
||||
|
||||
submissions_attrs, _, new_fields =
|
||||
@@ -111,4 +115,23 @@ class SubmissionsController < ApplicationController
|
||||
def submissions_params
|
||||
params.permit(submission: { submitters: [:uuid, :email, :phone, :name, { values: {} }] })
|
||||
end
|
||||
|
||||
def normalize_message_submitter_uuids!(params)
|
||||
return if params[:request_email_per_submitter] == '1'
|
||||
|
||||
uuids = params[:email_message_submitter_uuids]
|
||||
|
||||
return if uuids.blank?
|
||||
return if params[:subject].blank? && params[:body].blank?
|
||||
|
||||
params[:submitter_preferences] =
|
||||
Array.wrap(uuids).index_with { { 'subject' => params[:subject], 'body' => params[:body] } }
|
||||
|
||||
params[:request_email_per_submitter] = '1'
|
||||
|
||||
params.delete(:subject)
|
||||
params.delete(:body)
|
||||
|
||||
params
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,7 +19,7 @@ class SubmitFormCompletedDownloadController < ApplicationController
|
||||
|
||||
@submitter ||= Submitter.find_by!(slug: submitter_slug)
|
||||
|
||||
Submissions::EnsureResultGenerated.call(@submitter)
|
||||
Submissions::EnsureResultGenerated.call(@submitter) if @submitter.completed_at?
|
||||
|
||||
last_submitter = @submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last
|
||||
|
||||
@@ -28,11 +28,7 @@ class SubmitFormCompletedDownloadController < ApplicationController
|
||||
Submissions::EnsureResultGenerated.call(last_submitter)
|
||||
|
||||
if !signature_valid && !current_user_submitter?(last_submitter)
|
||||
unless Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||
Rollbar.info("2FA download error: #{last_submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return head :not_found
|
||||
end
|
||||
return head :not_found unless Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||
|
||||
if last_submitter.completed_at < TTL.ago
|
||||
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
|
||||
|
||||
@@ -18,7 +18,10 @@ class SubmitFormController < ApplicationController
|
||||
submission = @submitter.submission
|
||||
|
||||
return render :email_2fa unless Submitters::AuthorizedForForm.pass_email_2fa?(@submitter, request)
|
||||
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
|
||||
|
||||
if @submitter.completed_at? || submission.completed_at?
|
||||
return redirect_to submit_form_completed_path(@submitter.slug)
|
||||
end
|
||||
|
||||
@form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS)
|
||||
|
||||
@@ -71,6 +74,12 @@ class SubmitFormController < ApplicationController
|
||||
status: :unprocessable_content
|
||||
end
|
||||
|
||||
if @submitter.viewer?
|
||||
Rollbar.warning("Submit viewer: #{@submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return render json: { error: I18n.t('form_is_view_only') }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
Submitters::SubmitValues.call(@submitter, params, request)
|
||||
|
||||
head :ok
|
||||
|
||||
@@ -13,6 +13,7 @@ class SubmitFormDeclineController < ApplicationController
|
||||
@submitter.submission.archived_at? ||
|
||||
@submitter.submission.expired? ||
|
||||
@submitter.submission.template&.archived_at? ||
|
||||
@submitter.viewer? ||
|
||||
!Submitters::AuthorizedForForm.call(@submitter,
|
||||
current_user,
|
||||
request)
|
||||
|
||||
@@ -12,6 +12,7 @@ class SubmitFormDelegateController < ApplicationController
|
||||
@submitter.submission.archived_at? ||
|
||||
@submitter.submission.expired? ||
|
||||
@submitter.submission.template&.archived_at? ||
|
||||
@submitter.viewer? ||
|
||||
!Submitters::AuthorizedForForm.call(@submitter,
|
||||
current_user,
|
||||
request)
|
||||
|
||||
@@ -14,6 +14,7 @@ class SubmitFormDownloadController < ApplicationController
|
||||
return head :unprocessable_content if @submitter.declined_at? ||
|
||||
@submitter.submission.archived_at? ||
|
||||
@submitter.submission.expired? ||
|
||||
@submitter.submission.completed_at? ||
|
||||
@submitter.submission.template&.archived_at? ||
|
||||
AccountConfig.exists?(account_id: @submitter.account_id,
|
||||
key: AccountConfig::ALLOW_TO_PARTIAL_DOWNLOAD_KEY,
|
||||
|
||||
@@ -12,6 +12,8 @@ class SubmitFormDrawSignatureController < ApplicationController
|
||||
|
||||
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
|
||||
|
||||
return redirect_to submit_form_path(@submitter.slug) if @submitter.viewer?
|
||||
|
||||
if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at? ||
|
||||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||
return redirect_to submit_form_path(@submitter.slug)
|
||||
|
||||
@@ -48,6 +48,7 @@ class SubmitFormInviteController < ApplicationController
|
||||
!submitter.submission.archived_at? &&
|
||||
!submitter.submission.expired? &&
|
||||
!submitter.submission.template&.archived_at? &&
|
||||
!submitter.viewer? &&
|
||||
Submitters::AuthorizedForForm.call(submitter, current_user, request)
|
||||
end
|
||||
|
||||
|
||||
@@ -7,13 +7,7 @@ class SubmitFormMetadataController < ApplicationController
|
||||
def index
|
||||
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
|
||||
return head :not_found if @submitter.declined_at? ||
|
||||
@submitter.completed_at? ||
|
||||
@submitter.submission.archived_at? ||
|
||||
@submitter.submission.expired? ||
|
||||
@submitter.submission.template&.archived_at? ||
|
||||
@submitter.account.archived_at? ||
|
||||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||
return head :not_found unless authorized_submitter?(@submitter)
|
||||
|
||||
submission = @submitter.submission
|
||||
values = submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) }
|
||||
@@ -34,4 +28,17 @@ class SubmitFormMetadataController < ApplicationController
|
||||
|
||||
render json: { text_runs: }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def authorized_submitter?(submitter)
|
||||
!submitter.declined_at? &&
|
||||
!submitter.completed_at? &&
|
||||
!submitter.submission.archived_at? &&
|
||||
!submitter.submission.completed_at? &&
|
||||
!submitter.submission.expired? &&
|
||||
!submitter.submission.template&.archived_at? &&
|
||||
!submitter.account.archived_at? &&
|
||||
Submitters::AuthorizedForForm.call(submitter, current_user, request)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,6 +8,8 @@ class SubmittersController < ApplicationController
|
||||
if @submitter.preferences['email_message_uuid'].present?
|
||||
@submitter.account.email_messages.find_by(uuid: @submitter.preferences['email_message_uuid'])
|
||||
end
|
||||
|
||||
render :edit, layout: 'plain'
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -50,7 +52,7 @@ class SubmittersController < ApplicationController
|
||||
|
||||
def submitter_editable?(submission)
|
||||
!@submitter.submission_events.exists?(event_type: 'start_form') &&
|
||||
!@submitter.completed_at? && !@submitter.declined_at? &&
|
||||
!@submitter.completed_at? && !@submitter.declined_at? && !submission.completed_at? &&
|
||||
!submission.archived_at? && !submission.expired? && !submission.template&.archived_at?
|
||||
end
|
||||
|
||||
@@ -58,7 +60,7 @@ class SubmittersController < ApplicationController
|
||||
if params[:send_email] == '1' && submitter.email.present?
|
||||
is_sent_recently = Docuseal.multitenant? &&
|
||||
EmailEvent.exists?(email: submitter.email,
|
||||
tag: 'submitter_invitation',
|
||||
tag: %w[submitter_invitation submitter_view_invitation],
|
||||
emailable: submitter,
|
||||
event_type: 'send',
|
||||
created_at: 4.hours.ago..Time.current)
|
||||
|
||||
@@ -25,7 +25,9 @@ class TemplatesController < ApplicationController
|
||||
redirect_to root_path
|
||||
end
|
||||
|
||||
def new; end
|
||||
def new
|
||||
render :new, layout: 'plain'
|
||||
end
|
||||
|
||||
def edit
|
||||
@template_data = Templates.serialize_for_builder(@template)
|
||||
|
||||
@@ -13,11 +13,13 @@ class TemplatesFormPreviewController < ApplicationController
|
||||
|
||||
@submitter.submission.submitters = @template.submitters.map { |item| Submitter.new(uuid: item['uuid']) }
|
||||
|
||||
Submissions::CreateFromSubmitters.maybe_set_dynamic_documents(@submitter.submission, preview: true)
|
||||
|
||||
Submissions.preload_with_pages(@submitter.submission)
|
||||
|
||||
@attachments_index = ActiveStorage::Attachment.where(record: @submitter.submission.submitters, name: :attachments)
|
||||
.preload(:blob).index_by(&:uuid)
|
||||
|
||||
@form_configs = Submitters::FormConfigs.call(@submitter)
|
||||
@form_configs = Submitters::FormConfigs.call(@submitter, SubmitFormController::CONFIG_KEYS)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,6 +5,8 @@ class TemplatesPreferencesController < ApplicationController
|
||||
|
||||
RESETTABLE_PREFERENCE_KEYS = {
|
||||
AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY => %w[request_email_subject request_email_body submitters],
|
||||
AccountConfig::SUBMITTER_VIEW_INVITATION_EMAIL_KEY => %w[invitation_view_email_subject
|
||||
invitation_view_email_body],
|
||||
AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY => %w[invitation_reminder_email_subject
|
||||
invitation_reminder_email_body],
|
||||
AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => %w[documents_copy_email_subject documents_copy_email_body],
|
||||
@@ -12,7 +14,9 @@ class TemplatesPreferencesController < ApplicationController
|
||||
completed_notification_email_body]
|
||||
}.freeze
|
||||
|
||||
def show; end
|
||||
def show
|
||||
render :show, layout: 'plain'
|
||||
end
|
||||
|
||||
def create
|
||||
authorize!(:update, @template)
|
||||
@@ -48,12 +52,12 @@ class TemplatesPreferencesController < ApplicationController
|
||||
def template_params
|
||||
params.require(:template).permit(
|
||||
preferences: %i[bcc_completed request_email_subject request_email_body
|
||||
invitation_view_email_subject invitation_view_email_body
|
||||
invitation_reminder_email_subject invitation_reminder_email_body
|
||||
documents_copy_email_subject documents_copy_email_body
|
||||
documents_copy_email_enabled documents_copy_email_attach_audit
|
||||
documents_copy_email_attach_documents documents_copy_email_reply_to
|
||||
completed_notification_email_attach_documents
|
||||
completed_redirect_url validate_unique_submitters
|
||||
completed_notification_email_attach_documents completed_redirect_url validate_unique_submitters
|
||||
require_all_submitters submitters_order require_phone_2fa require_email_2fa
|
||||
default_expire_at_duration shared_link_2fa default_expire_at request_email_enabled
|
||||
completed_notification_email_subject completed_notification_email_body
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
class TemplatesShareLinkController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def show; end
|
||||
def show
|
||||
render :show, layout: 'plain'
|
||||
end
|
||||
|
||||
def create
|
||||
authorize!(:update, @template)
|
||||
|
||||
@@ -42,6 +42,7 @@ import RequiredCheckboxGroup from './elements/required_checkbox_group'
|
||||
import PageContainer from './elements/page_container'
|
||||
import EmailEditor from './elements/email_editor'
|
||||
import MarkdownEditor from './elements/markdown_editor'
|
||||
import HtmlEditor from './elements/html_editor'
|
||||
import MountOnClick from './elements/mount_on_click'
|
||||
import RemoveOnEvent from './elements/remove_on_event'
|
||||
import ScrollTo from './elements/scroll_to'
|
||||
@@ -55,6 +56,9 @@ import OpenModal from './elements/open_modal'
|
||||
import BarChart from './elements/bar_chart'
|
||||
import FieldCondition from './elements/field_condition'
|
||||
import ConfirmUpload from './elements/confirm_upload'
|
||||
import ScrollFade from './elements/scroll_fade'
|
||||
import OpenModalMobile from './elements/open_modal_mobile'
|
||||
import HistoryBack from './elements/history_back'
|
||||
|
||||
import * as TurboInstantClick from './lib/turbo_instant_click'
|
||||
|
||||
@@ -135,6 +139,7 @@ safeRegisterElement('required-checkbox-group', RequiredCheckboxGroup)
|
||||
safeRegisterElement('page-container', PageContainer)
|
||||
safeRegisterElement('email-editor', EmailEditor)
|
||||
safeRegisterElement('markdown-editor', MarkdownEditor)
|
||||
safeRegisterElement('html-editor', HtmlEditor)
|
||||
safeRegisterElement('mount-on-click', MountOnClick)
|
||||
safeRegisterElement('remove-on-event', RemoveOnEvent)
|
||||
safeRegisterElement('scroll-to', ScrollTo)
|
||||
@@ -148,10 +153,14 @@ safeRegisterElement('open-modal', OpenModal)
|
||||
safeRegisterElement('bar-chart', BarChart)
|
||||
safeRegisterElement('field-condition', FieldCondition)
|
||||
safeRegisterElement('confirm-upload', ConfirmUpload)
|
||||
safeRegisterElement('scroll-fade', ScrollFade)
|
||||
safeRegisterElement('open-modal-mobile', OpenModalMobile)
|
||||
safeRegisterElement('history-back', HistoryBack)
|
||||
|
||||
safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
document.addEventListener('turbo:submit-end', this.onSubmit)
|
||||
document.addEventListener('turbo:before-cache', this.onBeforeCache)
|
||||
|
||||
this.appElem = document.createElement('div')
|
||||
|
||||
@@ -162,6 +171,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,
|
||||
@@ -178,7 +188,10 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
|
||||
withCustomFields: true,
|
||||
withPayment: this.dataset.withPayment === 'true',
|
||||
isPaymentConnected: this.dataset.isPaymentConnected === 'true',
|
||||
isStripeConnected: this.dataset.isPaymentConnected === 'true' || this.dataset.isStripeConnected === 'true',
|
||||
withStripe: this.dataset.withStripe !== 'false',
|
||||
withPaypal: this.dataset.withPaypal === 'true',
|
||||
isPaypalConnected: this.dataset.isPaypalConnected === 'true',
|
||||
withFormula: this.dataset.withFormula === 'true',
|
||||
withSendButton: this.dataset.withSendButton !== 'false',
|
||||
withSignYourselfButton: this.dataset.withSignYourselfButton !== 'false',
|
||||
@@ -231,8 +244,14 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeCache = () => {
|
||||
this.app?.unmount()
|
||||
this.appElem?.remove()
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
document.removeEventListener('turbo:submit-end', this.onSubmit)
|
||||
document.removeEventListener('turbo:before-cache', this.onBeforeCache)
|
||||
|
||||
this.app?.unmount()
|
||||
this.appElem?.remove()
|
||||
@@ -241,6 +260,8 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
|
||||
safeRegisterElement('import-list', class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
document.addEventListener('turbo:before-cache', this.onBeforeCache)
|
||||
|
||||
this.appElem = document.createElement('div')
|
||||
|
||||
this.app = createApp(ImportList, {
|
||||
@@ -255,7 +276,14 @@ safeRegisterElement('import-list', class extends HTMLElement {
|
||||
this.appendChild(this.appElem)
|
||||
}
|
||||
|
||||
onBeforeCache = () => {
|
||||
this.app?.unmount()
|
||||
this.appElem?.remove()
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
document.removeEventListener('turbo:before-cache', this.onBeforeCache)
|
||||
|
||||
this.app?.unmount()
|
||||
this.appElem?.remove()
|
||||
}
|
||||
|
||||
@@ -114,6 +114,13 @@ button[disabled] .enabled, button.btn-disabled .enabled {
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.tooltip-no-touch:before,
|
||||
.tooltip-no-touch:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.autocomplete {
|
||||
background: white;
|
||||
z-index: 1000;
|
||||
|
||||
@@ -9,8 +9,9 @@ function loadCodeMirror () {
|
||||
import(/* webpackChunkName: "email-editor" */ '@codemirror/commands'),
|
||||
import(/* webpackChunkName: "email-editor" */ '@codemirror/language'),
|
||||
import(/* webpackChunkName: "email-editor" */ '@codemirror/lang-html'),
|
||||
import(/* webpackChunkName: "email-editor" */ '@codemirror/lint'),
|
||||
import(/* webpackChunkName: "email-editor" */ '@specious/htmlflow')
|
||||
]).then(([view, commands, language, html, htmlflow]) => {
|
||||
]).then(([view, commands, language, html, lint, htmlflow]) => {
|
||||
return {
|
||||
minimalSetup: [
|
||||
commands.history(),
|
||||
@@ -19,6 +20,8 @@ function loadCodeMirror () {
|
||||
],
|
||||
EditorView: view.EditorView,
|
||||
html: html.html,
|
||||
htmlLanguage: html.htmlLanguage,
|
||||
linter: lint.linter,
|
||||
htmlflow: htmlflow.default || htmlflow
|
||||
}
|
||||
})
|
||||
@@ -46,6 +49,70 @@ export default targetable(class extends HTMLElement {
|
||||
|
||||
this.previewViewTab.addEventListener('click', this.showPreviewView)
|
||||
this.codeViewTab.addEventListener('click', this.showCodeView)
|
||||
|
||||
this.form = this.closest('form')
|
||||
this.form?.addEventListener('submit', this.validateOnSubmit)
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.form?.removeEventListener('submit', this.validateOnSubmit)
|
||||
}
|
||||
|
||||
validateOnSubmit = (e) => {
|
||||
if (!this.htmlLanguage) return
|
||||
|
||||
const bodyType = this.form.querySelector('input[name$="[body_type]"]:checked')?.value
|
||||
|
||||
if (bodyType && bodyType !== 'html') return
|
||||
|
||||
const diagnostics = this.buildDiagnostics(this.input.value)
|
||||
|
||||
if (diagnostics.length === 0) return
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
this.showCodeView()
|
||||
|
||||
const pos = Math.min(diagnostics[0].from, this.editorView.state.doc.length)
|
||||
|
||||
this.editorView.dispatch({ selection: { anchor: pos }, scrollIntoView: true })
|
||||
this.editorView.focus()
|
||||
|
||||
alert(diagnostics[0].message)
|
||||
}
|
||||
|
||||
buildDiagnostics (value) {
|
||||
const diagnostics = []
|
||||
|
||||
if (!value.trim()) return diagnostics
|
||||
|
||||
if (!/^\s*(<!doctype[^>]*>\s*)?<html/i.test(value)) {
|
||||
diagnostics.push({
|
||||
from: 0,
|
||||
to: Math.min(5, value.length),
|
||||
severity: 'error',
|
||||
message: 'The email template must start with the <html> tag'
|
||||
})
|
||||
}
|
||||
|
||||
const seen = new Set()
|
||||
|
||||
this.htmlLanguage.parser.parse(value).iterate({
|
||||
enter: (node) => {
|
||||
if (!node.type.isError || seen.has(node.from) || seen.size >= 20) return
|
||||
|
||||
seen.add(node.from)
|
||||
|
||||
diagnostics.push({
|
||||
from: node.from,
|
||||
to: Math.min(node.to + 1, value.length),
|
||||
severity: 'error',
|
||||
message: 'The email template contains invalid HTML'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return diagnostics
|
||||
}
|
||||
|
||||
showCodeView = () => {
|
||||
@@ -76,7 +143,9 @@ export default targetable(class extends HTMLElement {
|
||||
this.input = this.querySelector('input[type="hidden"]')
|
||||
this.input.style.display = 'none'
|
||||
|
||||
const { EditorView, minimalSetup, html, htmlflow } = await loadCodeMirror()
|
||||
const { EditorView, minimalSetup, html, htmlLanguage, linter, htmlflow } = await loadCodeMirror()
|
||||
|
||||
this.htmlLanguage = htmlLanguage
|
||||
|
||||
this.editorView = new EditorView({
|
||||
doc: this.input.value,
|
||||
@@ -85,8 +154,11 @@ export default targetable(class extends HTMLElement {
|
||||
html(),
|
||||
minimalSetup,
|
||||
EditorView.lineWrapping,
|
||||
linter((view) => this.buildDiagnostics(view.state.doc.toString()), { delay: 600 }),
|
||||
EditorView.updateListener.of(update => {
|
||||
if (update.docChanged) this.input.value = update.state.doc.toString()
|
||||
if (update.docChanged) {
|
||||
this.input.value = update.state.doc.toString()
|
||||
}
|
||||
}),
|
||||
EditorView.theme({
|
||||
'&': {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.addEventListener('click', this.onClick)
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.removeEventListener('click', this.onClick)
|
||||
}
|
||||
|
||||
onClick = (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
window.history.back()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
import { target, targetable } from '@github/catalyst/lib/targetable'
|
||||
import { actionable } from '@github/catalyst/lib/actionable'
|
||||
import { LinkTooltip } from './markdown_editor'
|
||||
|
||||
async function loadTiptap () {
|
||||
const [core, document, text, hardBreak, gapcursor, dropcursor, extensions, pmState, pmView] = await Promise.all([
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/core'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-document'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-text'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-hard-break'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-gapcursor'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extension-dropcursor'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/extensions'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/state'),
|
||||
import(/* webpackChunkName: "markdown-editor" */ '@tiptap/pm/view')
|
||||
])
|
||||
|
||||
return {
|
||||
Editor: core.Editor,
|
||||
Extension: core.Extension,
|
||||
Node: core.Node,
|
||||
Mark: core.Mark,
|
||||
Document: document.default || document,
|
||||
Text: text.default || text,
|
||||
HardBreak: hardBreak.default || hardBreak,
|
||||
Gapcursor: gapcursor.default || gapcursor,
|
||||
Dropcursor: dropcursor.default || dropcursor,
|
||||
UndoRedo: extensions.UndoRedo,
|
||||
Plugin: pmState.Plugin,
|
||||
Decoration: pmView.Decoration,
|
||||
DecorationSet: pmView.DecorationSet
|
||||
}
|
||||
}
|
||||
|
||||
const editorStylesheet = new CSSStyleSheet()
|
||||
|
||||
editorStylesheet.replaceSync(`
|
||||
:host {
|
||||
display: block;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
border-radius: 0 0 1rem 1rem;
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
word-wrap: break-word;
|
||||
-webkit-font-variant-ligatures: none;
|
||||
font-variant-ligatures: none;
|
||||
font-feature-settings: "liga" 0;
|
||||
outline: none;
|
||||
min-height: 220px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
img.ProseMirror-separator {
|
||||
display: inline !important;
|
||||
border: none !important;
|
||||
margin: 0 !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
.ProseMirror-gapcursor {
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ProseMirror-gapcursor:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
width: 20px;
|
||||
border-top: 1px solid black;
|
||||
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
|
||||
}
|
||||
|
||||
@keyframes ProseMirror-cursor-blink {
|
||||
to {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-hideselection *::selection {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ProseMirror-hideselection *::-moz-selection {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ProseMirror-hideselection * {
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
.ProseMirror-focused .ProseMirror-gapcursor {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.variable-highlight {
|
||||
background-color: #fef3c7;
|
||||
padding: 1px 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
`)
|
||||
|
||||
const DROP_ATTRS = [
|
||||
'srcdoc', 'xlink:href', 'srcset', 'action', 'formaction', 'poster',
|
||||
'background', 'data', 'cite', 'ping', 'longdesc', 'manifest', 'profile'
|
||||
]
|
||||
|
||||
const SAFE_URL_REGEXP = /^(?:https?:\/\/|data:image\/|blob:|mailto:|tel:|\{|#)/i
|
||||
|
||||
function isSafeAttr (name, value) {
|
||||
const lowerName = name.toLowerCase()
|
||||
|
||||
if (lowerName.startsWith('on') || DROP_ATTRS.includes(lowerName)) return false
|
||||
if ((lowerName === 'href' || lowerName === 'src') && !SAFE_URL_REGEXP.test(value.trim())) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function collectDomAttrs (dom) {
|
||||
const attrs = {}
|
||||
|
||||
for (let i = 0; i < dom.attributes.length; i++) {
|
||||
const { name, value } = dom.attributes[i]
|
||||
|
||||
if (isSafeAttr(name, value)) attrs[name] = value
|
||||
}
|
||||
|
||||
return { htmlAttrs: attrs }
|
||||
}
|
||||
|
||||
function collectSpanDomAttrs (dom) {
|
||||
const result = collectDomAttrs(dom)
|
||||
|
||||
if (result.htmlAttrs.style) {
|
||||
const temp = document.createElement('span')
|
||||
|
||||
temp.style.cssText = result.htmlAttrs.style
|
||||
|
||||
if (['bold', '700'].includes(temp.style.fontWeight)) {
|
||||
temp.style.removeProperty('font-weight')
|
||||
}
|
||||
|
||||
if (temp.style.fontStyle === 'italic') {
|
||||
temp.style.removeProperty('font-style')
|
||||
}
|
||||
|
||||
if (temp.style.textDecoration === 'underline') {
|
||||
temp.style.removeProperty('text-decoration')
|
||||
}
|
||||
|
||||
if (temp.style.cssText) {
|
||||
result.htmlAttrs.style = temp.style.cssText
|
||||
} else {
|
||||
delete result.htmlAttrs.style
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function buildExtensions ({ Node, Mark, Extension, Plugin, Decoration, DecorationSet }) {
|
||||
const blockNode = (name, tag, content, extra = {}) => Node.create({
|
||||
name,
|
||||
group: 'block',
|
||||
content: content || 'block+',
|
||||
...extra,
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag, getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return [tag, node.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const attrsMark = (name, tag) => Mark.create({
|
||||
name,
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag, getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ mark }) {
|
||||
return [tag, mark.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const SpanMark = Mark.create({
|
||||
name: 'span',
|
||||
excludes: '',
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'span', getAttrs: collectSpanDomAttrs }]
|
||||
},
|
||||
renderHTML ({ mark }) {
|
||||
return ['span', mark.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const toggleMark = (name, renderTag, parseRules, shortcuts) => Mark.create({
|
||||
name,
|
||||
parseHTML () {
|
||||
return parseRules
|
||||
},
|
||||
renderHTML () {
|
||||
return [renderTag, 0]
|
||||
},
|
||||
addCommands () {
|
||||
const commandName = `toggle${name[0].toUpperCase()}${name.slice(1)}`
|
||||
|
||||
return {
|
||||
[commandName]: () => ({ commands }) => commands.toggleMark(name)
|
||||
}
|
||||
},
|
||||
addKeyboardShortcuts () {
|
||||
return {
|
||||
[shortcuts]: () => this.editor.commands.toggleMark(name)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const Heading = Node.create({
|
||||
name: 'heading',
|
||||
group: 'block',
|
||||
content: 'inline*',
|
||||
addAttributes () {
|
||||
return {
|
||||
htmlAttrs: { default: {} },
|
||||
level: { default: 1 }
|
||||
}
|
||||
},
|
||||
parseHTML () {
|
||||
return [1, 2, 3, 4, 5, 6].map((level) => ({
|
||||
tag: `h${level}`,
|
||||
getAttrs: (dom) => ({ ...collectDomAttrs(dom), level })
|
||||
}))
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return [`h${node.attrs.level}`, node.attrs.htmlAttrs, 0]
|
||||
}
|
||||
})
|
||||
|
||||
const ImageNode = Node.create({
|
||||
name: 'image',
|
||||
inline: true,
|
||||
group: 'inline',
|
||||
draggable: true,
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'img', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['img', node.attrs.htmlAttrs]
|
||||
}
|
||||
})
|
||||
|
||||
const HrNode = Node.create({
|
||||
name: 'horizontalRule',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'hr', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['hr', node.attrs.htmlAttrs]
|
||||
}
|
||||
})
|
||||
|
||||
const StyleNode = Node.create({
|
||||
name: 'style',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
selectable: false,
|
||||
addAttributes () {
|
||||
return {
|
||||
htmlAttrs: { default: {} },
|
||||
css: { default: '' }
|
||||
}
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'style', getAttrs: (dom) => ({ ...collectDomAttrs(dom), css: dom.textContent }) }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['style', node.attrs.htmlAttrs, node.attrs.css]
|
||||
}
|
||||
})
|
||||
|
||||
const EmptySpanNode = Node.create({
|
||||
name: 'emptySpan',
|
||||
inline: true,
|
||||
group: 'inline',
|
||||
atom: true,
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{
|
||||
tag: 'span',
|
||||
priority: 60,
|
||||
getAttrs (dom) {
|
||||
if (dom.childNodes.length === 0 && dom.attributes.length > 0) {
|
||||
return collectDomAttrs(dom)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['span', node.attrs.htmlAttrs]
|
||||
}
|
||||
})
|
||||
|
||||
const LinkMark = Mark.create({
|
||||
name: 'link',
|
||||
inclusive: true,
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'a', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ mark }) {
|
||||
return ['a', mark.attrs.htmlAttrs, 0]
|
||||
},
|
||||
addCommands () {
|
||||
return {
|
||||
setLink: ({ href }) => ({ editor, commands }) => {
|
||||
const htmlAttrs = { ...(editor.getAttributes('link').htmlAttrs || {}), href }
|
||||
|
||||
return commands.setMark('link', { htmlAttrs })
|
||||
},
|
||||
unsetLink: () => ({ commands }) => commands.unsetMark('link', { extendEmptyMarkRange: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const buildDecorations = (doc) => {
|
||||
const decorations = []
|
||||
const regex = /\{\{?[a-zA-Z0-9_.-]+\}\}?/g
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (!node.isText) return
|
||||
|
||||
let match
|
||||
|
||||
while ((match = regex.exec(node.text)) !== null) {
|
||||
decorations.push(
|
||||
Decoration.inline(pos + match.index, pos + match.index + match[0].length, {
|
||||
class: 'variable-highlight'
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return DecorationSet.create(doc, decorations)
|
||||
}
|
||||
|
||||
const VariableHighlight = Extension.create({
|
||||
name: 'variableHighlight',
|
||||
addProseMirrorPlugins () {
|
||||
return [new Plugin({
|
||||
state: {
|
||||
init (_, { doc }) {
|
||||
return buildDecorations(doc)
|
||||
},
|
||||
apply (tr, oldSet) {
|
||||
return tr.docChanged ? buildDecorations(tr.doc) : oldSet
|
||||
}
|
||||
},
|
||||
props: {
|
||||
decorations (state) {
|
||||
return this.getState(state)
|
||||
}
|
||||
}
|
||||
})]
|
||||
}
|
||||
})
|
||||
|
||||
return [
|
||||
blockNode('paragraph', 'p', 'inline*'),
|
||||
Heading,
|
||||
blockNode('section', 'section'),
|
||||
blockNode('article', 'article', null, { isolating: true }),
|
||||
blockNode('header', 'header', null, { isolating: true }),
|
||||
blockNode('footer', 'footer', null, { isolating: true }),
|
||||
blockNode('div', 'div'),
|
||||
blockNode('center', 'center'),
|
||||
blockNode('blockquote', 'blockquote'),
|
||||
blockNode('pre', 'pre'),
|
||||
blockNode('orderedList', 'ol', '(listItem | block)+'),
|
||||
blockNode('bulletList', 'ul', '(listItem | block)+'),
|
||||
blockNode('listItem', 'li', 'block+', { group: null }),
|
||||
blockNode('table', 'table', '(colgroup | tableHead | tableBody | tableFoot | tableRow)+'),
|
||||
blockNode('tableHead', 'thead', 'tableRow+', { group: null }),
|
||||
blockNode('tableBody', 'tbody', 'tableRow+', { group: null }),
|
||||
blockNode('tableFoot', 'tfoot', 'tableRow+', { group: null }),
|
||||
blockNode('tableRow', 'tr', '(tableCell | tableHeader)+', { group: null }),
|
||||
blockNode('tableCell', 'td', 'block*', { group: null }),
|
||||
blockNode('tableHeader', 'th', 'block*', { group: null }),
|
||||
blockNode('colgroup', 'colgroup', 'col*', { group: null }),
|
||||
Node.create({
|
||||
name: 'col',
|
||||
atom: true,
|
||||
addAttributes () {
|
||||
return { htmlAttrs: { default: {} } }
|
||||
},
|
||||
parseHTML () {
|
||||
return [{ tag: 'col', getAttrs: collectDomAttrs }]
|
||||
},
|
||||
renderHTML ({ node }) {
|
||||
return ['col', node.attrs.htmlAttrs]
|
||||
}
|
||||
}),
|
||||
ImageNode,
|
||||
HrNode,
|
||||
StyleNode,
|
||||
EmptySpanNode,
|
||||
SpanMark,
|
||||
LinkMark,
|
||||
toggleMark('bold', 'strong', [{ tag: 'strong' }, { tag: 'b' }, { style: 'font-weight=bold' }, { style: 'font-weight=700' }], 'Mod-b'),
|
||||
toggleMark('italic', 'em', [{ tag: 'em' }, { tag: 'i' }, { style: 'font-style=italic' }], 'Mod-i'),
|
||||
toggleMark('underline', 'u', [{ tag: 'u' }, { style: 'text-decoration=underline' }], 'Mod-u'),
|
||||
toggleMark('strike', 's', [{ tag: 's' }, { tag: 'del' }, { tag: 'strike' }, { style: 'text-decoration=line-through' }], 'Mod-Shift-s'),
|
||||
attrsMark('subscript', 'sub'),
|
||||
attrsMark('superscript', 'sup'),
|
||||
VariableHighlight
|
||||
]
|
||||
}
|
||||
|
||||
export default actionable(targetable(class extends HTMLElement {
|
||||
static [target.static] = [
|
||||
'textarea',
|
||||
'editorElement',
|
||||
'boldButton',
|
||||
'italicButton',
|
||||
'underlineButton',
|
||||
'linkButton',
|
||||
'linkTooltipTemplate'
|
||||
]
|
||||
|
||||
async connectedCallback () {
|
||||
if (!this.textarea || !this.editorElement) return
|
||||
|
||||
this.textarea.style.display = 'none'
|
||||
this.adjustShortcutsForPlatform()
|
||||
|
||||
const tiptap = await loadTiptap()
|
||||
|
||||
const { Editor, Extension, Document, Text, HardBreak, UndoRedo, Gapcursor, Dropcursor } = tiptap
|
||||
|
||||
this.emailDocument = new DOMParser().parseFromString(this.textarea.value, 'text/html')
|
||||
|
||||
const shadow = this.editorElement.attachShadow({ mode: 'open' })
|
||||
|
||||
shadow.adoptedStyleSheets = [editorStylesheet]
|
||||
|
||||
this.emailDocument.head.querySelectorAll('style').forEach((style) => {
|
||||
shadow.appendChild(style.cloneNode(true))
|
||||
})
|
||||
|
||||
const container = document.createElement('div')
|
||||
const bodyStyle = this.emailDocument.body.getAttribute('style')
|
||||
|
||||
if (bodyStyle) container.setAttribute('style', bodyStyle)
|
||||
|
||||
shadow.appendChild(container)
|
||||
|
||||
const LinkShortcut = Extension.create({
|
||||
name: 'linkShortcut',
|
||||
addKeyboardShortcuts: () => ({
|
||||
'Mod-k': () => {
|
||||
this.toggleLink()
|
||||
|
||||
return true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.editor = new Editor({
|
||||
element: container,
|
||||
extensions: [
|
||||
Document,
|
||||
Text,
|
||||
HardBreak,
|
||||
UndoRedo,
|
||||
Gapcursor,
|
||||
Dropcursor,
|
||||
...buildExtensions(tiptap),
|
||||
LinkShortcut
|
||||
],
|
||||
content: this.emailDocument.body.innerHTML,
|
||||
injectCSS: false,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
dir: 'auto'
|
||||
},
|
||||
handleDOMEvents: {
|
||||
click: (_, event) => {
|
||||
if (event.target.closest('a')) event.preventDefault()
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
this.emailDocument.body.innerHTML = editor.getHTML()
|
||||
|
||||
this.textarea.value = this.emailDocument.documentElement.outerHTML
|
||||
this.textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
},
|
||||
onSelectionUpdate: ({ editor }) => {
|
||||
this.updateToolbarState()
|
||||
this.handleLinkTooltip(editor)
|
||||
},
|
||||
onBlur: () => {
|
||||
setTimeout(() => {
|
||||
if (!this.linkTooltip.tooltip.contains(document.activeElement)) {
|
||||
this.linkTooltip.hide()
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
})
|
||||
|
||||
this.linkTooltip = new LinkTooltip(this, this.editor, this.linkTooltipTemplate)
|
||||
}
|
||||
|
||||
adjustShortcutsForPlatform () {
|
||||
if ((navigator.userAgentData?.platform)?.toLowerCase()?.includes('mac')) {
|
||||
this.querySelectorAll('.tooltip[data-tip]').forEach(tooltip => {
|
||||
const tip = tooltip.getAttribute('data-tip')
|
||||
|
||||
if (tip && tip.includes('Ctrl')) {
|
||||
tooltip.setAttribute('data-tip', tip.replace(/Ctrl/g, '⌘'))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
bold (e) {
|
||||
e.preventDefault()
|
||||
|
||||
this.editor.chain().focus().toggleBold().run()
|
||||
this.updateToolbarState()
|
||||
}
|
||||
|
||||
italic (e) {
|
||||
e.preventDefault()
|
||||
|
||||
this.editor.chain().focus().toggleItalic().run()
|
||||
this.updateToolbarState()
|
||||
}
|
||||
|
||||
underline (e) {
|
||||
e.preventDefault()
|
||||
|
||||
this.editor.chain().focus().toggleUnderline().run()
|
||||
this.updateToolbarState()
|
||||
}
|
||||
|
||||
linkSelection (e) {
|
||||
e.preventDefault()
|
||||
|
||||
this.toggleLink()
|
||||
this.updateToolbarState()
|
||||
}
|
||||
|
||||
undo (e) {
|
||||
e.preventDefault()
|
||||
|
||||
this.editor.chain().focus().undo().run()
|
||||
this.updateToolbarState()
|
||||
}
|
||||
|
||||
redo (e) {
|
||||
e.preventDefault()
|
||||
|
||||
this.editor.chain().focus().redo().run()
|
||||
this.updateToolbarState()
|
||||
}
|
||||
|
||||
updateToolbarState () {
|
||||
this.boldButton.classList.toggle('bg-base-200', this.editor.isActive('bold'))
|
||||
this.italicButton.classList.toggle('bg-base-200', this.editor.isActive('italic'))
|
||||
this.underlineButton.classList.toggle('bg-base-200', this.editor.isActive('underline'))
|
||||
this.linkButton.classList.toggle('bg-base-200', this.editor.isActive('link'))
|
||||
}
|
||||
|
||||
handleLinkTooltip (editor) {
|
||||
const { from } = editor.state.selection
|
||||
const mark = editor.state.doc.resolve(from).marks().find(m => m.type.name === 'link')
|
||||
|
||||
if (!mark) {
|
||||
if (this.linkTooltip.isVisible()) this.linkTooltip.hide()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (this.linkTooltip.isVisible() && this.linkTooltip.currentMark === mark) return
|
||||
|
||||
let linkStart = from
|
||||
const start = editor.state.doc.resolve(from).start()
|
||||
|
||||
for (let i = from - 1; i >= start; i--) {
|
||||
if (editor.state.doc.resolve(i).marks().some(m => m.eq(mark))) {
|
||||
linkStart = i
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
this.linkTooltip.hide()
|
||||
this.linkTooltip.show(mark.attrs.htmlAttrs?.href, linkStart > start ? linkStart - 1 : linkStart)
|
||||
this.linkTooltip.currentMark = mark
|
||||
}
|
||||
|
||||
toggleLink () {
|
||||
if (this.editor.isActive('link')) {
|
||||
this.linkTooltip.hide()
|
||||
this.editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||
this.updateToolbarState()
|
||||
} else {
|
||||
const { from } = this.editor.state.selection
|
||||
|
||||
this.linkTooltip.hide()
|
||||
this.linkTooltip.show(this.editor.getAttributes('link').htmlAttrs?.href, from, { focus: true })
|
||||
}
|
||||
}
|
||||
|
||||
insertVariable (e) {
|
||||
const variable = e.target.closest('[data-variable]')?.dataset.variable
|
||||
|
||||
if (variable) {
|
||||
const { from, to } = this.editor.state.selection
|
||||
|
||||
if (variable.includes('link') && from !== to) {
|
||||
this.editor.chain().focus().setLink({ href: `{${variable}}` }).run()
|
||||
} else {
|
||||
this.editor.chain().focus().insertContent(`{${variable}}`).run()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.linkTooltip?.hide()
|
||||
|
||||
if (this.editor) {
|
||||
this.editor.destroy()
|
||||
}
|
||||
}
|
||||
}))
|
||||
@@ -35,7 +35,7 @@ function loadTiptap () {
|
||||
}))
|
||||
}
|
||||
|
||||
class LinkTooltip {
|
||||
export class LinkTooltip {
|
||||
constructor (container, editor, templateEl) {
|
||||
this.container = container
|
||||
this.editor = editor
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
if (this.dataset.disabled === 'true') return
|
||||
if (!this.isMobile()) return
|
||||
if (window.innerWidth >= 768) return
|
||||
|
||||
this.querySelectorAll('a[data-turbo-frame]').forEach((link) => {
|
||||
link.dataset.turboFrame = '_top'
|
||||
})
|
||||
}
|
||||
|
||||
isMobile () {
|
||||
const isTouchWebkit = 'ontouchstart' in window && navigator.maxTouchPoints > 0 && /AppleWebKit|android/i.test(navigator.userAgent)
|
||||
|
||||
return isTouchWebkit || /iPhone|iPad|iPod/i.test(navigator.userAgent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.addEventListener('scroll', this.updateFade, { passive: true })
|
||||
window.addEventListener('resize', this.updateFade)
|
||||
|
||||
this.updateFade()
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
window.removeEventListener('resize', this.updateFade)
|
||||
}
|
||||
|
||||
updateFade = () => {
|
||||
const maxScroll = this.scrollWidth - this.clientWidth
|
||||
const fadeGradient = {
|
||||
right: 'linear-gradient(to right, black calc(100% - 24px), transparent)',
|
||||
left: 'linear-gradient(to right, transparent, black 24px)',
|
||||
both: 'linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), transparent)'
|
||||
}
|
||||
|
||||
let state = 'none'
|
||||
|
||||
if (maxScroll > 4) {
|
||||
if (this.scrollLeft <= 4) {
|
||||
state = 'right'
|
||||
} else if (this.scrollLeft >= maxScroll - 4) {
|
||||
state = 'left'
|
||||
} else {
|
||||
state = 'both'
|
||||
}
|
||||
}
|
||||
|
||||
this.applyFade(fadeGradient[state] || '')
|
||||
}
|
||||
|
||||
applyFade (gradient) {
|
||||
this.style.maskImage = gradient
|
||||
this.style.webkitMaskImage = gradient
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export default actionable(class extends HTMLElement {
|
||||
|
||||
document.addEventListener('keyup', this.onEscKey)
|
||||
|
||||
document.addEventListener('turbo:before-cache', this.close)
|
||||
document.addEventListener('turbo:before-cache', this.onBeforeCache)
|
||||
|
||||
if (this.dataset.closeAfterSubmit !== 'false') {
|
||||
document.addEventListener('turbo:submit-end', this.onSubmit)
|
||||
@@ -18,7 +18,7 @@ export default actionable(class extends HTMLElement {
|
||||
|
||||
document.removeEventListener('keyup', this.onEscKey)
|
||||
document.removeEventListener('turbo:submit-end', this.onSubmit)
|
||||
document.removeEventListener('turbo:before-cache', this.close)
|
||||
document.removeEventListener('turbo:before-cache', this.onBeforeCache)
|
||||
}
|
||||
|
||||
onSubmit = (e) => {
|
||||
@@ -33,6 +33,12 @@ export default actionable(class extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeCache = () => {
|
||||
if (this.closest('turbo-frame')?.src) {
|
||||
this.remove()
|
||||
}
|
||||
}
|
||||
|
||||
close = (e) => {
|
||||
e?.preventDefault()
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ safeRegisterElement('submission-form', class extends HTMLElement {
|
||||
scrollPadding: this.dataset.scrollPadding || '-80px',
|
||||
signatureText: this.dataset.signatureText,
|
||||
language: this.dataset.language,
|
||||
paymentProvider: this.dataset.paymentProvider,
|
||||
dryRun: this.dataset.dryRun === 'true',
|
||||
expand: ['true', 'false'].includes(this.dataset.expand) ? this.dataset.expand === 'true' : null,
|
||||
withSignatureId: this.dataset.withSignatureId === 'true',
|
||||
|
||||
@@ -69,8 +69,8 @@
|
||||
:attachments-index="attachmentsIndex"
|
||||
/>
|
||||
<FieldArea
|
||||
v-else-if="item.type === 'formula_area' && isMathLoaded"
|
||||
:model-value="calculateFormula(item.field)"
|
||||
v-else-if="item.type === 'formula_area' && (isMathLoaded || item.field.type === 'text')"
|
||||
:model-value="item.field.type === 'text' ? evalTextFormula(item.field) : calculateFormula(item.field)"
|
||||
:is-inline-size="isInlineSize"
|
||||
:field="item.field"
|
||||
:area="item.area"
|
||||
@@ -289,6 +289,7 @@ export default {
|
||||
methods: {
|
||||
normalizeFormula: FormulaAreas.methods.normalizeFormula,
|
||||
calculateFormula: FormulaAreas.methods.calculateFormula,
|
||||
evalTextFormula: FormulaAreas.methods.evalTextFormula,
|
||||
scrollInContainer: FieldAreas.methods.scrollInContainer,
|
||||
scrollIntoArea: FieldAreas.methods.scrollIntoArea,
|
||||
scrollIntoField: FieldAreas.methods.scrollIntoField,
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
@submit.prevent="submitStep"
|
||||
/>
|
||||
<button
|
||||
v-if="!isFormVisible"
|
||||
v-if="!isFormVisible && currentField"
|
||||
id="expand_form_button"
|
||||
class="btn btn-neutral flex text-white absolute bottom-0 w-full mb-3 expand-form-button text-base"
|
||||
style="width: 96%; margin-left: 2%"
|
||||
@@ -174,6 +174,7 @@
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
v-if="currentField"
|
||||
v-show="isFormVisible"
|
||||
id="form_container"
|
||||
class="shadow-md bg-base-100 absolute bottom-0 w-full border-base-200 border p-4 rounded form-container overflow-hidden"
|
||||
@@ -559,6 +560,7 @@
|
||||
:fields="formulaFields"
|
||||
:values="values"
|
||||
:readonly-values="readonlyFieldValues"
|
||||
:provider="paymentProvider"
|
||||
@attached="attachments.push($event)"
|
||||
@focus="scrollIntoField(currentField)"
|
||||
@submit="!isSubmitting && submitStep()"
|
||||
@@ -999,6 +1001,11 @@ export default {
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
paymentProvider: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
values: {
|
||||
type: Object,
|
||||
required: false,
|
||||
@@ -1172,7 +1179,11 @@ export default {
|
||||
}
|
||||
},
|
||||
isAnonymousChecboxes () {
|
||||
return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name && !e.required) && this.currentStepFields.length > 4
|
||||
if (this.currentField) {
|
||||
return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name && !e.required) && this.currentStepFields.length > 4
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
},
|
||||
isButtonDisabled () {
|
||||
if (this.recalculateButtonDisabledKey) {
|
||||
@@ -1570,6 +1581,7 @@ export default {
|
||||
}
|
||||
},
|
||||
goToStep (stepIndex, scrollToArea = false, clickUpload = false) {
|
||||
this.isInvite = false
|
||||
this.currentStep = stepIndex
|
||||
this.showFillAllRequiredFields = false
|
||||
|
||||
@@ -1594,6 +1606,10 @@ export default {
|
||||
})
|
||||
},
|
||||
saveStep (formData) {
|
||||
if (!formData && !this.$refs.form) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentFieldUuids = this.currentStepFields.map((f) => f.uuid)
|
||||
const currentFieldType = this.currentField.type
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
:to="findPageElementForArea(area)"
|
||||
>
|
||||
<FieldArea
|
||||
v-if="isMathLoaded"
|
||||
:model-value="calculateFormula(field)"
|
||||
v-if="isMathLoaded || field.type === 'text'"
|
||||
:model-value="field.type === 'text' ? evalTextFormula(field) : calculateFormula(field)"
|
||||
:is-inline-size="isInlineSize"
|
||||
:field="field"
|
||||
:area="area"
|
||||
@@ -95,6 +95,25 @@ export default {
|
||||
})
|
||||
|
||||
return this.math.evaluate(transformedFormula.toLowerCase())
|
||||
},
|
||||
evalTextFormula (field, depth = 0) {
|
||||
if (depth > 10) return ''
|
||||
|
||||
return field.preferences.formula.replace(/{{(.*?)}}/g, (match, uuid) => {
|
||||
const formulaField = this.fieldsUuidIndex[uuid]
|
||||
|
||||
if (formulaField?.preferences?.formula) {
|
||||
if (formulaField.type === 'text') {
|
||||
return this.evalTextFormula(formulaField, depth + 1)
|
||||
} else if (this.isMathLoaded) {
|
||||
return this.calculateFormula(formulaField)
|
||||
}
|
||||
}
|
||||
|
||||
const value = this.readonlyValues[uuid] ?? this.values[uuid]
|
||||
|
||||
return Array.isArray(value) ? value.join(', ') : (value ?? '')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ const en = {
|
||||
email_has_been_sent: 'Email has been sent',
|
||||
processing: 'Processing',
|
||||
pay_with_stripe: 'Pay with Stripe',
|
||||
pay_with_paypal: 'Pay with PayPal',
|
||||
reupload: 'Reupload',
|
||||
upload: 'Upload',
|
||||
files: 'Files',
|
||||
@@ -225,6 +226,7 @@ const es = {
|
||||
email_has_been_sent: 'El correo electrónico ha sido enviado',
|
||||
processing: 'Procesando',
|
||||
pay_with_stripe: 'Pagar con Stripe',
|
||||
pay_with_paypal: 'Pagar con PayPal',
|
||||
reupload: 'Volver a subir',
|
||||
upload: 'Subir',
|
||||
files: 'Archivos',
|
||||
@@ -343,6 +345,7 @@ const it = {
|
||||
email_has_been_sent: "L'email è stata inviata",
|
||||
processing: 'Elaborazione',
|
||||
pay_with_stripe: 'Paga con Stripe',
|
||||
pay_with_paypal: 'Paga con PayPal',
|
||||
reupload: 'Ricarica',
|
||||
upload: 'Carica',
|
||||
files: 'File',
|
||||
@@ -461,6 +464,7 @@ const de = {
|
||||
email_has_been_sent: 'E-Mail wurde gesendet',
|
||||
processing: 'Verarbeitung',
|
||||
pay_with_stripe: 'Mit Stripe bezahlen',
|
||||
pay_with_paypal: 'Mit PayPal bezahlen',
|
||||
reupload: 'Erneut hochladen',
|
||||
upload: 'Hochladen',
|
||||
files: 'Dateien',
|
||||
@@ -579,6 +583,7 @@ const fr = {
|
||||
email_has_been_sent: "L'e-mail a été envoyé",
|
||||
processing: 'Traitement en cours',
|
||||
pay_with_stripe: 'Payer avec Stripe',
|
||||
pay_with_paypal: 'Payer avec PayPal',
|
||||
reupload: 'Retéléverser',
|
||||
upload: 'Téléverser',
|
||||
files: 'Fichiers',
|
||||
@@ -697,6 +702,7 @@ const pl = {
|
||||
email_has_been_sent: 'E-mail został wysłany',
|
||||
processing: 'Przetwarzanie',
|
||||
pay_with_stripe: 'Zapłać za pomocą Stripe',
|
||||
pay_with_paypal: 'Zapłać za pomocą PayPal',
|
||||
reupload: 'Ponowne przesłanie',
|
||||
upload: 'Przesyłanie',
|
||||
files: 'Pliki',
|
||||
@@ -815,6 +821,7 @@ const uk = {
|
||||
email_has_been_sent: 'Електронний лист був відправлений',
|
||||
processing: 'Обробка',
|
||||
pay_with_stripe: 'Сплатити за допомогою Stripe',
|
||||
pay_with_paypal: 'Сплатити за допомогою PayPal',
|
||||
reupload: 'Перезавантажити',
|
||||
upload: 'Завантажити',
|
||||
files: 'Файли',
|
||||
@@ -933,6 +940,7 @@ const cs = {
|
||||
email_has_been_sent: 'E-mail byl odeslán',
|
||||
processing: 'Zpracování',
|
||||
pay_with_stripe: 'Zaplatit přes Stripe',
|
||||
pay_with_paypal: 'Zaplatit přes PayPal',
|
||||
reupload: 'Znovu nahrát',
|
||||
upload: 'Nahrát',
|
||||
files: 'Soubory',
|
||||
@@ -1051,6 +1059,7 @@ const pt = {
|
||||
email_has_been_sent: 'Email enviado',
|
||||
processing: 'Processamento',
|
||||
pay_with_stripe: 'Pagar com Stripe',
|
||||
pay_with_paypal: 'Pagar com PayPal',
|
||||
reupload: 'Recarregar',
|
||||
upload: 'Carregar',
|
||||
files: 'Arquivos',
|
||||
@@ -1169,6 +1178,7 @@ const he = {
|
||||
email_has_been_sent: 'האימייל נשלח',
|
||||
processing: 'מעבד',
|
||||
pay_with_stripe: 'שלם עם סטרייפ',
|
||||
pay_with_paypal: 'שלם עם פייפאל',
|
||||
reupload: 'העלה שוב',
|
||||
upload: 'העלאה',
|
||||
files: 'קבצים',
|
||||
@@ -1287,6 +1297,7 @@ const nl = {
|
||||
email_has_been_sent: 'E-mail is verzonden',
|
||||
processing: 'Verwerken',
|
||||
pay_with_stripe: 'Betalen met Stripe',
|
||||
pay_with_paypal: 'Betalen met PayPal',
|
||||
reupload: 'Opnieuw uploaden',
|
||||
upload: 'Uploaden',
|
||||
files: 'Bestanden',
|
||||
@@ -1405,6 +1416,7 @@ const ar = {
|
||||
email_has_been_sent: 'تم إرسال البريد الإلكتروني',
|
||||
processing: 'جارٍ المعالجة',
|
||||
pay_with_stripe: 'الدفع بواسطة Stripe',
|
||||
pay_with_paypal: 'الدفع بواسطة PayPal',
|
||||
reupload: 'إعادة التحميل',
|
||||
upload: 'تحميل',
|
||||
files: 'الملفات',
|
||||
@@ -1523,6 +1535,7 @@ const ko = {
|
||||
email_has_been_sent: '이메일이 전송되었습니다',
|
||||
processing: '처리 중',
|
||||
pay_with_stripe: '스트라이프로 결제',
|
||||
pay_with_paypal: 'PayPal로 결제',
|
||||
reupload: '다시 업로드',
|
||||
upload: '업로드',
|
||||
files: '파일',
|
||||
@@ -1641,6 +1654,7 @@ const ja = {
|
||||
email_has_been_sent: 'メールが送信されました',
|
||||
processing: '処理中',
|
||||
pay_with_stripe: 'Stripeで支払う',
|
||||
pay_with_paypal: 'PayPalで支払う',
|
||||
reupload: '再アップロード',
|
||||
upload: 'アップロード',
|
||||
files: 'ファイル',
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<label
|
||||
v-if="!modelValue && !sessionId"
|
||||
v-if="!modelValue && !isProcessing"
|
||||
class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5 field-name-label"
|
||||
>
|
||||
<MarkdownContent
|
||||
@@ -25,14 +25,14 @@
|
||||
class="hidden"
|
||||
>
|
||||
<div
|
||||
v-if="modelValue && !sessionId"
|
||||
v-if="modelValue && !isProcessing"
|
||||
class=" text-2xl mb-2"
|
||||
>
|
||||
{{ t('already_paid') }}
|
||||
</div>
|
||||
<div v-else>
|
||||
<button
|
||||
v-if="sessionId"
|
||||
v-if="isProcessing"
|
||||
type="button"
|
||||
disabled
|
||||
class="base-button w-full modal-save-button"
|
||||
@@ -49,8 +49,8 @@
|
||||
v-else
|
||||
:id="field.uuid"
|
||||
type="button"
|
||||
class="btn bg-[#7B73FF] text-white hover:bg-[#0A2540] text-lg w-full"
|
||||
:class="{ disabled: isCreatingCheckout }"
|
||||
class="btn text-white text-lg w-full"
|
||||
:class="[isPaypal ? 'bg-[#003087] hover:bg-[#012169]' : 'bg-[#7B73FF] hover:bg-[#0A2540]', { disabled: isCreatingCheckout }]"
|
||||
:disabled="isCreatingCheckout"
|
||||
@click.prevent="postCheckout"
|
||||
>
|
||||
@@ -59,12 +59,16 @@
|
||||
width="22"
|
||||
class="animate-spin"
|
||||
/>
|
||||
<IconBrandPaypal
|
||||
v-else-if="isPaypal"
|
||||
width="22"
|
||||
/>
|
||||
<IconBrandStripe
|
||||
v-else
|
||||
width="22"
|
||||
/>
|
||||
<span>
|
||||
{{ t('pay_with_stripe') }}
|
||||
{{ isPaypal ? t('pay_with_paypal') : t('pay_with_stripe') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -72,13 +76,14 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconBrandStripe, IconInnerShadowTop, IconLoader } from '@tabler/icons-vue'
|
||||
import { IconBrandStripe, IconBrandPaypal, IconInnerShadowTop, IconLoader } from '@tabler/icons-vue'
|
||||
import MarkdownContent from './markdown_content'
|
||||
|
||||
export default {
|
||||
name: 'PaymentStep',
|
||||
components: {
|
||||
IconBrandStripe,
|
||||
IconBrandPaypal,
|
||||
MarkdownContent,
|
||||
IconInnerShadowTop,
|
||||
IconLoader
|
||||
@@ -111,12 +116,18 @@ export default {
|
||||
submitterSlug: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
provider: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
emits: ['focus', 'submit', 'update:model-value', 'attached'],
|
||||
data () {
|
||||
return {
|
||||
isCreatingCheckout: false,
|
||||
isProcessing: false,
|
||||
isMathLoaded: false
|
||||
}
|
||||
},
|
||||
@@ -131,8 +142,14 @@ export default {
|
||||
queryParams () {
|
||||
return new URLSearchParams(window.location.search)
|
||||
},
|
||||
isPaypal () {
|
||||
return this.provider === 'paypal'
|
||||
},
|
||||
paymentEndpoint () {
|
||||
return this.isPaypal ? '/api/paypal_payments' : '/api/stripe_payments'
|
||||
},
|
||||
sessionId () {
|
||||
return this.queryParams.get('stripe_session_id')
|
||||
return this.queryParams.get('stripe_session_id') || this.queryParams.get('paypal_order_id') || (this.isPaypal && this.queryParams.get('token'))
|
||||
},
|
||||
defaultName () {
|
||||
if (this.field.preferences?.price_id || this.field.preferences?.payment_link_id) {
|
||||
@@ -159,6 +176,8 @@ export default {
|
||||
},
|
||||
async mounted () {
|
||||
if (this.sessionId) {
|
||||
this.isProcessing = true
|
||||
|
||||
this.$emit('submit')
|
||||
}
|
||||
|
||||
@@ -175,6 +194,21 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
normalizedPaymentUrl () {
|
||||
const url = new URL(window.location.href)
|
||||
const keys = ['stripe_session_id', 'paypal_order_id', 'field_uuid']
|
||||
|
||||
if (this.isPaypal) {
|
||||
keys.push('token', 'PayerID')
|
||||
}
|
||||
|
||||
keys.forEach((key) => url.searchParams.delete(key))
|
||||
|
||||
return url.toString()
|
||||
},
|
||||
normalizedPaymentParams () {
|
||||
window.history.replaceState({}, document.title, this.normalizedPaymentUrl())
|
||||
},
|
||||
calculateFormula () {
|
||||
const transformedFormula = this.normalizeFormula(this.field.preferences.formula).replace(/{{(.*?)}}/g, (match, uuid) => {
|
||||
return this.readonlyValues[uuid] || this.values[uuid] || 0.0
|
||||
@@ -195,7 +229,7 @@ export default {
|
||||
},
|
||||
async submit () {
|
||||
if (this.sessionId) {
|
||||
return fetch(this.baseUrl + '/api/stripe_payments/' + this.sessionId, {
|
||||
return fetch(this.baseUrl + this.paymentEndpoint + '/' + this.sessionId, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
submitter_slug: this.submitterSlug
|
||||
@@ -203,16 +237,22 @@ export default {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}).then(async (resp) => {
|
||||
if (resp.status === 422 || resp.status === 500) {
|
||||
this.isProcessing = false
|
||||
|
||||
const data = await resp.json()
|
||||
|
||||
alert(data.error || 'Unexpected error')
|
||||
if (data.error) {
|
||||
alert(data.error)
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(data.error))
|
||||
}
|
||||
|
||||
const attachment = await resp.json()
|
||||
|
||||
window.history.replaceState({}, document.title, window.location.pathname)
|
||||
this.isProcessing = false
|
||||
|
||||
this.normalizedPaymentParams()
|
||||
|
||||
this.$emit('update:model-value', attachment.uuid)
|
||||
this.$emit('attached', attachment)
|
||||
@@ -226,13 +266,13 @@ export default {
|
||||
postCheckout ({ checkStatus } = {}) {
|
||||
this.isCreatingCheckout = true
|
||||
|
||||
fetch(this.baseUrl + '/api/stripe_payments', {
|
||||
fetch(this.baseUrl + this.paymentEndpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
submitter_slug: this.submitterSlug,
|
||||
field_uuid: this.field.uuid,
|
||||
check_status: checkStatus,
|
||||
referer: document.location.href
|
||||
referer: this.normalizedPaymentUrl()
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}).then(async (resp) => {
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
required
|
||||
maxlength="6"
|
||||
autofocus
|
||||
inputmode="decimal"
|
||||
inputmode="numeric"
|
||||
@input="onInputCode"
|
||||
>
|
||||
<div class="flex justify-between mt-2 -mb-2 md:-mb-4">
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
v-if="withSendButton"
|
||||
id="send_button"
|
||||
:href="`/templates/${template.id}/submissions/new?with_link=true`"
|
||||
data-turbo-frame="modal"
|
||||
:data-turbo-frame="isMobile && isBreakpointLg ? '_top' : 'modal'"
|
||||
class="white-button md:!px-6"
|
||||
@click="maybeShowErrorTemplateAlert"
|
||||
>
|
||||
@@ -202,7 +202,7 @@
|
||||
</button>
|
||||
<div
|
||||
class="dropdown dropdown-end"
|
||||
:class="{ 'dropdown-open': isDownloading }"
|
||||
:class="{ 'dropdown-open': isDownloading || isPreviewLoading }"
|
||||
>
|
||||
<label
|
||||
tabindex="0"
|
||||
@@ -221,15 +221,23 @@
|
||||
:href="`/templates/${template.id}/form`"
|
||||
data-turbo="false"
|
||||
class="flex items-center justify-center space-x-2"
|
||||
@click.exact="isPreviewLoading = true"
|
||||
>
|
||||
<IconEye class="w-6 h-6 flex-shrink-0" />
|
||||
<IconInnerShadowTop
|
||||
v-if="isPreviewLoading"
|
||||
class="animate-spin w-6 h-6 flex-shrink-0"
|
||||
/>
|
||||
<IconEye
|
||||
v-else
|
||||
class="w-6 h-6 flex-shrink-0"
|
||||
/>
|
||||
<span class="whitespace-nowrap">{{ t('save_and_preview') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
:href="`/templates/${template.id}/preferences`"
|
||||
data-turbo-frame="modal"
|
||||
:data-turbo-frame="isMobile && isBreakpointLg ? '_top' : 'modal'"
|
||||
class="flex space-x-2"
|
||||
@click="closeDropdown"
|
||||
>
|
||||
@@ -281,6 +289,10 @@
|
||||
:href="`/templates/${template.id}`"
|
||||
class="base-button"
|
||||
>
|
||||
<IconX
|
||||
width="20"
|
||||
class="md:hidden stroke-2"
|
||||
/>
|
||||
<span class="hidden md:inline">
|
||||
{{ t('back') }}
|
||||
</span>
|
||||
@@ -779,7 +791,10 @@ export default {
|
||||
withVerification: this.withVerification,
|
||||
withKba: this.withKba,
|
||||
withPayment: this.withPayment,
|
||||
isPaymentConnected: this.isPaymentConnected,
|
||||
isStripeConnected: this.isStripeConnected,
|
||||
withStripe: this.withStripe,
|
||||
withPaypal: this.withPaypal,
|
||||
isPaypalConnected: this.isPaypalConnected,
|
||||
withFormula: this.withFormula,
|
||||
withConditions: this.withConditions,
|
||||
withCustomFields: this.withCustomFields,
|
||||
@@ -1049,7 +1064,22 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
isPaymentConnected: {
|
||||
isStripeConnected: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withStripe: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
withPaypal: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
isPaypalConnected: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
@@ -1100,6 +1130,7 @@ export default {
|
||||
documentRefs: [],
|
||||
isBreakpointLg: false,
|
||||
isDownloading: false,
|
||||
isPreviewLoading: false,
|
||||
isLoadingBlankPage: false,
|
||||
isSaving: false,
|
||||
isDetectingPageFields: false,
|
||||
@@ -1241,7 +1272,7 @@ export default {
|
||||
|
||||
return formulaFields.reduce((acc, f) => {
|
||||
if (this.conditionalFieldIndex[f.uuid] !== false) {
|
||||
acc[f.uuid] = this.calculateFormula(f)
|
||||
acc[f.uuid] = f.type === 'text' ? this.evalTextFormula(f) : this.calculateFormula(f)
|
||||
}
|
||||
|
||||
return acc
|
||||
@@ -1363,6 +1394,10 @@ export default {
|
||||
if (document.location.search?.includes('stripe_connect_success')) {
|
||||
document.querySelector('form[action="/auth/stripe_connect"]')?.closest('.dropdown')?.querySelector('label')?.focus()
|
||||
}
|
||||
|
||||
if (document.location.search?.includes('paypal_connect_success')) {
|
||||
document.querySelector('a[href^="/auth/paypal_connect"]')?.closest('.dropdown')?.querySelector('label')?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
this.template.schema.forEach((item) => {
|
||||
@@ -1504,6 +1539,25 @@ export default {
|
||||
|
||||
return this.math.evaluate(transformedFormula.toLowerCase())
|
||||
},
|
||||
evalTextFormula (field, depth = 0) {
|
||||
if (depth > 10) return ''
|
||||
|
||||
return field.preferences.formula.replace(/{{(.*?)}}/g, (match, uuid) => {
|
||||
const formulaField = this.fieldsUuidIndex[uuid]
|
||||
|
||||
if (formulaField?.preferences?.formula) {
|
||||
if (formulaField.type === 'text') {
|
||||
return this.evalTextFormula(formulaField, depth + 1)
|
||||
} else if (this.isMathLoaded) {
|
||||
return this.calculateFormula(formulaField)
|
||||
}
|
||||
}
|
||||
|
||||
const value = formulaField?.default_value
|
||||
|
||||
return Array.isArray(value) ? value.join(', ') : (value ?? '')
|
||||
})
|
||||
},
|
||||
hasFormulaDependencyValue (field) {
|
||||
const normalized = this.normalizeFormula(field.preferences.formula)
|
||||
|
||||
@@ -3206,15 +3260,6 @@ export default {
|
||||
e.preventDefault()
|
||||
|
||||
alert(this.t('please_draw_fields_to_prepare_the_document'))
|
||||
} else {
|
||||
const submitterWithoutFields =
|
||||
this.template.submitters.find((submitter) => !this.template.fields.some((f) => f.submitter_uuid === submitter.uuid))
|
||||
|
||||
if (submitterWithoutFields) {
|
||||
e.preventDefault()
|
||||
|
||||
alert(this.t('please_add_fields_for_the_submitter_name_or_remove_the_submitter_name_if_not_needed').replaceAll('{submitter_name}', submitterWithoutFields.name))
|
||||
}
|
||||
}
|
||||
},
|
||||
onSaveClick () {
|
||||
@@ -3231,32 +3276,25 @@ export default {
|
||||
if (!this.template.fields.length) {
|
||||
alert(this.t('please_draw_fields_to_prepare_the_document'))
|
||||
} else {
|
||||
const submitterWithoutFields =
|
||||
this.template.submitters.find((submitter) => !this.template.fields.some((f) => f.submitter_uuid === submitter.uuid))
|
||||
this.isSaving = true
|
||||
|
||||
if (submitterWithoutFields) {
|
||||
alert(this.t('please_add_fields_for_the_submitter_name_or_remove_the_submitter_name_if_not_needed').replaceAll('{submitter_name}', submitterWithoutFields.name))
|
||||
} else {
|
||||
this.isSaving = true
|
||||
const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic)
|
||||
|
||||
const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic)
|
||||
dynamicDocumentRefs.map((ref) => ref.update())
|
||||
|
||||
dynamicDocumentRefs.map((ref) => ref.update())
|
||||
this.rebuildVariablesSchema({ disable: false })
|
||||
|
||||
this.rebuildVariablesSchema({ disable: false })
|
||||
const dynamicDocumentSaves = dynamicDocumentRefs.map((ref) => ref.saveBody())
|
||||
|
||||
const dynamicDocumentSaves = dynamicDocumentRefs.map((ref) => ref.saveBody())
|
||||
Promise.all([this.save({ force: true }), ...dynamicDocumentSaves]).then(() => {
|
||||
if (this.withRevisions) {
|
||||
this.captureRevision()
|
||||
}
|
||||
|
||||
Promise.all([this.save({ force: true }), ...dynamicDocumentSaves]).then(() => {
|
||||
if (this.withRevisions) {
|
||||
this.captureRevision()
|
||||
}
|
||||
|
||||
window.Turbo.visit(`/templates/${this.template.id}`)
|
||||
}).finally(() => {
|
||||
this.isSaving = false
|
||||
})
|
||||
}
|
||||
window.Turbo.visit(`/templates/${this.template.id}`)
|
||||
}).finally(() => {
|
||||
this.isSaving = false
|
||||
})
|
||||
}
|
||||
},
|
||||
scrollToArea (area) {
|
||||
|
||||
@@ -98,11 +98,29 @@ dynamic-variable {
|
||||
overflow-wrap: anywhere;
|
||||
}`)
|
||||
|
||||
const DROP_ATTRS = [
|
||||
'srcdoc', 'xlink:href', 'srcset', 'action', 'formaction', 'poster',
|
||||
'background', 'data', 'cite', 'ping', 'longdesc', 'manifest', 'profile'
|
||||
]
|
||||
|
||||
const SAFE_URL_REGEXP = /^(?:https?:\/\/|data:image\/|blob:|mailto:|tel:|\{|#)/i
|
||||
|
||||
function isSafeAttr (name, value) {
|
||||
const lowerName = name.toLowerCase()
|
||||
|
||||
if (lowerName.startsWith('on') || DROP_ATTRS.includes(lowerName)) return false
|
||||
if ((lowerName === 'href' || lowerName === 'src') && !SAFE_URL_REGEXP.test(value.trim())) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function collectDomAttrs (dom) {
|
||||
const attrs = {}
|
||||
|
||||
for (let i = 0; i < dom.attributes.length; i++) {
|
||||
attrs[dom.attributes[i].name] = dom.attributes[i].value
|
||||
const { name, value } = dom.attributes[i]
|
||||
|
||||
if (isSafeAttr(name, value)) attrs[name] = value
|
||||
}
|
||||
|
||||
return { htmlAttrs: attrs }
|
||||
|
||||
@@ -646,9 +646,7 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
const container = document.createElement('div')
|
||||
|
||||
container.innerHTML = clipboardHtml
|
||||
const container = new DOMParser().parseFromString(clipboardHtml, 'text/html').body
|
||||
|
||||
const fieldNodes = [...container.querySelectorAll('dynamic-field[data-field][data-area]')]
|
||||
|
||||
|
||||
@@ -494,6 +494,7 @@ import FieldType from './field_type.vue'
|
||||
import FieldSettings from './field_settings.vue'
|
||||
import ContextSubmenu from './field_context_submenu.vue'
|
||||
import ContextModal from './field_context_modal.vue'
|
||||
import { stripeConnectedRef } from './payment_settings'
|
||||
|
||||
export default {
|
||||
name: 'FieldContextMenu',
|
||||
@@ -610,7 +611,7 @@ export default {
|
||||
return this.withCondition && !['stamp', 'heading'].includes(this.field.type)
|
||||
},
|
||||
showFormula () {
|
||||
return this.field.type === 'number' || this.field.type === 'payment'
|
||||
return this.field.type === 'number' || this.field.type === 'payment' || !!this.field.preferences?.formula
|
||||
},
|
||||
showRequired () {
|
||||
return this.withRequired && !['phone', 'stamp', 'verification', 'strikethrough', 'heading'].includes(this.field.type)
|
||||
@@ -716,12 +717,18 @@ export default {
|
||||
currentCurrency () {
|
||||
return this.field.preferences?.currency || 'USD'
|
||||
},
|
||||
isStripeConnected: () => stripeConnectedRef.value,
|
||||
priceTypeOptions () {
|
||||
return [
|
||||
const options = [
|
||||
{ value: 'one_off', label: this.t('fixed') },
|
||||
{ value: 'formula', label: this.t('formula') },
|
||||
{ value: 'payment_link', label: this.t('payment_link') }
|
||||
{ value: 'formula', label: this.t('formula') }
|
||||
]
|
||||
|
||||
if (this.isStripeConnected) {
|
||||
options.push({ value: 'payment_link', label: this.t('payment_link') })
|
||||
}
|
||||
|
||||
return options
|
||||
},
|
||||
currentPriceType () {
|
||||
if (this.field.preferences?.formula) {
|
||||
|
||||
@@ -500,7 +500,7 @@
|
||||
</label>
|
||||
</li>
|
||||
<li
|
||||
v-if="field.type == 'number'"
|
||||
v-if="field.type == 'number' || field.preferences?.formula"
|
||||
class="field-settings-formula"
|
||||
>
|
||||
<label
|
||||
@@ -830,6 +830,7 @@ export default {
|
||||
} else if (format === 'percent') {
|
||||
return `${number}%`
|
||||
} else if (format === 'percent_space') {
|
||||
// eslint-disable-next-line no-irregular-whitespace
|
||||
return `${String(number).replace('.', ',')} %`
|
||||
} else {
|
||||
return number
|
||||
|
||||
@@ -61,7 +61,10 @@
|
||||
@input="resizeTextarea"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-3 mt-1">
|
||||
<div
|
||||
v-if="field.type !== 'text'"
|
||||
class="mb-3 mt-1"
|
||||
>
|
||||
<div
|
||||
target="blank"
|
||||
class="text-sm mb-2 inline space-x-2 font-mono"
|
||||
@@ -161,7 +164,9 @@ export default {
|
||||
computed: {
|
||||
fields () {
|
||||
return this.template.fields.reduce((acc, f) => {
|
||||
if (f !== this.field && this.isNumberField(f) && (!f.preferences?.formula || !f.preferences.formula.includes(this.field.uuid))) {
|
||||
const isAllowed = this.field.type === 'text' ? this.isTextField(f) : this.isNumberField(f)
|
||||
|
||||
if (f !== this.field && isAllowed && (!f.preferences?.formula || !f.preferences.formula.includes(this.field.uuid))) {
|
||||
acc.push(f)
|
||||
}
|
||||
|
||||
@@ -179,6 +184,9 @@ export default {
|
||||
isNumberField (field) {
|
||||
return field.type === 'number' || (['radio', 'select'].includes(field.type) && field.options?.every((o) => String(o.value).match(/^[\d.-]+$/)))
|
||||
},
|
||||
isTextField (field) {
|
||||
return ['text', 'number', 'select', 'radio', 'cells', 'phone'].includes(field.type)
|
||||
},
|
||||
humanizeFormula (text) {
|
||||
return text.replace(/{{(.*?)}}/g, (match, uuid) => {
|
||||
const foundField = this.template.fields.find((f) => f.uuid === uuid)
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<span
|
||||
class="dropdown dropdown-end field-settings-dropdown"
|
||||
:class="{ 'dropdown-open': withForceOpen && ((!field.preferences?.price && !field.preferences?.formula && !field.preferences?.price_id && !field.preferences?.payment_link_id) || !isConnected) && !isLoading }"
|
||||
:class="{ 'dropdown-open': withForceOpen && !field.preferences?.price && !field.preferences?.formula && !field.preferences?.price_id && !field.preferences?.payment_link_id && !isLoading }"
|
||||
>
|
||||
<label
|
||||
tabindex="0"
|
||||
@@ -21,7 +21,7 @@
|
||||
@click="closeDropdown"
|
||||
>
|
||||
<div
|
||||
v-if="!('price_id' in field.preferences) && !('payment_link_id' in field.preferences)"
|
||||
v-if="isPaypal || (!('price_id' in field.preferences) && !('payment_link_id' in field.preferences))"
|
||||
class="field-settings-currency py-1.5 px-1 relative"
|
||||
@click.stop
|
||||
>
|
||||
@@ -52,14 +52,14 @@
|
||||
@click.stop
|
||||
>
|
||||
<input
|
||||
v-if="'payment_link_id' in field.preferences"
|
||||
v-if="isStripe && 'payment_link_id' in field.preferences"
|
||||
v-model="field.preferences.payment_link_id"
|
||||
placeholder="plink_XXXXX"
|
||||
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0"
|
||||
@blur="save"
|
||||
>
|
||||
<input
|
||||
v-else-if="'price_id' in field.preferences"
|
||||
v-else-if="isStripe && 'price_id' in field.preferences"
|
||||
v-model="field.preferences.price_id"
|
||||
placeholder="Price ID: price_XXXXX"
|
||||
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0"
|
||||
@@ -89,7 +89,10 @@
|
||||
>
|
||||
{{ 'payment_link_id' in field.preferences ? t('payment_link') : t('price') }}
|
||||
</label>
|
||||
<div class="flex items-center justify-center">
|
||||
<div
|
||||
v-if="isStripe && stripeConnected"
|
||||
class="flex items-center justify-center"
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
class="hover:underline"
|
||||
@@ -120,21 +123,22 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isConnected || isOauthSuccess"
|
||||
class="field-settings-stripe-connect py-1.5 px-1 relative"
|
||||
v-if="!stripeConnected && !paypalConnected"
|
||||
class="flex flex-col gap-1 py-1.5 px-1"
|
||||
@click.stop
|
||||
>
|
||||
<div
|
||||
v-if="isConnected && isOauthSuccess"
|
||||
class="text-sm text-center"
|
||||
<a
|
||||
v-if="withPaypal"
|
||||
:href="paypalConnectUrl"
|
||||
target="_blank"
|
||||
data-turbo="false"
|
||||
class="btn bg-[#003087] hover:bg-[#012169] btn-sm text-white w-full"
|
||||
>
|
||||
<IconCircleCheck
|
||||
class="inline text-green-600 w-4 h-4"
|
||||
/>
|
||||
Stripe Connected
|
||||
</div>
|
||||
<IconBrandPaypal class="w-4 h-4 inline" />
|
||||
<span>Connect PayPal</span>
|
||||
</a>
|
||||
<form
|
||||
v-if="!isConnected"
|
||||
v-if="withStripe"
|
||||
data-turbo="false"
|
||||
action="/auth/stripe_connect"
|
||||
accept-charset="UTF-8"
|
||||
@@ -170,33 +174,25 @@
|
||||
:disabled="isLoading"
|
||||
class="btn bg-[#7B73FF] hover:bg-[#0A2540] btn-sm text-white w-full"
|
||||
>
|
||||
<span
|
||||
<IconInnerShadowTop
|
||||
v-if="isLoading"
|
||||
class="flex items-center space-x-1"
|
||||
>
|
||||
<IconInnerShadowTop
|
||||
class="w-4 h-4 animate-spin inline"
|
||||
/>
|
||||
<span>
|
||||
Connect Stripe
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="w-4 h-4 animate-spin inline"
|
||||
/>
|
||||
<IconBrandStripe
|
||||
v-else
|
||||
class="flex items-center space-x-1"
|
||||
>
|
||||
<IconBrandStripe
|
||||
class="w-4 h-4 inline"
|
||||
/>
|
||||
<span>
|
||||
Connect Stripe
|
||||
</span>
|
||||
</span>
|
||||
class="w-4 h-4 inline"
|
||||
/>
|
||||
<span>Connect Stripe</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div
|
||||
v-if="isStripe && !stripeConnected"
|
||||
class="px-1 pb-1.5"
|
||||
@click.stop
|
||||
>
|
||||
<a
|
||||
v-if="!isConnected"
|
||||
class="block link text-center mt-1"
|
||||
class="block link text-center text-xs"
|
||||
href="https://www.docuseal.com/blog/accept-payments-and-request-signatures-with-ease"
|
||||
target="_blank"
|
||||
data-turbo="false"
|
||||
@@ -272,24 +268,25 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconMathFunction, IconSettings, IconCircleCheck, IconInfoCircle, IconBrandStripe, IconInnerShadowTop, IconRouteAltLeft, IconForms } from '@tabler/icons-vue'
|
||||
import { IconMathFunction, IconSettings, IconInfoCircle, IconBrandStripe, IconBrandPaypal, IconInnerShadowTop, IconRouteAltLeft, IconForms } from '@tabler/icons-vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const isConnected = ref(false)
|
||||
export const stripeConnectedRef = ref(false)
|
||||
export const paypalConnectedRef = ref(false)
|
||||
|
||||
export default {
|
||||
name: 'PaymentSettings',
|
||||
components: {
|
||||
IconSettings,
|
||||
IconCircleCheck,
|
||||
IconRouteAltLeft,
|
||||
IconInfoCircle,
|
||||
IconForms,
|
||||
IconMathFunction,
|
||||
IconInnerShadowTop,
|
||||
IconBrandStripe
|
||||
IconBrandStripe,
|
||||
IconBrandPaypal
|
||||
},
|
||||
inject: ['backgroundColor', 'save', 'currencies', 't', 'isPaymentConnected'],
|
||||
inject: ['backgroundColor', 'save', 'currencies', 't', 'isStripeConnected', 'isPaypalConnected', 'withStripe', 'withPaypal'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
@@ -318,13 +315,27 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isConnected: () => isConnected.value,
|
||||
isOauthSuccess () {
|
||||
return document.location.search?.includes('stripe_connect_success')
|
||||
stripeConnected: () => stripeConnectedRef.value,
|
||||
paypalConnected: () => paypalConnectedRef.value,
|
||||
provider () {
|
||||
if (this.paypalConnected && !this.stripeConnected) return 'paypal'
|
||||
if (this.stripeConnected && !this.paypalConnected) return 'stripe'
|
||||
if (this.withPaypal && !this.withStripe) return 'paypal'
|
||||
|
||||
return 'stripe'
|
||||
},
|
||||
isStripe () {
|
||||
return this.provider === 'stripe'
|
||||
},
|
||||
isPaypal () {
|
||||
return this.provider === 'paypal'
|
||||
},
|
||||
redirectUri () {
|
||||
return document.location.origin + '/auth/stripe_connect/callback'
|
||||
},
|
||||
paypalConnectUrl () {
|
||||
return `/auth/paypal_connect?redir=${encodeURIComponent(document.location.href)}`
|
||||
},
|
||||
defaultCurrencies () {
|
||||
return ['USD', 'EUR', 'GBP', 'CAD', 'AUD']
|
||||
},
|
||||
@@ -363,26 +374,53 @@ export default {
|
||||
mounted () {
|
||||
this.field.preferences.currency ||= this.defaultCurrency
|
||||
|
||||
isConnected.value ||= this.isPaymentConnected
|
||||
stripeConnectedRef.value ||= this.isStripeConnected
|
||||
paypalConnectedRef.value ||= this.isPaypalConnected
|
||||
|
||||
if (!this.isConnected) {
|
||||
this.checkStatus()
|
||||
if (this.withStripe && !this.stripeConnected && !this.paypalConnected) {
|
||||
this.checkStripeStatus()
|
||||
}
|
||||
|
||||
if (this.withPaypal && !this.paypalConnected && !this.stripeConnected) {
|
||||
this.checkPaypalStatus()
|
||||
window.addEventListener('focus', this.onWindowFocus)
|
||||
}
|
||||
},
|
||||
beforeUnmount () {
|
||||
window.removeEventListener('focus', this.onWindowFocus)
|
||||
},
|
||||
methods: {
|
||||
checkStatus () {
|
||||
onWindowFocus () {
|
||||
if (this.withPaypal && !this.paypalConnected) {
|
||||
this.checkPaypalStatus()
|
||||
}
|
||||
},
|
||||
checkStripeStatus () {
|
||||
this.isLoading = true
|
||||
|
||||
fetch('/api/stripe_connect').then(async (resp) => {
|
||||
const { status } = await resp.json()
|
||||
|
||||
if (status === 'connected') {
|
||||
isConnected.value = true
|
||||
stripeConnectedRef.value = true
|
||||
|
||||
window.removeEventListener('focus', this.onWindowFocus)
|
||||
}
|
||||
}).finally(() => {
|
||||
this.isLoading = false
|
||||
})
|
||||
},
|
||||
checkPaypalStatus () {
|
||||
fetch('/api/paypal_connect').then(async (resp) => {
|
||||
const { status } = await resp.json()
|
||||
|
||||
if (status === 'connected') {
|
||||
paypalConnectedRef.value = true
|
||||
|
||||
window.removeEventListener('focus', this.onWindowFocus)
|
||||
}
|
||||
})
|
||||
},
|
||||
closeDropdown () {
|
||||
document.activeElement.blur()
|
||||
}
|
||||
|
||||
@@ -9,6 +9,62 @@
|
||||
@mousedown.stop
|
||||
@pointerdown.stop
|
||||
>
|
||||
<label
|
||||
v-if="requiredFields.length"
|
||||
class="field-settings-required w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
|
||||
@click.stop
|
||||
>
|
||||
<input
|
||||
:checked="isAllRequired"
|
||||
:indeterminate="isMixedRequired"
|
||||
type="checkbox"
|
||||
class="toggle toggle-xs"
|
||||
:disabled="!editable"
|
||||
@change="handleToggleRequired($event.target.checked)"
|
||||
@click.stop
|
||||
>
|
||||
<span>{{ t('required') }}</span>
|
||||
</label>
|
||||
<label
|
||||
v-if="readOnlyFields.length"
|
||||
class="field-settings-read-only w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm cursor-pointer"
|
||||
@click.stop
|
||||
>
|
||||
<input
|
||||
:checked="isAllReadOnly"
|
||||
:indeterminate="isMixedReadOnly"
|
||||
type="checkbox"
|
||||
class="toggle toggle-xs"
|
||||
:disabled="!editable"
|
||||
@change="handleToggleReadOnly($event.target.checked)"
|
||||
@click.stop
|
||||
>
|
||||
<span>{{ t('read_only') }}</span>
|
||||
</label>
|
||||
<hr
|
||||
v-if="requiredFields.length || readOnlyFields.length"
|
||||
class="my-1 border-neutral-200"
|
||||
>
|
||||
<button
|
||||
v-if="showFont"
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
|
||||
@click.stop="openFontModal"
|
||||
>
|
||||
<IconTypography class="w-4 h-4" />
|
||||
<span>{{ t('font') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="showCondition"
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
|
||||
@click.stop="openConditionModal"
|
||||
>
|
||||
<IconRouteAltLeft class="w-4 h-4" />
|
||||
<span>{{ t('condition') }}</span>
|
||||
</button>
|
||||
<hr
|
||||
v-if="showFont || showCondition"
|
||||
class="my-1 border-neutral-200"
|
||||
>
|
||||
<ContextSubmenu
|
||||
:icon="IconLayoutAlignMiddle"
|
||||
:label="t('align')"
|
||||
@@ -61,26 +117,6 @@
|
||||
<span>{{ t('height') }}</span>
|
||||
</button>
|
||||
</ContextSubmenu>
|
||||
<hr
|
||||
v-if="showFont || showCondition"
|
||||
class="my-1 border-neutral-200"
|
||||
>
|
||||
<button
|
||||
v-if="showFont"
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
|
||||
@click.stop="openFontModal"
|
||||
>
|
||||
<IconTypography class="w-4 h-4" />
|
||||
<span>{{ t('font') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="showCondition"
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center space-x-2 text-sm"
|
||||
@click.stop="openConditionModal"
|
||||
>
|
||||
<IconRouteAltLeft class="w-4 h-4" />
|
||||
<span>{{ t('condition') }}</span>
|
||||
</button>
|
||||
<hr class="my-1 border-neutral-200">
|
||||
<button
|
||||
class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 flex items-center justify-between text-sm"
|
||||
@@ -192,6 +228,24 @@ export default {
|
||||
return this.template.fields.find((f) => f.areas?.includes(area))
|
||||
}).filter(Boolean)
|
||||
},
|
||||
requiredFields () {
|
||||
return this.selectedFields.filter((f) => !['phone', 'stamp', 'verification', 'strikethrough', 'heading'].includes(f.type))
|
||||
},
|
||||
readOnlyFields () {
|
||||
return this.selectedFields.filter((f) => ['text', 'number', 'radio', 'multiple', 'select'].includes(f.type))
|
||||
},
|
||||
isAllRequired () {
|
||||
return this.requiredFields.every((f) => f.required)
|
||||
},
|
||||
isMixedRequired () {
|
||||
return !this.isAllRequired && this.requiredFields.some((f) => f.required)
|
||||
},
|
||||
isAllReadOnly () {
|
||||
return this.readOnlyFields.every((f) => f.readonly)
|
||||
},
|
||||
isMixedReadOnly () {
|
||||
return !this.isAllReadOnly && this.readOnlyFields.some((f) => f.readonly)
|
||||
},
|
||||
isMac () {
|
||||
return (navigator.userAgentData?.platform || navigator.platform)?.toLowerCase()?.includes('mac')
|
||||
},
|
||||
@@ -234,6 +288,16 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
handleToggleRequired (value) {
|
||||
this.requiredFields.forEach((field) => { field.required = value })
|
||||
|
||||
this.save()
|
||||
},
|
||||
handleToggleReadOnly (value) {
|
||||
this.readOnlyFields.forEach((field) => { field.readonly = value })
|
||||
|
||||
this.save()
|
||||
},
|
||||
onKeyDown (event) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -12,6 +12,7 @@ class ProcessSubmissionExpiredJob
|
||||
return if submission.template&.archived_at?
|
||||
return if submission.submitters.where.not(declined_at: nil).exists?
|
||||
return if submission.completed_at?
|
||||
return if params['expire_at'] && submission.expire_at&.to_i != params['expire_at']
|
||||
|
||||
WebhookUrls.enqueue_events(submission, 'submission.expired')
|
||||
end
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
class ProcessSubmitterCompletionJob
|
||||
include Sidekiq::Job
|
||||
|
||||
BCC_LIMIT = 14
|
||||
TooManyBcc = Class.new(StandardError)
|
||||
|
||||
def perform(params = {})
|
||||
submitter = Submitter.find(params['submitter_id'])
|
||||
submission = submitter.submission
|
||||
@@ -13,7 +16,12 @@ class ProcessSubmitterCompletionJob
|
||||
if params.key?('is_last')
|
||||
params['is_last']
|
||||
else
|
||||
!submission.submitters.exists?(completed_at: nil) &&
|
||||
viewer_uuids = submission.template_submitters.to_a.filter_map { |s| s['uuid'] if s['is_viewer'] }
|
||||
|
||||
incomplete = submission.submitters.where(completed_at: nil)
|
||||
incomplete = incomplete.where.not(uuid: viewer_uuids) if viewer_uuids.present?
|
||||
|
||||
!incomplete.exists? &&
|
||||
submitter.completed_at == submission.submitters.maximum(:completed_at)
|
||||
end
|
||||
|
||||
@@ -33,7 +41,9 @@ class ProcessSubmitterCompletionJob
|
||||
|
||||
if !submission.completed_at && submission.submitters_order_preserved? && params['send_invitation_email'] != false &&
|
||||
Submission.exists?(id: submission.id, completed_at: nil)
|
||||
enqueue_next_submitter_request_notification(submitter)
|
||||
next_submitters = enqueue_next_submitter_request_notification(submitter)
|
||||
|
||||
enqueue_next_submitter_viewer_notification(submission, next_submitters) unless is_last
|
||||
end
|
||||
|
||||
enqueue_completed_webhooks(submitter, is_last:)
|
||||
@@ -126,16 +136,24 @@ class ProcessSubmitterCompletionJob
|
||||
true
|
||||
end
|
||||
|
||||
build_bcc_addresses(submission).each do |to|
|
||||
next if is_sent_to_user && to == user.email
|
||||
|
||||
SubmitterMailer.completed_email(submitter, user, to:).deliver_later!
|
||||
end
|
||||
enqueue_bcc_completed_emails(submitter, user, is_sent_to_user)
|
||||
end
|
||||
|
||||
maybe_enqueue_copy_emails(submitter)
|
||||
end
|
||||
|
||||
def enqueue_bcc_completed_emails(submitter, user, is_sent_to_user)
|
||||
bcc_addresses = build_bcc_addresses(submitter.submission)
|
||||
|
||||
raise TooManyBcc, submitter.account_id if Docuseal.multitenant? && bcc_addresses.size > BCC_LIMIT
|
||||
|
||||
bcc_addresses.each do |to|
|
||||
next if is_sent_to_user && to == user.email
|
||||
|
||||
SubmitterMailer.completed_email(submitter, user, to:).deliver_later!
|
||||
end
|
||||
end
|
||||
|
||||
def maybe_enqueue_copy_emails(submitter)
|
||||
return if submitter.template&.preferences&.dig('documents_copy_email_enabled') == false
|
||||
|
||||
@@ -145,7 +163,7 @@ class ProcessSubmitterCompletionJob
|
||||
return if configs.value['enabled'] == false
|
||||
|
||||
to = submitter.submission.submitters.reject { |e| e.preferences['send_email'] == false }
|
||||
.sort_by(&:completed_at).select(&:email?).map(&:friendly_name)
|
||||
.sort_by { |e| e.completed_at || Time.current }.select(&:email?).map(&:friendly_name)
|
||||
|
||||
return if to.blank?
|
||||
|
||||
@@ -165,9 +183,9 @@ class ProcessSubmitterCompletionJob
|
||||
bcc.to_s.scan(User::EMAIL_REGEXP)
|
||||
end
|
||||
|
||||
def enqueue_next_submitter_request_notification(submitter)
|
||||
def enqueue_next_submitter_request_notification(submitter) # rubocop:disable Metrics/PerceivedComplexity
|
||||
submission = submitter.submission
|
||||
submitters_index = submission.submitters.index_by(&:uuid)
|
||||
submitters_index = submission.submitters.reject(&:viewer?).index_by(&:uuid)
|
||||
|
||||
next_submitter_items =
|
||||
if submission.template_submitters.any? { |s| s['order'] }
|
||||
@@ -196,5 +214,43 @@ class ProcessSubmitterCompletionJob
|
||||
next_submitters = submitters_index.values_at(*Array.wrap(next_submitter_items).pluck('uuid')).compact
|
||||
|
||||
Submitters.send_signature_requests(next_submitters)
|
||||
|
||||
next_submitters
|
||||
end
|
||||
|
||||
def enqueue_next_submitter_viewer_notification(submission, next_submitters)
|
||||
viewers = submission.submitters.select(&:viewer?)
|
||||
|
||||
return [] if viewers.blank?
|
||||
|
||||
next_submitter_uuids = next_submitters.to_set(&:uuid)
|
||||
viewers_index = viewers.index_by(&:uuid)
|
||||
|
||||
next_viewers =
|
||||
if submission.template_submitters.any? { |s| s['order'] }
|
||||
next_orders = submission.template_submitters
|
||||
.select { |s| next_submitter_uuids.include?(s['uuid']) }
|
||||
.pluck('order')
|
||||
|
||||
submission.template_submitters.filter_map do |s|
|
||||
viewers_index[s['uuid']] if next_orders.include?(s['order'])
|
||||
end
|
||||
else
|
||||
preceding_submitter_uuid = nil
|
||||
|
||||
submission.template_submitters.filter_map do |template_submitter|
|
||||
viewer = viewers_index[template_submitter['uuid']]
|
||||
|
||||
if viewer
|
||||
viewer if next_submitter_uuids.include?(preceding_submitter_uuid)
|
||||
else
|
||||
preceding_submitter_uuid = template_submitter['uuid']
|
||||
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Submitters.send_signature_requests(next_viewers)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,7 +19,12 @@ class SendSubmitterInvitationEmailJob
|
||||
return
|
||||
end
|
||||
|
||||
mail = SubmitterMailer.invitation_email(submitter)
|
||||
mail =
|
||||
if submitter.viewer?
|
||||
SubmitterMailer.invitation_view_email(submitter)
|
||||
else
|
||||
SubmitterMailer.invitation_email(submitter)
|
||||
end
|
||||
|
||||
Submitters::ValidateSending.call(submitter, mail)
|
||||
|
||||
|
||||
@@ -45,6 +45,45 @@ class SubmitterMailer < ApplicationMailer
|
||||
end
|
||||
end
|
||||
|
||||
def invitation_view_email(submitter)
|
||||
@current_account = submitter.submission.account
|
||||
@submitter = submitter
|
||||
|
||||
if submitter.preferences['email_message_uuid']
|
||||
@email_message = submitter.account.email_messages.find_by(uuid: submitter.preferences['email_message_uuid'])
|
||||
end
|
||||
|
||||
template_submitters_index = @email_message.blank? ? build_submitter_preferences_index(@submitter) : {}
|
||||
|
||||
@body = @email_message&.normalized_body.presence ||
|
||||
@submitter.template&.preferences&.dig('invitation_view_email_body').presence ||
|
||||
template_submitters_index.dig(@submitter.uuid, 'request_email_body').presence
|
||||
|
||||
@subject = @email_message&.subject.presence ||
|
||||
@submitter.template&.preferences&.dig('invitation_view_email_subject').presence ||
|
||||
template_submitters_index.dig(@submitter.uuid, 'request_email_subject').presence
|
||||
|
||||
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_VIEW_INVITATION_EMAIL_KEY)
|
||||
@body ||= fetch_config_email_body(@email_config, @submitter)
|
||||
|
||||
assign_message_metadata('submitter_view_invitation', @submitter)
|
||||
|
||||
reply_to = build_submitter_reply_to(@submitter, email_config: @email_config)
|
||||
|
||||
maybe_set_custom_domain(@submitter)
|
||||
|
||||
I18n.with_locale(@current_account.locale) do
|
||||
subject = build_invite_subject(@subject, @email_config, submitter)
|
||||
|
||||
mail(
|
||||
to: @submitter.friendly_name,
|
||||
from: from_address_for_submitter(submitter),
|
||||
subject:,
|
||||
reply_to:
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def completed_email(submitter, user, to: nil)
|
||||
@current_account = submitter.submission.account
|
||||
@submitter = submitter
|
||||
@@ -53,8 +92,6 @@ class SubmitterMailer < ApplicationMailer
|
||||
|
||||
template_preferences = @submission.template&.preferences || {}
|
||||
|
||||
Submissions::EnsureResultGenerated.call(submitter)
|
||||
|
||||
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY)
|
||||
|
||||
add_completed_email_attachments!(
|
||||
@@ -109,8 +146,6 @@ class SubmitterMailer < ApplicationMailer
|
||||
|
||||
template_preferences = @submitter.template&.preferences || {}
|
||||
|
||||
Submissions::EnsureResultGenerated.call(@submitter)
|
||||
|
||||
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY)
|
||||
|
||||
add_completed_email_attachments!(
|
||||
@@ -172,7 +207,7 @@ class SubmitterMailer < ApplicationMailer
|
||||
end
|
||||
|
||||
def add_completed_email_attachments!(submitter, with_audit_log: true, with_documents: true)
|
||||
documents = with_documents ? Submitters.select_attachments_for_download(submitter) : []
|
||||
documents = with_documents ? select_completed_documents(submitter) : []
|
||||
|
||||
filename_format = AccountConfig.find_or_initialize_by(account_id: submitter.account_id,
|
||||
key: AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY)&.value
|
||||
@@ -216,6 +251,8 @@ class SubmitterMailer < ApplicationMailer
|
||||
def build_invite_subject(subject, email_config, submitter)
|
||||
if email_config || subject
|
||||
ReplaceEmailVariables.call(subject || email_config.value['subject'], submitter:)
|
||||
elsif submitter.viewer?
|
||||
I18n.t(:you_are_invited_to_view_a_document)
|
||||
elsif submitter.with_signature_fields?
|
||||
I18n.t(:you_are_invited_to_sign_a_document)
|
||||
else
|
||||
@@ -227,6 +264,14 @@ class SubmitterMailer < ApplicationMailer
|
||||
submitter.template&.preferences&.dig('submitters').to_a.index_by { |e| e['uuid'] }
|
||||
end
|
||||
|
||||
def select_completed_documents(submitter)
|
||||
last_submitter = Submitter.where(submission_id: submitter.submission_id).completed.order(:completed_at).last
|
||||
|
||||
Submissions::EnsureResultGenerated.call(last_submitter)
|
||||
|
||||
Submitters.select_attachments_for_download(last_submitter)
|
||||
end
|
||||
|
||||
def add_attachments_with_size_limit(submitter, storage_attachments, current_size, filename_format = nil)
|
||||
total_size = current_size
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#
|
||||
class AccountConfig < ApplicationRecord
|
||||
SUBMITTER_INVITATION_EMAIL_KEY = 'submitter_invitation_email'
|
||||
SUBMITTER_VIEW_INVITATION_EMAIL_KEY = 'submitter_view_invitation_email'
|
||||
SUBMITTER_INVITATION_REMINDER_EMAIL_KEY = 'submitter_invitation_reminder_email'
|
||||
SUBMITTER_COMPLETED_EMAIL_KEY = 'submitter_completed_email'
|
||||
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY = 'submitter_documents_copy_email'
|
||||
@@ -47,6 +48,7 @@ class AccountConfig < ApplicationRecord
|
||||
WITH_SIGNATURE_ID = 'with_signature_id'
|
||||
WITH_FILE_LINKS_KEY = 'with_file_links'
|
||||
WITH_SIGNATURE_ID_REASON_KEY = 'with_signature_id_reason'
|
||||
WITH_SIGNATURE_ID_COMPLETED_AT_KEY = 'with_signature_id_completed_at'
|
||||
RECIPIENT_FORM_FIELDS_KEY = 'recipient_form_fields'
|
||||
WITH_AUDIT_VALUES_KEY = 'with_audit_values'
|
||||
WITH_AUDIT_SENDER_KEY = 'with_audit_sender'
|
||||
@@ -58,11 +60,13 @@ class AccountConfig < ApplicationRecord
|
||||
COMBINE_PDF_RESULT_KEY = 'combine_pdf_result_key'
|
||||
DOCUMENT_FILENAME_FORMAT_KEY = 'document_filename_format'
|
||||
TEMPLATE_CUSTOM_FIELDS_KEY = 'template_custom_fields'
|
||||
TEMPLATE_DATE_FORMATS_KEY = 'template_date_formats'
|
||||
POLICY_LINKS_KEY = 'policy_links'
|
||||
ENABLE_MCP_KEY = 'enable_mcp'
|
||||
|
||||
EMAIL_VARIABLES = {
|
||||
SUBMITTER_INVITATION_EMAIL_KEY => %w[template.name submitter.link account.name].freeze,
|
||||
SUBMITTER_VIEW_INVITATION_EMAIL_KEY => %w[template.name submitter.link account.name].freeze,
|
||||
SUBMITTER_COMPLETED_EMAIL_KEY => %w[template.name submission.submitters submission.link].freeze,
|
||||
SUBMITTER_INVITATION_REMINDER_EMAIL_KEY => %w[template.name submitter.link account.name].freeze,
|
||||
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => %w[template.name documents.link account.name].freeze
|
||||
@@ -75,6 +79,12 @@ class AccountConfig < ApplicationRecord
|
||||
'body' => I18n.t(:submitter_invitation_email_sign_body)
|
||||
}
|
||||
},
|
||||
SUBMITTER_VIEW_INVITATION_EMAIL_KEY => lambda {
|
||||
{
|
||||
'subject' => I18n.t(:you_are_invited_to_view_a_document),
|
||||
'body' => I18n.t(:submitter_invitation_email_view_body)
|
||||
}
|
||||
},
|
||||
SUBMITTER_INVITATION_REMINDER_EMAIL_KEY => lambda {
|
||||
{
|
||||
'subject' => I18n.t(:you_are_invited_to_sign_a_document),
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
# index_submissions_on_account_id_and_id_pending (account_id,id) WHERE ((completed_at IS NULL) AND (archived_at IS NULL))
|
||||
# index_submissions_on_account_id_and_template_id_and_id (account_id,template_id,id) WHERE (archived_at IS NULL)
|
||||
# index_submissions_on_account_id_and_template_id_and_id_archived (account_id,template_id,id) WHERE (archived_at IS NOT NULL)
|
||||
# index_submissions_on_created_at (created_at)
|
||||
# index_submissions_on_created_by_user_id (created_by_user_id)
|
||||
# index_submissions_on_slug (slug) UNIQUE
|
||||
# index_submissions_on_template_id (template_id)
|
||||
@@ -91,9 +92,8 @@ class Submission < ApplicationRecord
|
||||
|
||||
scope :active, -> { where(archived_at: nil) }
|
||||
scope :archived, -> { where.not(archived_at: nil) }
|
||||
scope :pending, lambda {
|
||||
where(expire_at: nil).or(where(expire_at: Time.current..)).where(completed_at: nil)
|
||||
}
|
||||
scope :non_expired, -> { where(expire_at: nil).or(where(expire_at: Time.current..)) }
|
||||
scope :pending, -> { non_expired.where(completed_at: nil) }
|
||||
scope :completed, -> { where.not(completed_at: nil) }
|
||||
scope :declined, lambda {
|
||||
where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id]))
|
||||
|
||||
@@ -116,6 +116,12 @@ class Submitter < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
def viewer?
|
||||
return false if submission.template_submitters.blank?
|
||||
|
||||
submission.template_submitters.any? { |s| s['uuid'] == uuid && s['is_viewer'] }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def anonymize_email_events
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="flex flex-wrap space-y-4 md:flex-nowrap md:space-y-0">
|
||||
<%= render 'shared/settings_nav' %>
|
||||
<div class="flex-grow max-w-xl mx-auto">
|
||||
<h1 class="text-4xl font-bold mb-4">
|
||||
<h1 class="text-3xl md:text-4xl font-bold mb-4">
|
||||
<%= t('account') %>
|
||||
</h1>
|
||||
<%= form_for '', url: settings_account_path, method: :patch, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
|
||||
@@ -53,8 +53,8 @@
|
||||
<div class="flex items-center justify-between gap-4 py-2.5">
|
||||
<div class="flex items-center space-x-1">
|
||||
<span class="text-left"><%= t('force_2fa_with_authenticator_app') %></span>
|
||||
<span class="tooltip tooltip-top flex cursor-pointer" data-tip="<%= t('require_two_factor_authentication_2fa_with_an_authenticator_app_e_g_google_authenticator_authy_all_users_signing_documents_must_pass_the_second_factor_verification_using_a_secure_code_in_addition_to_their_password') %>">
|
||||
<%= svg_icon('info_circle', class: 'hidden md:inline-block w-4 h-4 shrink-0') %>
|
||||
<span class="tooltip tooltip-top hidden md:flex cursor-pointer" data-tip="<%= t('require_two_factor_authentication_2fa_with_an_authenticator_app_e_g_google_authenticator_authy_all_users_signing_documents_must_pass_the_second_factor_verification_using_a_secure_code_in_addition_to_their_password') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4 shrink-0') %>
|
||||
</span>
|
||||
</div>
|
||||
<submit-form data-on="change" class="flex">
|
||||
@@ -70,8 +70,8 @@
|
||||
<div class="flex items-center justify-between gap-4 py-2.5">
|
||||
<div class="flex items-center space-x-1">
|
||||
<span class="text-left"><%= t('add_signature_id_to_the_documents') %></span>
|
||||
<span class="tooltip tooltip-top flex cursor-pointer" data-tip="<%= t('add_a_unique_signature_id_and_timestamp_to_each_signature_for_audit_and_traceability_purposes_along_with_the_timestamp_part_of_docuseals_21_cfr_part_11_compliance_settings') %>">
|
||||
<%= svg_icon('info_circle', class: 'hidden md:inline-block w-4 h-4 shrink-0') %>
|
||||
<span class="tooltip tooltip-top hidden md:flex cursor-pointer" data-tip="<%= t('add_a_unique_signature_id_and_timestamp_to_each_signature_for_audit_and_traceability_purposes_along_with_the_timestamp_part_of_docuseals_21_cfr_part_11_compliance_settings') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4 shrink-0') %>
|
||||
</span>
|
||||
</div>
|
||||
<submit-form data-on="change" class="flex">
|
||||
@@ -87,8 +87,8 @@
|
||||
<div class="flex items-center justify-between gap-4 py-2.5">
|
||||
<div class="flex items-center space-x-1">
|
||||
<span class="text-left"><%= t('require_signing_reason') %></span>
|
||||
<span class="tooltip tooltip-top flex cursor-pointer" data-tip="<%= t('require_signer_to_provide_a_reason_for_signing_before_completing_their_signature_e_g_approvals_certifications_part_of_docuseals_21_cfr_part_11_compliance_settings') %>">
|
||||
<%= svg_icon('info_circle', class: 'hidden md:inline-block w-4 h-4 shrink-0') %>
|
||||
<span class="tooltip tooltip-top hidden md:flex cursor-pointer" data-tip="<%= t('require_signer_to_provide_a_reason_for_signing_before_completing_their_signature_e_g_approvals_certifications_part_of_docuseals_21_cfr_part_11_compliance_settings') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4 shrink-0') %>
|
||||
</span>
|
||||
</div>
|
||||
<submit-form data-on="change" class="flex">
|
||||
@@ -104,8 +104,8 @@
|
||||
<div class="flex items-center justify-between gap-4 py-2.5">
|
||||
<div class="flex items-center space-x-1">
|
||||
<span class="text-left"><%= t('allow_typed_text_signatures') %></span>
|
||||
<span class="tooltip tooltip-top flex cursor-pointer" data-tip="<%= t('allow_signers_to_create_signatures_by_typing_their_name_instead_of_drawing_or_uploading_one') %>">
|
||||
<%= svg_icon('info_circle', class: 'hidden md:inline-block w-4 h-4 shrink-0') %>
|
||||
<span class="tooltip tooltip-top hidden md:flex cursor-pointer" data-tip="<%= t('allow_signers_to_create_signatures_by_typing_their_name_instead_of_drawing_or_uploading_one') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4 shrink-0') %>
|
||||
</span>
|
||||
</div>
|
||||
<submit-form data-on="change" class="flex">
|
||||
@@ -121,8 +121,8 @@
|
||||
<div class="flex items-center justify-between gap-4 py-2.5">
|
||||
<div class="flex items-center space-x-1">
|
||||
<span class="text-left"><%= t('allow_to_resubmit_completed_forms') %></span>
|
||||
<span class="tooltip tooltip-top flex cursor-pointer" data-tip="<%= t('allow_signers_to_resubmit_forms_after_completion_useful_when_corrections_or_multiple_submissions_are_needed') %>">
|
||||
<%= svg_icon('info_circle', class: 'hidden md:inline-block w-4 h-4 shrink-0') %>
|
||||
<span class="tooltip tooltip-top hidden md:flex cursor-pointer" data-tip="<%= t('allow_signers_to_resubmit_forms_after_completion_useful_when_corrections_or_multiple_submissions_are_needed') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4 shrink-0') %>
|
||||
</span>
|
||||
</div>
|
||||
<submit-form data-on="change" class="flex">
|
||||
@@ -138,8 +138,8 @@
|
||||
<div class="flex items-center justify-between gap-4 py-2.5">
|
||||
<div class="flex items-center space-x-1">
|
||||
<span class="text-left"><%= t('allow_to_decline_documents') %></span>
|
||||
<span class="tooltip tooltip-top flex cursor-pointer" data-tip="<%= t('allow_recipients_to_decline_signing_a_document_the_decline_reason_notification_will_be_sent_to_the_signature_requester') %>">
|
||||
<%= svg_icon('info_circle', class: 'hidden md:inline-block w-4 h-4 shrink-0') %>
|
||||
<span class="tooltip tooltip-top hidden md:flex cursor-pointer" data-tip="<%= t('allow_recipients_to_decline_signing_a_document_the_decline_reason_notification_will_be_sent_to_the_signature_requester') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4 shrink-0') %>
|
||||
</span>
|
||||
</div>
|
||||
<% if !Docuseal.multitenant? || can?(:manage, :disable_decline) %>
|
||||
@@ -147,7 +147,7 @@
|
||||
<%= f.check_box :value, class: 'toggle', checked: account_config.value != false %>
|
||||
</submit-form>
|
||||
<% else %>
|
||||
<a href="<%= console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") %>" data-turbo="false" data-tip="<%= I18n.t('unlock_with_docuseal_pro') %>" data-on="change" class="flex tooltip">
|
||||
<a href="<%= console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") %>" data-turbo="false" data-tip="<%= I18n.t('unlock_with_docuseal_pro') %>" data-on="change" class="flex tooltip tooltip-no-touch">
|
||||
<%= f.check_box :value, class: 'toggle pointer-events-none', checked: account_config.value != false, disabled: true %>
|
||||
</a>
|
||||
<% end %>
|
||||
@@ -161,8 +161,8 @@
|
||||
<div class="flex items-center justify-between gap-4 py-2.5">
|
||||
<div class="flex items-center space-x-1">
|
||||
<span class="text-left"><%= t('allow_to_delegate_documents') %></span>
|
||||
<span class="tooltip tooltip-top flex cursor-pointer" data-tip="<%= t('allow_recipients_to_delegate_documents') %>">
|
||||
<%= svg_icon('info_circle', class: 'hidden md:inline-block w-4 h-4 shrink-0') %>
|
||||
<span class="tooltip tooltip-top hidden md:flex cursor-pointer" data-tip="<%= t('allow_recipients_to_delegate_documents') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4 shrink-0') %>
|
||||
</span>
|
||||
</div>
|
||||
<% if !Docuseal.multitenant? || can?(:manage, :delegate_form) %>
|
||||
@@ -170,7 +170,7 @@
|
||||
<%= f.check_box :value, class: 'toggle', checked: account_config.value == true %>
|
||||
</submit-form>
|
||||
<% else %>
|
||||
<a href="<%= console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") %>" data-turbo="false" data-tip="<%= I18n.t('unlock_with_docuseal_pro') %>" class="flex tooltip">
|
||||
<a href="<%= console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") %>" data-turbo="false" data-tip="<%= I18n.t('unlock_with_docuseal_pro') %>" class="flex tooltip tooltip-no-touch">
|
||||
<%= f.check_box :value, class: 'toggle pointer-events-none', checked: account_config.value == true, disabled: true %>
|
||||
</a>
|
||||
<% end %>
|
||||
@@ -184,8 +184,8 @@
|
||||
<div class="flex items-center justify-between gap-4 py-2.5">
|
||||
<div class="flex items-center space-x-1">
|
||||
<span class="text-left"><%= t('remember_and_pre_fill_signatures') %></span>
|
||||
<span class="tooltip tooltip-top flex cursor-pointer" data-tip="<%= t('save_a_users_signature_and_automatically_pre_fill_it_in_future_signing_sessions') %>">
|
||||
<%= svg_icon('info_circle', class: 'hidden md:inline-block w-4 h-4 shrink-0') %>
|
||||
<span class="tooltip tooltip-top hidden md:flex cursor-pointer" data-tip="<%= t('save_a_users_signature_and_automatically_pre_fill_it_in_future_signing_sessions') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4 shrink-0') %>
|
||||
</span>
|
||||
</div>
|
||||
<submit-form data-on="change" class="flex">
|
||||
@@ -201,8 +201,8 @@
|
||||
<div class="flex items-center justify-between gap-4 py-2.5">
|
||||
<div class="flex items-center space-x-1">
|
||||
<span class="text-left"><%= t('expirable_file_download_links') %></span>
|
||||
<span class="tooltip tooltip-top flex cursor-pointer" data-tip="<%= t('make_document_download_links_expire_after_40_minutes_to_prevent_long_term_access_and_enhance_security') %>">
|
||||
<%= svg_icon('info_circle', class: 'hidden md:inline-block w-4 h-4 shrink-0') %>
|
||||
<span class="tooltip tooltip-top hidden md:flex cursor-pointer" data-tip="<%= t('make_document_download_links_expire_after_40_minutes_to_prevent_long_term_access_and_enhance_security') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4 shrink-0') %>
|
||||
</span>
|
||||
</div>
|
||||
<submit-form data-on="change" class="flex">
|
||||
@@ -218,8 +218,8 @@
|
||||
<div class="flex items-center justify-between gap-4 py-2.5">
|
||||
<div class="flex items-center space-x-1">
|
||||
<span class="text-left"><%= t('require_authentication_for_file_download_links') %></span>
|
||||
<span class="tooltip tooltip-top flex cursor-pointer" data-tip="<%= t('require_authentication_with_user_login_or_api_key_to_access_the_document_download_links') %>">
|
||||
<%= svg_icon('info_circle', class: 'hidden md:inline-block w-4 h-4 shrink-0') %>
|
||||
<span class="tooltip tooltip-top hidden md:flex cursor-pointer" data-tip="<%= t('require_authentication_with_user_login_or_api_key_to_access_the_document_download_links') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4 shrink-0') %>
|
||||
</span>
|
||||
</div>
|
||||
<submit-form data-on="change" class="flex">
|
||||
@@ -235,8 +235,8 @@
|
||||
<div class="flex items-center justify-between gap-4 py-2.5">
|
||||
<div class="flex items-center space-x-1">
|
||||
<span class="text-left"><%= t('combine_completed_documents_and_audit_log') %></span>
|
||||
<span class="tooltip tooltip-top flex cursor-pointer" data-tip="<%= t('combine_signed_documents_and_the_audit_log_into_a_single_pdf_file_for_easier_recordkeeping_and_compliance') %>">
|
||||
<%= svg_icon('info_circle', class: 'hidden md:inline-block w-4 h-4 shrink-0') %>
|
||||
<span class="tooltip tooltip-top hidden md:flex cursor-pointer" data-tip="<%= t('combine_signed_documents_and_the_audit_log_into_a_single_pdf_file_for_easier_recordkeeping_and_compliance') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4 shrink-0') %>
|
||||
</span>
|
||||
</div>
|
||||
<submit-form data-on="change" class="flex">
|
||||
@@ -253,8 +253,8 @@
|
||||
<div class="flex items-center justify-between gap-4 py-2.5">
|
||||
<div class="flex items-center space-x-1">
|
||||
<span class="text-left"><%= t('always_enforce_signing_order') %></span>
|
||||
<span class="tooltip tooltip-top flex cursor-pointer" data-tip="<%= t('make_the_recipients_signing_order_always_enforced_so_that_the_second_signer_can_start_signing_their_part_only_after_the_first_signer_has_completed_signing') %>">
|
||||
<%= svg_icon('info_circle', class: 'hidden md:inline-block w-4 h-4 shrink-0') %>
|
||||
<span class="tooltip tooltip-top hidden md:flex cursor-pointer" data-tip="<%= t('make_the_recipients_signing_order_always_enforced_so_that_the_second_signer_can_start_signing_their_part_only_after_the_first_signer_has_completed_signing') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4 shrink-0') %>
|
||||
</span>
|
||||
</div>
|
||||
<submit-form data-on="change" class="flex">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<%= render 'shared/settings_nav' %>
|
||||
<div class="flex-grow">
|
||||
<div class="flex flex-col gap-2 md:flex-row md:justify-between md:items-end mb-4">
|
||||
<h1 class="text-4xl font-bold">API</h1>
|
||||
<h1 class="text-3xl md:text-4xl font-bold">API</h1>
|
||||
<%= render 'shared/test_mode_toggle' %>
|
||||
</div>
|
||||
<div class="card bg-base-200">
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<form action="<%= root_path %>" method="get" id="templates_submissions_toggle" class="bg-base-200 px-1.5 rounded-xl py-1 whitespace-nowrap">
|
||||
<toggle-cookies data-value="templates" data-key="dashboard_view" class="sm:tooltip tooltip-top" data-tip="<%= t('templates') %>">
|
||||
<toggle-cookies data-value="templates" data-key="dashboard_view" class="tooltip tooltip-top tooltip-no-touch" data-tip="<%= t('templates') %>">
|
||||
<button class="<%= local_assigns[:selected] == 'submissions' ? 'btn !border !rounded-lg btn-square !p-0 !btn-sm !h-8 !w-9' : 'btn btn-neutral !rounded-lg btn-square !p-0 hover:text-neutral-300 !btn-sm !h-8 !w-9 disabled:btn-neutral' %>">
|
||||
<%= svg_icon('layout_grid', class: 'w-6 h-6 stroke-2') %>
|
||||
</button>
|
||||
</toggle-cookies>
|
||||
<toggle-cookies data-value="submissions" data-key="dashboard_view" class="sm:tooltip tooltip-top" data-tip="<%= t('submissions') %>">
|
||||
<toggle-cookies data-value="submissions" data-key="dashboard_view" class="tooltip tooltip-top tooltip-no-touch" data-tip="<%= t('submissions') %>">
|
||||
<button class="<%= local_assigns[:selected] == 'submissions' ? 'btn btn-neutral !rounded-lg btn-square !p-0 hover:text-neutral-300 !btn-sm !h-8 !w-9' : 'btn !border !rounded-lg btn-square !p-0 !btn-sm !h-8 !w-9 disabled:btn-neutral' %>">
|
||||
<%= svg_icon('layout_list', class: 'w-6 h-6 stroke-2') %>
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="form-control">
|
||||
<%= label_tag 'user[otp_attempt]', t('two_factor_code_from_authenticator_app'), class: 'label' %>
|
||||
<%= text_field_tag 'user[otp_attempt]', nil, autofocus: true, autocomplete: 'off', placeholder: 'XXX-XXX', required: true, class: 'base-input' %>
|
||||
<%= text_field_tag 'user[otp_attempt]', nil, autofocus: true, autocomplete: 'off', inputmode: 'numeric', placeholder: 'XXXXXX', required: true, class: 'base-input' %>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="flex flex-wrap space-y-4 md:flex-nowrap md:space-y-0">
|
||||
<%= render 'shared/settings_nav' %>
|
||||
<div class="flex-grow max-w-xl mx-auto">
|
||||
<h1 class="text-4xl font-bold mb-4">Email SMTP</h1>
|
||||
<div class="flex-grow max-w-xl mx-auto min-w-0">
|
||||
<h1 class="text-3xl md:text-4xl font-bold mb-4">Email SMTP</h1>
|
||||
<% value = @encrypted_config.value || {} %>
|
||||
<%= form_for @encrypted_config, url: settings_email_index_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
|
||||
<%= f.fields_for :value do |ff| %>
|
||||
@@ -38,7 +38,7 @@
|
||||
<% if !Docuseal.multitenant? || can?(:manage, :personalization_advanced) %>
|
||||
<div class="form-control">
|
||||
<%= ff.label :security_label, 'SMTP Security', class: 'label' %>
|
||||
<div class="flex items-center space-x-6">
|
||||
<div class="flex flex-wrap items-center gap-x-6">
|
||||
<% [%w[STARTTLS none], %w[TLS tls], %w[SSL ssl], %w[Noverify noverify]].each do |(label, val)| %>
|
||||
<%= ff.label :security, value: val, for: "#{val}_radio", class: 'label' do %>
|
||||
<%= ff.radio_button :security, val, checked: (value['security'].blank? && val == 'none') || value['security'] == val, id: "#{val}_radio", class: 'base-radio mr-2' %>
|
||||
@@ -57,6 +57,11 @@
|
||||
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if Docuseal.multitenant? && @encrypted_config.persisted? %>
|
||||
<div class="flex justify-center pt-2">
|
||||
<%= button_to t('reset_default'), settings_email_path(@encrypted_config), method: :delete, class: 'link', data: { turbo_confirm: t('are_you_sure_') } %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="w-0 md:w-52"></div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<tr scope="row" class="group">
|
||||
<tr class="group">
|
||||
<td class="flex items-center space-x-1">
|
||||
<%= svg_icon('discount_check_filled', class: 'w-6 h-6 text-green-500') %>
|
||||
<%= svg_icon('discount_check_filled', class: 'w-6 h-6 shrink-0 text-green-500') %>
|
||||
<span class="flex items-center">
|
||||
<%= t('docuseal_trusted_signature') %>
|
||||
<div class="tooltip ml-1" data-tip="<%= t('sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature') %>">
|
||||
<%= svg_icon('circle_question', class: 'w-4 h-4 stroke-1') %>
|
||||
<span class="whitespace-nowrap"><%= t('docuseal_trusted_signature') %></span>
|
||||
<div class="tooltip tooltip-top ml-1 shrink-0" data-tip="<%= t('sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature') %>">
|
||||
<%= svg_icon('circle_question', class: 'w-4 h-4 stroke-1 shrink-0') %>
|
||||
</div>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="<%= "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premises" }.to_query}" %>" class="btn btn-neutral btn-sm text-white">
|
||||
<a href="<%= "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premises" }.to_query}" %>" class="btn btn-neutral btn-sm text-white whitespace-nowrap">
|
||||
<%= t('unlock_with_docuseal_pro') %>
|
||||
</a>
|
||||
</td>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<%= render 'shared/settings_nav' %>
|
||||
<div class="md:flex-grow">
|
||||
<div class="max-w-xl">
|
||||
<h1 class="text-4xl font-bold mb-4">
|
||||
<h1 class="text-3xl md:text-4xl font-bold mb-4">
|
||||
<%= t('pdf_signature') %>
|
||||
</h1>
|
||||
<div id="result">
|
||||
@@ -41,7 +41,7 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row gap-2 md:justify-between md:items-end mb-4 mt-8">
|
||||
<h2 class="text-3xl font-bold">
|
||||
<h2 class="text-2xl md:text-3xl font-bold">
|
||||
<%= t('signing_certificates') %>
|
||||
</h2>
|
||||
<% if can?(:create, @encrypted_config) %>
|
||||
@@ -72,7 +72,7 @@
|
||||
<tbody>
|
||||
<% @pkcs_list.each do |item| %>
|
||||
<% next if item['pkcs'].blank? %>
|
||||
<tr scope="row" class="group">
|
||||
<tr class="group">
|
||||
<td>
|
||||
<%= item['name'] %>
|
||||
</td>
|
||||
@@ -109,7 +109,7 @@
|
||||
<% if !Docuseal.multitenant? && can?(:manage, encrypted_config) %>
|
||||
<div class="flex-grow max-w-xl">
|
||||
<div class="flex justify-between items-end mb-4 mt-8">
|
||||
<h2 class="text-3xl font-bold">
|
||||
<h2 class="text-2xl md:text-3xl font-bold">
|
||||
<%= t('timestamp_server') %>
|
||||
</h2>
|
||||
</div>
|
||||
@@ -137,7 +137,7 @@
|
||||
<% if can?(:manage, account_config) %>
|
||||
<div class="px-1 mt-8 max-w-xl">
|
||||
<div class="flex justify-between items-end mb-4 mt-8">
|
||||
<h2 class="text-3xl font-bold">
|
||||
<h2 class="text-2xl md:text-3xl font-bold">
|
||||
<%= t('preferences') %>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -1 +1 @@
|
||||
<svg class="<%= local_assigns[:class] %>" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-brand-google-drive"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M12 10l-6 10l-3 -5l6 -10z" /><path d="M9 15h12l-3 5h-12" /><path d="M15 15l-6 -10h6l6 10z" /></svg>
|
||||
<svg class="<%= local_assigns[:class] %>" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M12 10l-6 10l-3 -5l6 -10z" /><path d="M9 15h12l-3 5h-12" /><path d="M15 15l-6 -10h6l6 10z" /></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 458 B After Width: | Height: | Size: 381 B |
@@ -1,8 +1,7 @@
|
||||
<svg class="<%= local_assigns[:class] %>" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M3 5a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v10a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-10z" />
|
||||
<path d="M7 20h10" /><path d="M9 16v4" />
|
||||
<path d="M15 16v4" /></svg>
|
||||
<path d="M7 20h10" />
|
||||
<path d="M9 16v4" />
|
||||
<path d="M15 16v4" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 497 B After Width: | Height: | Size: 447 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
|
||||
<path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2" />
|
||||
<path d="M9 15l2 2l4 -4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 437 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
|
||||
<path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2" />
|
||||
<path d="M12 17v.01" />
|
||||
<path d="M12 14a1.5 1.5 0 1 0 -1.14 -2.474" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 482 B |
@@ -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 |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M16 7h4" />
|
||||
<path d="M16 16v1l2 2l.5 -.5m1.5 -2.5v-11c0 -1.121 -.879 -2 -2 -2s-2 .879 -2 2v7" />
|
||||
<path d="M18 19h-13a2 2 0 1 1 0 -4h4a2 2 0 1 0 0 -4h-3" />
|
||||
<path d="M3 3l18 18" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 480 B |
@@ -2,7 +2,7 @@
|
||||
<%= render 'shared/settings_nav' %>
|
||||
<div class="md:flex-grow">
|
||||
<div class="flex flex-col md:flex-row md:flex-wrap gap-2 md:justify-between md:items-end mb-4 min-h-12">
|
||||
<h1 class="text-4xl font-bold">
|
||||
<h1 class="text-3xl md:text-4xl font-bold">
|
||||
<%= t('mcp_server') %>
|
||||
</h1>
|
||||
<div class="flex flex-col md:flex-row gap-y-2 gap-x-4 md:items-center">
|
||||
@@ -67,7 +67,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @mcp_tokens.each do |mcp_token| %>
|
||||
<tr scope="row">
|
||||
<tr>
|
||||
<td>
|
||||
<%= mcp_token.name %>
|
||||
</td>
|
||||
@@ -98,8 +98,8 @@
|
||||
<div class="flex items-center gap-4 py-2.5">
|
||||
<div class="flex items-center space-x-1">
|
||||
<span class="text-left"><%= t('enable_mcp_server') %></span>
|
||||
<span class="tooltip tooltip-top flex cursor-pointer" data-tip="<%= t('all_existing_mcp_connections_will_be_stopped_immediately_when_this_setting_is_disabled') %>">
|
||||
<%= svg_icon('info_circle', class: 'hidden md:inline-block w-4 h-4 shrink-0') %>
|
||||
<span class="tooltip tooltip-top hidden md:flex cursor-pointer" data-tip="<%= t('all_existing_mcp_connections_will_be_stopped_immediately_when_this_setting_is_disabled') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4 shrink-0') %>
|
||||
</span>
|
||||
</div>
|
||||
<submit-form data-on="change" class="flex">
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
<%= t('use_an_authenticator_mobile_app_like_google_authenticator_or_1password_to_scan_the_qr_code_below') %>
|
||||
</p>
|
||||
<div>
|
||||
<%== RQRCode::QRCode.new(@provision_url).as_svg(viewbox: true, svg_attributes: { class: 'md:w-80 md:h-80 my-4 mx-auto' }) %>
|
||||
<%== RQRCode::QRCode.new(@provision_url).as_svg(viewbox: true, svg_attributes: { class: 'md:w-80 md:h-80 my-4 mx-auto' }).sub(/\A<\?xml[^>]*\?>/, '') %>
|
||||
</div>
|
||||
<div class="form-control my-6 space-y-2">
|
||||
<%= f.text_field :otp_attempt, required: true, placeholder: 'XXX-XXX', class: 'base-input text-center' %>
|
||||
<%= f.text_field :otp_attempt, required: true, inputmode: 'numeric', placeholder: 'XXXXXX', class: 'base-input text-center' %>
|
||||
<span>
|
||||
<%= @error_message %>
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<%= render 'shared/turbo_modal', title: t('remove_2fa') do %>
|
||||
<%= form_for '', url: mfa_setup_path, method: :delete, data: { turbo_frame: :_top } do |f| %>
|
||||
<div class="form-control my-6 space-y-2">
|
||||
<%= f.text_field :otp_attempt, required: true, placeholder: 'XXX-XXX', class: 'base-input text-center' %>
|
||||
<%= f.text_field :otp_attempt, required: true, inputmode: 'numeric', placeholder: 'XXXXXX', class: 'base-input text-center' %>
|
||||
<span>
|
||||
<%= @error_message %>
|
||||
</span>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<span>
|
||||
<%= t('completed_documents_notification_bcc_address') %>
|
||||
</span>
|
||||
<span class="tooltip" data-tip="<%= t('send_email_copy_with_completed_documents_to_a_specified_bcc_address') %>">
|
||||
<span class="tooltip hidden md:inline-flex" data-tip="<%= t('send_email_copy_with_completed_documents_to_a_specified_bcc_address') %>">
|
||||
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="flex flex-wrap space-y-4 md:flex-nowrap md:space-y-0">
|
||||
<%= render 'shared/settings_nav' %>
|
||||
<div class="flex-grow max-w-xl mx-auto">
|
||||
<h1 class="text-4xl font-bold mb-4">
|
||||
<h1 class="text-3xl md:text-4xl font-bold mb-4">
|
||||
<%= t('email_notifications') %>
|
||||
</h1>
|
||||
<div class="mt-2 mb-1">
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
<%= render 'bcc_form', config: @bcc_config %>
|
||||
<div class="flex justify-between items-end mb-4 mt-8">
|
||||
<h2 class="text-3xl font-bold">
|
||||
<h2 class="text-2xl md:text-3xl font-bold">
|
||||
<%= t('sign_request_email_reminders') %>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<div class="flex items-center px-2 py-2 border-b" style="height: 42px;">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="tooltip tooltip-top tooltip-no-touch before:text-xs" data-tip="<%= t('bold') %> (Ctrl+B)">
|
||||
<button type="button" data-action="click:<%= editor_tag %>#bold" data-target="<%= editor_tag %>.boldButton" aria-label="<%= t('bold') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
||||
<%= svg_icon('bold', class: 'w-4 h-4') %>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tooltip tooltip-top tooltip-no-touch before:text-xs" data-tip="<%= t('italic') %> (Ctrl+I)">
|
||||
<button type="button" data-action="click:<%= editor_tag %>#italic" data-target="<%= editor_tag %>.italicButton" aria-label="<%= t('italic') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
||||
<%= svg_icon('italic', class: 'w-4 h-4') %>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tooltip tooltip-top tooltip-no-touch before:text-xs" data-tip="<%= t('underline') %> (Ctrl+U)">
|
||||
<button type="button" data-action="click:<%= editor_tag %>#underline" data-target="<%= editor_tag %>.underlineButton" aria-label="<%= t('underline') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
||||
<%= svg_icon('underline', class: 'w-4 h-4') %>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tooltip tooltip-top tooltip-no-touch before:text-xs" data-tip="<%= t('link') %> (Ctrl+K)">
|
||||
<button type="button" data-action="click:<%= editor_tag %>#linkSelection" data-target="<%= editor_tag %>.linkButton" aria-label="<%= t('link') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
||||
<%= svg_icon('link', class: 'w-4 h-4') %>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx-2 h-5 border-l border-base-content/20"></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="tooltip tooltip-top tooltip-no-touch before:text-xs" data-tip="<%= t('undo') %> (Ctrl+Z)">
|
||||
<button type="button" data-action="click:<%= editor_tag %>#undo" data-target="<%= editor_tag %>.undoButton" aria-label="<%= t('undo') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
||||
<%= svg_icon('arrow_back_up', class: 'w-4 h-4') %>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tooltip tooltip-top tooltip-no-touch before:text-xs" data-tip="<%= t('redo') %> (Ctrl+Shift+Z)">
|
||||
<button type="button" data-action="click:<%= editor_tag %>#redo" data-target="<%= editor_tag %>.redoButton" aria-label="<%= t('redo') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
||||
<%= svg_icon('arrow_forward_up', class: 'w-4 h-4') %>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% if local_assigns[:variables]&.any? %>
|
||||
<% variable_labels = { 'account.name' => t('variables.account_name'), 'submitter.link' => t('variables.submitter_link'), 'template.name' => t('variables.template_name'), 'submission.submitters' => t('variables.submission_submitters'), 'submission.link' => t('variables.submission_link'), 'documents.link' => t('variables.documents_link') } %>
|
||||
<div class="dropdown dropdown-end ml-auto">
|
||||
<label tabindex="0" class="flex items-center gap-1 text-sm px-2 py-1 rounded hover:bg-base-200 cursor-pointer whitespace-nowrap">
|
||||
<span class="hidden sm:inline"><%= t('add_variable') %></span>
|
||||
<span class="sm:hidden"><%= t('variable') %></span>
|
||||
<%= svg_icon('chevron_down', class: 'w-3.5 h-3.5') %>
|
||||
</label>
|
||||
<div tabindex="0" class="dropdown-content right-0 top-full mt-1 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50">
|
||||
<% local_assigns[:variables]&.each do |variable| %>
|
||||
<button type="button" data-variable="<%= variable %>" data-action="click:<%= editor_tag %>#insertVariable" class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 text-left text-sm cursor-pointer whitespace-nowrap">
|
||||
<%= variable_labels.fetch(variable, "{#{variable}}") %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
<%= render 'personalization_settings/markdown_editor', name:, value:, variables: local_assigns[:variables] %>
|
||||
@@ -1,76 +1,18 @@
|
||||
<% if value.to_s.start_with?('<html') %>
|
||||
<autoresize-textarea>
|
||||
<%= text_area_tag name, value, required: true, class: 'base-input w-full py-2 !rounded-2xl', dir: 'auto', style: 'max-height: 400px' %>
|
||||
</autoresize-textarea>
|
||||
<% else %>
|
||||
<markdown-editor>
|
||||
<template data-target="markdown-editor.linkTooltipTemplate">
|
||||
<div class="hidden absolute flex bg-white border border-base-300 rounded-xl shadow p-1 gap-1 items-center z-50" contenteditable="false">
|
||||
<input type="text" placeholder="<%= t('enter_a_url_or_variable_name') %>" class="rounded-lg border border-base-300 px-2 py-1 text-sm outline-none" style="field-sizing: content; min-width: 205px; max-width: 320px;" autocomplete="off">
|
||||
<button type="button" data-role="link-save" class="flex items-center px-1 w-6 h-6 rounded hover:bg-success/10 cursor-pointer">
|
||||
<%= svg_icon('check', class: 'w-4 h-4 text-success') %>
|
||||
</button>
|
||||
<button type="button" data-role="link-remove" class="flex items-center px-1 w-6 h-6 rounded hover:bg-error/10 cursor-pointer">
|
||||
<%= svg_icon('x', class: 'w-4 h-4 text-error') %>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="border border-base-content/20 rounded-2xl bg-white">
|
||||
<div class="flex items-center px-2 py-2 border-b" style="height: 42px;">
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('bold') %> (Ctrl+B)">
|
||||
<button type="button" data-action="click:markdown-editor#bold" data-target="markdown-editor.boldButton" aria-label="<%= t('bold') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
||||
<%= svg_icon('bold', class: 'w-4 h-4') %>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('italic') %> (Ctrl+I)">
|
||||
<button type="button" data-action="click:markdown-editor#italic" data-target="markdown-editor.italicButton" aria-label="<%= t('italic') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
||||
<%= svg_icon('italic', class: 'w-4 h-4') %>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('underline') %> (Ctrl+U)">
|
||||
<button type="button" data-action="click:markdown-editor#underline" data-target="markdown-editor.underlineButton" aria-label="<%= t('underline') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
||||
<%= svg_icon('underline', class: 'w-4 h-4') %>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('link') %> (Ctrl+K)">
|
||||
<button type="button" data-action="click:markdown-editor#linkSelection" data-target="markdown-editor.linkButton" aria-label="<%= t('link') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
||||
<%= svg_icon('link', class: 'w-4 h-4') %>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx-2 h-5 border-l border-base-content/20"></div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('undo') %> (Ctrl+Z)">
|
||||
<button type="button" data-action="click:markdown-editor#undo" data-target="markdown-editor.undoButton" aria-label="<%= t('undo') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
||||
<%= svg_icon('arrow_back_up', class: 'w-4 h-4') %>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tooltip tooltip-top before:text-xs" data-tip="<%= t('redo') %> (Ctrl+Shift+Z)">
|
||||
<button type="button" data-action="click:markdown-editor#redo" data-target="markdown-editor.redoButton" aria-label="<%= t('redo') %>" class="flex items-center px-1 w-6 h-6 rounded hover:bg-base-300">
|
||||
<%= svg_icon('arrow_forward_up', class: 'w-4 h-4') %>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% if local_assigns[:variables]&.any? %>
|
||||
<% variable_labels = { 'account.name' => t('variables.account_name'), 'submitter.link' => t('variables.submitter_link'), 'template.name' => t('variables.template_name'), 'submission.submitters' => t('variables.submission_submitters'), 'submission.link' => t('variables.submission_link'), 'documents.link' => t('variables.documents_link') } %>
|
||||
<div class="dropdown dropdown-end ml-auto">
|
||||
<label tabindex="0" class="flex items-center gap-1 text-sm px-2 py-1 rounded hover:bg-base-200 cursor-pointer">
|
||||
<%= t('add_variable') %>
|
||||
<%= svg_icon('chevron_down', class: 'w-3.5 h-3.5') %>
|
||||
</label>
|
||||
<div tabindex="0" class="dropdown-content right-0 top-full mt-1 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50">
|
||||
<% local_assigns[:variables]&.each do |variable| %>
|
||||
<button type="button" data-variable="<%= variable %>" data-action="click:markdown-editor#insertVariable" class="w-full px-2 py-1 rounded-md hover:bg-neutral-100 text-left text-sm cursor-pointer whitespace-nowrap">
|
||||
<%= variable_labels.fetch(variable, "{#{variable}}") %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<div data-target="markdown-editor.editorElement"></div>
|
||||
<markdown-editor>
|
||||
<template data-target="markdown-editor.linkTooltipTemplate">
|
||||
<div class="hidden absolute flex bg-white border border-base-300 rounded-xl shadow p-1 gap-1 items-center z-50" contenteditable="false">
|
||||
<input type="text" placeholder="<%= t('enter_a_url_or_variable_name') %>" class="rounded-lg border border-base-300 px-2 py-1 text-sm outline-none" style="field-sizing: content; min-width: 205px; max-width: 320px;" autocomplete="off">
|
||||
<button type="button" data-role="link-save" class="flex items-center px-1 w-6 h-6 rounded hover:bg-success/10 cursor-pointer">
|
||||
<%= svg_icon('check', class: 'w-4 h-4 text-success') %>
|
||||
</button>
|
||||
<button type="button" data-role="link-remove" class="flex items-center px-1 w-6 h-6 rounded hover:bg-error/10 cursor-pointer">
|
||||
<%= svg_icon('x', class: 'w-4 h-4 text-error') %>
|
||||
</button>
|
||||
</div>
|
||||
<%= hidden_field_tag name, value, required: true, data: { target: 'markdown-editor.textarea' } %>
|
||||
</markdown-editor>
|
||||
<% end %>
|
||||
</template>
|
||||
<div class="border border-base-content/20 rounded-2xl bg-white">
|
||||
<%= render 'personalization_settings/editor_toolbar', editor_tag: 'markdown-editor', variables: local_assigns[:variables] %>
|
||||
<div data-target="markdown-editor.editorElement"></div>
|
||||
</div>
|
||||
<%= hidden_field_tag name, value, required: true, data: { target: 'markdown-editor.textarea' } %>
|
||||
</markdown-editor>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="flex flex-wrap space-y-4 md:flex-nowrap md:space-y-0">
|
||||
<%= render 'shared/settings_nav' %>
|
||||
<div class="flex-grow max-w-xl mx-auto">
|
||||
<p class="text-4xl font-bold mb-4">
|
||||
<p class="text-3xl md:text-4xl font-bold mb-4">
|
||||
<%= t('email_templates') %>
|
||||
</p>
|
||||
<div class="space-y-4">
|
||||
@@ -9,11 +9,11 @@
|
||||
<%= render 'documents_copy_email_form' %>
|
||||
<%= render 'submitter_completed_email_form' %>
|
||||
</div>
|
||||
<p class="text-4xl font-bold mb-4 mt-8">
|
||||
<p class="text-3xl md:text-4xl font-bold mb-4 mt-8">
|
||||
<%= t('company_logo') %>
|
||||
</p>
|
||||
<%= render 'logo_form' %>
|
||||
<p class="text-4xl font-bold mb-4 mt-8">
|
||||
<p class="text-3xl md:text-4xl font-bold mb-4 mt-8">
|
||||
<%= t('submission_form') %>
|
||||
</p>
|
||||
<div class="space-y-4">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="flex flex-wrap space-y-4 md:flex-nowrap md:space-y-0">
|
||||
<%= render 'shared/settings_nav' %>
|
||||
<div class="flex-grow max-w-xl mx-auto">
|
||||
<h1 class="text-4xl font-bold mb-4">
|
||||
<h1 class="text-3xl md:text-4xl font-bold mb-4">
|
||||
<%= t('profile') %>
|
||||
</h1>
|
||||
<%= form_for current_user, url: update_contact_settings_profile_index_path, method: :patch, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<div class="mb-2 w-full">
|
||||
<a href="<%= root_path %>" class="flex items-center space-x-0.5 font-medium">
|
||||
<%= svg_icon('arrow_left', class: 'w-4 h-4 stroke-2') %>
|
||||
<span><%= t('back') %></span>
|
||||
</a>
|
||||
</div>
|
||||
<%= render 'shared/settings_nav', expanded: true %>
|
||||
@@ -1 +1 @@
|
||||
<% 'stats stat stat-figure stat-title stat-value text-accent w-fit hover:bg-white dropdown-open border-base-content/30 before:border-base-content/30' %>
|
||||
<% 'stats stat stat-figure stat-title stat-value text-accent w-fit hover:bg-white dropdown-open border-base-content/30 before:border-base-content/30 order-1 order-2 order-3 order-4 order-5 lg:inline lg:hidden' %>
|
||||
|
||||
@@ -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 tooltip-no-touch" 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>
|
||||
|
||||
@@ -28,7 +28,13 @@
|
||||
<span class="text-sm align-text-top"><%= current_user.initials %></span>
|
||||
</label>
|
||||
<ul tabindex="0" class="z-10 dropdown-content p-2 mt-2 shadow menu text-base bg-base-100 rounded-box min-w-[160px] text-right">
|
||||
<li>
|
||||
<li class="md:hidden">
|
||||
<%= link_to settings_path, class: 'flex items-center' do %>
|
||||
<%= svg_icon('adjustments', class: 'w-5 h-5 flex-shrink-0 stroke-2') %>
|
||||
<span class="mr-1"><%= t('settings') %></span>
|
||||
<% end %>
|
||||
</li>
|
||||
<li class="hidden md:list-item">
|
||||
<%= link_to settings_profile_index_path, class: 'flex items-center' do %>
|
||||
<%= svg_icon('adjustments', class: 'w-5 h-5 flex-shrink-0 stroke-2') %>
|
||||
<span class="mr-1"><%= t('profile') %></span>
|
||||
@@ -75,7 +81,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>
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<% if pagy.pages > 1 %>
|
||||
<div class="flex my-6 justify-center md:justify-between">
|
||||
<div class="hidden md:block text-sm">
|
||||
<% if pagy.count.nil? %>
|
||||
<%= t("pagination.#{local_assigns.fetch(:items_name, 'items')}.range_without_total", from: local_assigns.fetch(:from, pagy.from), to: local_assigns.fetch(:to, pagy.to)) %>
|
||||
<% else %>
|
||||
<%= t("pagination.#{local_assigns.fetch(:items_name, 'items')}.range_with_total", from: local_assigns.fetch(:from, pagy.from), to: local_assigns.fetch(:to, pagy.to), count: local_assigns.fetch(:count, pagy.count)) %>
|
||||
<% end %>
|
||||
<%= local_assigns[:left_additional_html] %>
|
||||
<% left_additional_html = local_assigns[:left_additional_html] %>
|
||||
<div class="flex flex-col md:flex-row md:items-center <%= left_additional_html.present? ? 'mt-3' : 'mt-6' %> mb-6 md:my-6 justify-center md:justify-between">
|
||||
<div class="text-sm <%= 'mb-2 md:mb-0' if left_additional_html.present? %>">
|
||||
<span class="hidden md:inline">
|
||||
<% if pagy.count.nil? %>
|
||||
<%= t("pagination.#{local_assigns.fetch(:items_name, 'items')}.range_without_total", from: local_assigns.fetch(:from, pagy.from), to: local_assigns.fetch(:to, pagy.to)) %>
|
||||
<% else %>
|
||||
<%= t("pagination.#{local_assigns.fetch(:items_name, 'items')}.range_with_total", from: local_assigns.fetch(:from, pagy.from), to: local_assigns.fetch(:to, pagy.to), count: local_assigns.fetch(:count, pagy.count)) %>
|
||||
<% end %>
|
||||
</span>
|
||||
<%= left_additional_html %>
|
||||
</div>
|
||||
<div class="flex items-center space-x-1.5">
|
||||
<div class="flex items-center space-x-1.5 justify-center md:justify-end">
|
||||
<%= local_assigns[:right_additional_html] %>
|
||||
<div class="join">
|
||||
<% if pagy.previous %>
|
||||
|
||||