Compare commits

..

48 Commits

Author SHA1 Message Date
Alex Turchyn 4486f8040e Merge from docusealco/wip 2026-03-30 14:35:18 +03:00
Pete Matsyburka a98caad968 use musl build 2026-03-29 11:18:41 +03:00
Pete Matsyburka ffb75238b4 disable confetti by default 2026-03-28 16:53:20 +02:00
Pete Matsyburka cba3e74f3a optimize build 2026-03-28 16:44:08 +02:00
Pete Matsyburka 8d3f0b5b98 silence not found trace 2026-03-28 14:50:38 +02:00
Pete Matsyburka a2598b30f7 optimize build 2026-03-28 13:48:39 +02:00
Pete Matsyburka 8d11f5ddae adjust email 2fa 2026-03-28 12:51:00 +02:00
Pete Matsyburka 6ffc88b4ed fix n+1 2026-03-28 09:27:50 +02:00
Pete Matsyburka 640b451727 adjust shared link 2026-03-28 09:07:52 +02:00
Pete Matsyburka db6e2eb7ca preserve order with verification 2026-03-27 17:16:06 +02:00
Pete Matsyburka 407816b576 redirect delegate completed 2026-03-27 14:51:14 +02:00
Pete Matsyburka 486cc493e6 add i18n 2026-03-27 14:21:47 +02:00
Pete Matsyburka 3eb0b8a89f allow to delegate 2026-03-27 14:21:47 +02:00
Pete Matsyburka c23eca0ade fix field style 2026-03-26 16:09:57 +02:00
Pete Matsyburka 67de153016 increase image limit 2026-03-26 11:40:42 +02:00
Pete Matsyburka ba2da69a67 fix load ico 2026-03-26 08:29:17 +02:00
Pete Matsyburka 111bef14e1 update rails 2026-03-24 23:56:40 +02:00
Alex Turchyn 22d4c1bd10 Merge from docusealco/wip 2026-03-23 14:59:53 +02:00
Pete Matsyburka e3dfb0ae83 add SMTP_ENABLE_TLS 2026-03-23 10:02:54 +02:00
Pete Matsyburka 8d7c3f0e26 fix generation 2026-03-23 09:48:39 +02:00
Pete Matsyburka b11775ebda remove premailer 2026-03-22 18:13:41 +02:00
Pete Matsyburka 608191183a update deps 2026-03-21 13:37:31 +02:00
Pete Matsyburka 2ba903e170 fix specs 2026-03-18 10:18:28 +02:00
Alex Turchyn 342b64d502 add translations 2026-03-18 09:52:58 +02:00
Pete Matsyburka 1a8f048aaa update devise 2026-03-18 09:52:21 +02:00
Pete Matsyburka 7309581b75 add template archived event 2026-03-18 09:52:21 +02:00
Pete Matsyburka 36d59c40a7 add template update event 2026-03-18 08:36:36 +02:00
Pete Matsyburka ed3c7c324b adjust save dynamic 2026-03-16 17:24:07 +02:00
Alex Turchyn fd868360a3 Merge from docusealco/wip 2026-03-16 17:07:07 +02:00
Pete Matsyburka 8dc2ebfbe0 fix builder save 2026-03-16 16:59:45 +02:00
Alex Turchyn 997711b34a Merge from docusealco/wip 2026-03-16 13:36:06 +02:00
Pete Matsyburka ac01b7129d add i18n 2026-03-16 12:42:58 +02:00
Pete Matsyburka f4b7528c0d fix send form 2026-03-16 08:52:42 +02:00
Pete Matsyburka d7b9ae5df5 add datenow size 2026-03-16 08:21:36 +02:00
Pete Matsyburka 4db29126e9 fix variables list 2026-03-16 08:04:02 +02:00
Pete Matsyburka 4cfee2fe45 fix schema documents 2026-03-15 15:02:46 +02:00
Pete Matsyburka db5dbd6ac2 adjust dynamic editor 2026-03-15 14:05:20 +02:00
Pete Matsyburka 44db775b89 fix variable style 2026-03-14 17:47:53 +02:00
Alex Turchyn 8e2e780a1a fix webhook events filtering 2026-03-13 20:04:09 +02:00
Pete Matsyburka 7aba2eb4da disable decline 2026-03-12 08:58:56 +02:00
Pete Matsyburka 3e250bfb28 error update if declined 2026-03-11 23:44:06 +02:00
Pete Matsyburka 9c02ed66f5 fix field scroll 2026-03-11 14:43:16 +02:00
Pete Matsyburka f5ae8c60d3 preload dynamic editor 2026-03-11 10:44:12 +02:00
Pete Matsyburka e90ac391b2 update readme 2026-03-10 19:09:00 +02:00
Pete Matsyburka 0e5f949bcf remove email from signing reason 2026-03-10 11:39:00 +02:00
Pete Matsyburka b7c270bef8 dir auto section 2026-03-10 10:31:45 +02:00
Pete Matsyburka 5f16b506f5 fix n+1 2026-03-10 08:12:32 +02:00
Pete Matsyburka 176148fc71 adjust default initial 2026-03-09 17:29:46 +02:00
70 changed files with 1858 additions and 496 deletions
+4 -5
View File
@@ -10,7 +10,7 @@ RUN apk --no-cache add fontforge wget && \
wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && \
wget https://github.com/impallari/DancingScript/raw/master/OFL.txt && \
wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/2.0.0/model_704_int8.onnx" && \
wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \
wget -O pdfium-linux.tgz "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-musl-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \
mkdir -p /pdfium-linux && \
tar -xzf pdfium-linux.tgz -C /pdfium-linux
@@ -47,13 +47,11 @@ FROM ruby:4.0.1-alpine AS app
ENV RAILS_ENV=production
ENV BUNDLE_WITHOUT="development:test"
ENV LD_PRELOAD=/lib/libgcompat.so.0
ENV OPENSSL_CONF=/etc/openssl_legacy.cnf
ENV VIPS_MAX_COORD=15000
WORKDIR /app
RUN apk add --no-cache sqlite-dev libpq-dev vips-dev yaml-dev redis libheif vips-heif gcompat ttf-freefont onnxruntime && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
RUN apk add --no-cache libpq vips redis vips-heif ttf-freefont onnxruntime && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal
@@ -71,7 +69,7 @@ activate = 1' >> /etc/openssl_legacy.cnf
COPY --chown=docuseal:docuseal ./Gemfile ./Gemfile.lock ./
RUN apk add --no-cache build-base git && bundle install && apk del --no-cache build-base git && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf && ln -sf /usr/lib/libonnxruntime.so.1 $(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first")
RUN apk add --no-cache build-base git libpq-dev yaml-dev && bundle install && apk del --no-cache build-base git libpq-dev yaml-dev && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales,resources/unicode_data/properties}'] + Dir['/usr/local/bundle/gems/*/{test,tests,examples,sample,misc,doc,docs}'] + Dir['/usr/local/bundle/gems/*/ext/**/*.{c,h,o,S}']" | xargs rm -rf && ln -sf /usr/lib/libonnxruntime.so.1 $(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first")
COPY --chown=docuseal:docuseal ./bin ./bin
COPY --chown=docuseal:docuseal ./app ./app
@@ -98,6 +96,7 @@ RUN ln -s /fonts /app/public/fonts && \
WORKDIR /data/docuseal
ENV HOME=/home/docuseal
ENV WORKDIR=/data/docuseal
ENV VIPS_MAX_COORD=17000
EXPOSE 3000
CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"]
+2 -2
View File
@@ -4,6 +4,7 @@ source 'https://rubygems.org'
ruby '4.0.1'
gem 'addressable'
gem 'arabic-letter-connector', require: false
gem 'aws-sdk-s3', require: false
gem 'aws-sdk-secretsmanager', require: false
@@ -28,7 +29,6 @@ gem 'oj'
gem 'onnxruntime', require: false
gem 'pagy'
gem 'pg', require: false
gem 'premailer-rails'
gem 'pretender'
gem 'puma', require: false
gem 'rack'
@@ -43,7 +43,7 @@ gem 'shakapacker'
gem 'sidekiq'
gem 'sqlite3', require: false
gem 'strip_attributes'
gem 'trilogy', github: 'trilogy-libraries/trilogy', glob: 'contrib/ruby/*.gemspec', require: false
gem 'trilogy', require: false
gem 'turbo-rails'
gem 'twitter_cldr', require: false
gem 'tzinfo-data'
+85 -100
View File
@@ -1,39 +1,31 @@
GIT
remote: https://github.com/trilogy-libraries/trilogy.git
revision: 3963d490459df7a2b5bedb42424c3285f25eab22
glob: contrib/ruby/*.gemspec
specs:
trilogy (2.10.0)
bigdecimal
GEM
remote: https://rubygems.org/
specs:
action_text-trix (2.1.16)
action_text-trix (2.1.17)
railties
actioncable (8.1.2)
actionpack (= 8.1.2)
activesupport (= 8.1.2)
actioncable (8.1.3)
actionpack (= 8.1.3)
activesupport (= 8.1.3)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.1.2)
actionpack (= 8.1.2)
activejob (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
actionmailbox (8.1.3)
actionpack (= 8.1.3)
activejob (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
mail (>= 2.8.0)
actionmailer (8.1.2)
actionpack (= 8.1.2)
actionview (= 8.1.2)
activejob (= 8.1.2)
activesupport (= 8.1.2)
actionmailer (8.1.3)
actionpack (= 8.1.3)
actionview (= 8.1.3)
activejob (= 8.1.3)
activesupport (= 8.1.3)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.1.2)
actionview (= 8.1.2)
activesupport (= 8.1.2)
actionpack (8.1.3)
actionview (= 8.1.3)
activesupport (= 8.1.3)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@@ -41,36 +33,36 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.1.2)
actiontext (8.1.3)
action_text-trix (~> 2.1.15)
actionpack (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
actionpack (= 8.1.3)
activerecord (= 8.1.3)
activestorage (= 8.1.3)
activesupport (= 8.1.3)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.1.2)
activesupport (= 8.1.2)
actionview (8.1.3)
activesupport (= 8.1.3)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.1.2)
activesupport (= 8.1.2)
activejob (8.1.3)
activesupport (= 8.1.3)
globalid (>= 0.3.6)
activemodel (8.1.2)
activesupport (= 8.1.2)
activerecord (8.1.2)
activemodel (= 8.1.2)
activesupport (= 8.1.2)
activemodel (8.1.3)
activesupport (= 8.1.3)
activerecord (8.1.3)
activemodel (= 8.1.3)
activesupport (= 8.1.3)
timeout (>= 0.4.0)
activestorage (8.1.2)
actionpack (= 8.1.2)
activejob (= 8.1.2)
activerecord (= 8.1.2)
activesupport (= 8.1.2)
activestorage (8.1.3)
actionpack (= 8.1.3)
activejob (= 8.1.3)
activerecord (= 8.1.3)
activesupport (= 8.1.3)
marcel (~> 1.0)
activesupport (8.1.2)
activesupport (8.1.3)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -116,7 +108,7 @@ GEM
cgi
rexml
base64 (0.3.0)
bcrypt (3.1.21)
bcrypt (3.1.22)
better_html (2.2.0)
actionview (>= 7.0)
activesupport (>= 7.0)
@@ -158,8 +150,6 @@ GEM
bigdecimal
rexml
crass (1.0.6)
css_parser (1.21.1)
addressable
csv (3.3.5)
csv-safe (3.3.1)
csv (~> 3.0)
@@ -171,16 +161,16 @@ GEM
irb (~> 1.10)
reline (>= 0.3.8)
declarative (0.0.20)
devise (4.9.4)
devise (5.0.3)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 4.1.0)
railties (>= 7.0)
responders
warden (~> 1.2.3)
devise-two-factor (6.3.1)
activesupport (>= 7.0, < 8.2)
devise (>= 4.0, < 5.0)
railties (>= 7.0, < 8.2)
devise-two-factor (6.4.0)
activesupport (>= 7.2, < 8.2)
devise (>= 4.0, < 6.0)
railties (>= 7.2, < 8.2)
rotp (~> 6.0)
diff-lcs (1.6.2)
digest-crc (0.7.0)
@@ -189,7 +179,7 @@ GEM
dotenv (3.2.0)
drb (2.2.3)
email_typo (0.2.3)
erb (6.0.1)
erb (6.0.2)
erb_lint (0.9.0)
activesupport
better_html (>= 2.0.1)
@@ -272,19 +262,19 @@ GEM
geom2d (~> 0.4, >= 0.4.1)
openssl (>= 2.2.1)
strscan (>= 3.1.2)
htmlentities (4.4.2)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3)
io-console (0.8.2)
irb (1.16.0)
irb (1.17.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.18.1)
json (2.19.2)
jwt (3.1.2)
base64
language_server-protocol (3.17.0.5)
@@ -306,7 +296,7 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.25.0)
loofah (2.25.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.0)
@@ -321,13 +311,14 @@ GEM
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
minitest (6.0.1)
minitest (6.0.2)
drb (~> 2.0)
prism (~> 1.5)
msgpack (1.8.0)
multi_json (1.19.1)
net-http (0.9.1)
uri (>= 0.11.1)
net-imap (0.6.2)
net-imap (0.6.3)
date
net-protocol
net-pop (0.1.2)
@@ -337,17 +328,17 @@ GEM
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
nokogiri (1.19.1-aarch64-linux-gnu)
nokogiri (1.19.2-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.1-aarch64-linux-musl)
nokogiri (1.19.2-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.19.1-arm64-darwin)
nokogiri (1.19.2-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.1-x86_64-linux-gnu)
nokogiri (1.19.2-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.1-x86_64-linux-musl)
nokogiri (1.19.2-x86_64-linux-musl)
racc (~> 1.4)
numo-narray-alt (0.9.13)
numo-narray-alt (0.10.3)
oj (3.16.13)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
@@ -376,18 +367,10 @@ GEM
pg (1.6.3-x86_64-linux-musl)
pp (0.6.3)
prettyprint
premailer (1.27.0)
addressable
css_parser (>= 1.19.0)
htmlentities (>= 4.0.0)
premailer-rails (1.12.0)
actionmailer (>= 3)
net-smtp
premailer (~> 1.7, >= 1.7.9)
pretender (0.6.0)
actionpack (>= 7.1)
prettyprint (0.2.0)
prism (1.8.0)
prism (1.9.0)
pry (0.16.0)
coderay (~> 1.1)
method_source (~> 1.0)
@@ -411,33 +394,33 @@ GEM
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
rails (8.1.2)
actioncable (= 8.1.2)
actionmailbox (= 8.1.2)
actionmailer (= 8.1.2)
actionpack (= 8.1.2)
actiontext (= 8.1.2)
actionview (= 8.1.2)
activejob (= 8.1.2)
activemodel (= 8.1.2)
activerecord (= 8.1.2)
activestorage (= 8.1.2)
activesupport (= 8.1.2)
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)
bundler (>= 1.15.0)
railties (= 8.1.2)
railties (= 8.1.3)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
rails-html-sanitizer (1.7.0)
loofah (~> 2.25)
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.2)
actionpack (= 8.1.2)
activesupport (= 8.1.2)
railties (8.1.3)
actionpack (= 8.1.3)
activesupport (= 8.1.3)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -446,7 +429,7 @@ GEM
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.1)
rdoc (7.1.0)
rdoc (7.2.0)
erb
psych (>= 4.0.0)
tsort
@@ -560,8 +543,10 @@ GEM
activemodel (>= 3.0, < 9.0)
strscan (3.1.7)
thor (1.5.0)
timeout (0.6.0)
timeout (0.6.1)
trailblazer-option (0.1.2)
trilogy (2.10.0)
bigdecimal
tsort (0.2.0)
turbo-rails (2.0.21)
actionpack (>= 7.1.0)
@@ -601,7 +586,7 @@ GEM
xpath (3.2.0)
nokogiri (~> 1.8)
yaml (0.4.0)
zeitwerk (2.7.4)
zeitwerk (2.7.5)
PLATFORMS
aarch64-linux
@@ -611,6 +596,7 @@ PLATFORMS
x86_64-linux-musl
DEPENDENCIES
addressable
annotaterb
arabic-letter-connector
aws-sdk-s3
@@ -647,7 +633,6 @@ DEPENDENCIES
onnxruntime
pagy
pg
premailer-rails
pretender
pry-rails
puma
@@ -669,7 +654,7 @@ DEPENDENCIES
simplecov
sqlite3
strip_attributes
trilogy!
trilogy
turbo-rails
twitter_cldr
tzinfo-data
+1 -1
View File
@@ -64,7 +64,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
|Heroku|Railway|
|:--:|:---:|
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.app/template/IGoDnc?referralCode=ruU7JR)|
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.com/deploy/IGoDnc?referralCode=ruU7JR)|
|**DigitalOcean**|**Render**|
| [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [<img alt="Deploy to Render" src="https://render.com/images/deploy-to-render-button.svg" height="40">](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render)
@@ -11,6 +11,7 @@ class AccountConfigsController < ApplicationController
AccountConfig::FORCE_MFA,
AccountConfig::ALLOW_TO_RESUBMIT,
AccountConfig::ALLOW_TO_DECLINE_KEY,
AccountConfig::ALLOW_TO_DELEGATE_KEY,
AccountConfig::FORM_PREFILL_SIGNATURE_KEY,
AccountConfig::ESIGNING_PREFERENCE_KEY,
AccountConfig::FORM_WITH_CONFETTI_KEY,
@@ -36,7 +37,7 @@ class AccountConfigsController < ApplicationController
end
def destroy
raise InvalidKey unless ALLOWED_KEYS.include?(@account_config.key)
raise InvalidKey unless allowed_keys.include?(@account_config.key)
@account_config.destroy!
@@ -45,8 +46,12 @@ class AccountConfigsController < ApplicationController
private
def allowed_keys
ALLOWED_KEYS
end
def load_account_config
raise InvalidKey unless ALLOWED_KEYS.include?(account_config_params[:key])
raise InvalidKey unless allowed_keys.include?(account_config_params[:key])
@account_config =
AccountConfig.find_or_initialize_by(account: current_account, key: account_config_params[:key])
@@ -40,6 +40,10 @@ module Api
return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_content
end
if @submitter.declined_at?
return render json: { error: 'Submitter has already declined the submission.' }, status: :unprocessable_content
end
submission = @submitter.submission
role = submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name']
@@ -69,6 +69,7 @@ module Api
SearchEntries.enqueue_reindex(@template)
WebhookUrls.enqueue_events(@template, 'template.updated')
WebhookUrls.enqueue_events(@template, 'template.archived') if archived == true
render json: @template.as_json(only: %i[id updated_at])
end
@@ -78,6 +79,8 @@ module Api
@template.destroy!
else
@template.update!(archived_at: Time.current)
WebhookUrls.enqueue_events(@template, 'template.archived')
end
render json: @template.as_json(only: %i[id archived_at])
+2 -2
View File
@@ -40,7 +40,7 @@ class ErrorsController < ActionController::Base
render json: { status: error_status_code, error: }.compact, status: error_status_code
end
f.html { render error_status_code.to_s, status: error_status_code }
f.any { render error_status_code.to_s, status: error_status_code }
end
end
@@ -51,7 +51,7 @@ class ErrorsController < ActionController::Base
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS'
headers['Access-Control-Allow-Headers'] = '*'
headers['Access-Control-Max-Age'] = '1728000'
headers['Access-Control-Allow-Credentials'] = true
headers['Access-Control-Allow-Credentials'] = 'true'
end
def error_status_code
@@ -16,6 +16,7 @@ class SubmissionEventsController < ApplicationController
'email_verified' => 'email_check',
'click_sms' => 'hand_click',
'decline_form' => 'x',
'delegate_form' => 'user_share',
'start_verification' => 'player_play',
'complete_verification' => 'check',
'invite_party' => 'user_plus'
+19 -2
View File
@@ -3,11 +3,12 @@
class SubmitFormController < ApplicationController
layout 'form'
around_action :with_browser_locale, only: %i[show completed success]
around_action :with_browser_locale, only: %i[show completed success delegated]
skip_before_action :authenticate_user!
skip_authorization_check
before_action :load_submitter, only: %i[show update completed]
before_action :maybe_redirect_delegated, only: %i[show completed]
before_action :maybe_render_locked_page, only: :show
before_action :maybe_require_link_2fa, only: %i[show]
@@ -91,6 +92,12 @@ class SubmitFormController < ApplicationController
def success; end
def delegated
submitter_version = SubmitterVersion.find_by!(slug: params[:slug] || params[:submit_form_slug])
@submitter = submitter_version.submitter
end
private
def maybe_require_link_2fa
@@ -108,8 +115,18 @@ class SubmitFormController < ApplicationController
render :declined if @submitter.declined_at?
end
def maybe_redirect_delegated
return if @submitter
submitter_version = SubmitterVersion.find_by!(slug: params[:slug] || params[:submit_form_slug])
submitter_version.submitter.submission_events.find_by!(event_type: :delegate_form)
redirect_to submit_form_delegated_path(submitter_version.slug)
end
def load_submitter
@submitter = Submitter.find_by!(slug: params[:slug] || params[:submit_form_slug])
@submitter = Submitter.find_by(slug: params[:slug] || params[:submit_form_slug])
end
def build_attachments_index(submission)
@@ -4,31 +4,38 @@ class SubmitFormDeclineController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
def create
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
before_action :load_submitter
return redirect_to submit_form_path(submitter.slug) if submitter.declined_at? ||
submitter.completed_at? ||
submitter.submission.archived_at? ||
submitter.submission.expired? ||
submitter.submission.template&.archived_at? ||
!Submitters::AuthorizedForForm.call(submitter, current_user,
request)
def create
return redirect_to submit_form_path(@submitter.slug) if @submitter.declined_at? ||
@submitter.completed_at? ||
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
@submitter.submission.template&.archived_at? ||
!Submitters::AuthorizedForForm.call(@submitter,
current_user,
request)
ApplicationRecord.transaction do
submitter.update!(declined_at: Time.current)
@submitter.update!(declined_at: Time.current)
SubmissionEvents.create_with_tracking_data(submitter, 'decline_form', request, { reason: params[:reason] })
SubmissionEvents.create_with_tracking_data(@submitter, 'decline_form', request, { reason: params[:reason] })
end
user = submitter.submission.created_by_user || submitter.template.author
user = @submitter.submission.created_by_user || @submitter.template.author
if user.user_configs.find_by(key: UserConfig::RECEIVE_DECLINED_EMAIL)&.value != false
SubmitterMailer.declined_email(submitter, user).deliver_later!
SubmitterMailer.declined_email(@submitter, user).deliver_later!
end
WebhookUrls.enqueue_events(submitter, 'form.declined')
WebhookUrls.enqueue_events(@submitter, 'form.declined')
redirect_to submit_form_path(submitter.slug)
redirect_to submit_form_path(@submitter.slug)
end
private
def load_submitter
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
end
end
@@ -0,0 +1,47 @@
# frozen_string_literal: true
class SubmitFormDelegateController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
before_action :load_submitter
def create
return redirect_to submit_form_path(@submitter.slug) if @submitter.declined_at? ||
@submitter.completed_at? ||
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
@submitter.submission.template&.archived_at? ||
!Submitters::AuthorizedForForm.call(@submitter,
current_user,
request)
@submitter.account.account_configs.find_by!(key: AccountConfig::ALLOW_TO_DELEGATE_KEY, value: true)
email = Submissions.normalize_email(params[:email])
return redirect_to submit_form_path(@submitter.slug) if email.blank?
old_slug = @submitter.slug
ApplicationRecord.transaction do
@submitter.submitter_versions.create!(slug: old_slug, email: @submitter.email,
name: @submitter.name, phone: @submitter.phone)
SubmissionEvents.create_with_tracking_data(@submitter, 'delegate_form', request,
{ old_email: @submitter.email, email: })
@submitter.update!(email:, phone: nil, name: nil, slug: SecureRandom.base58(14))
end
SendSubmitterInvitationEmailJob.perform_async('submitter_id' => @submitter.id)
redirect_to submit_form_delegated_path(old_slug)
end
private
def load_submitter
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
end
end
+2
View File
@@ -84,6 +84,8 @@ class TemplatesController < ApplicationController
else
@template.update!(archived_at: Time.current)
WebhookUrls.enqueue_events(@template, 'template.archived')
I18n.t('template_has_been_archived')
end
@@ -6,6 +6,8 @@ class TemplatesRestoreController < ApplicationController
def create
@template.update!(archived_at: nil)
WebhookUrls.enqueue_events(@template, 'template.updated')
redirect_to template_path(@template), notice: I18n.t('template_has_been_unarchived')
end
end
+1 -1
View File
@@ -15,7 +15,7 @@ class WebhookEventsController < ApplicationController
Submissions::SerializeForApi.call(@webhook_event.record)
when 'template.created', 'template.updated'
Templates::SerializeForApi.call(@webhook_event.record)
when 'submission.archived'
when 'submission.archived', 'template.archived'
@webhook_event.record.as_json(only: %i[id archived_at])
end
end
+1 -1
View File
@@ -36,7 +36,7 @@ safeRegisterElement('submission-form', class extends HTMLElement {
expand: ['true', 'false'].includes(this.dataset.expand) ? this.dataset.expand === 'true' : null,
withSignatureId: this.dataset.withSignatureId === 'true',
requireSigningReason: this.dataset.requireSigningReason === 'true',
withConfetti: this.dataset.withConfetti !== 'false',
withConfetti: this.dataset.withConfetti === 'true',
withFieldLabels: this.dataset.withFieldLabels !== 'false',
withDisclosure: this.dataset.withDisclosure === 'true',
reuseSignature: this.dataset.reuseSignature !== 'false',
@@ -359,7 +359,9 @@ export default {
},
initTextInitial () {
if (this.submitter.name) {
this.$refs.textInput.value = this.submitter.name.trim().split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]?.toUpperCase() || '').join('')
const parts = this.submitter.name.trim().split(/\s+/)
this.$refs.textInput.value = (parts.length > 1 ? [parts[0], parts[parts.length - 1]] : parts).map((part) => part[0]?.toUpperCase() || '').join('')
}
if (this.$refs.textInput.value) {
+125 -119
View File
@@ -364,7 +364,14 @@
:document="dynamicDocuments.find((dynamicDocument) => dynamicDocument.uuid === document.uuid)"
:selected-submitter="selectedSubmitter"
:drag-field="dragField"
:draw-field="drawField"
:draw-field-type="drawFieldType"
:draw-custom-field="drawCustomField"
:draw-option="drawOption"
@update="onDynamicDocumentUpdate"
@draw="clearDrawField"
@add-custom-field="addCustomField"
@set-draw="[drawField = $event.field, drawOption = $event.option]"
/>
<Document
v-else
@@ -509,6 +516,8 @@
:show-tour-start-form="showTourStartForm"
@add-field="addField"
@set-draw="[drawField = $event.field, drawOption = $event.option]"
@remove-field="onRemoveField"
@remove-submitter="onRemoveSubmitter"
@select-submitter="selectedSubmitter = $event"
@set-draw-type="[drawFieldType = $event, showDrawField = true]"
@set-draw-custom-field="[drawCustomField = $event, showDrawField = true]"
@@ -1155,6 +1164,32 @@ export default {
this.drawCustomField = null
this.showDrawField = false
},
onRemoveField (field) {
if (this.dynamicDocuments.length) {
field.areas?.forEach((area) => {
this.documentRefs.forEach((documentRef) => {
if (documentRef.isDynamic && documentRef.document.uuid === area.attachment_uuid) {
documentRef.removeArea(area)
}
})
})
}
},
onRemoveSubmitter (submitter) {
if (this.dynamicDocuments.length) {
this.template.fields.forEach((field) => {
if (field.submitter_uuid === submitter.uuid) {
field.areas?.forEach((area) => {
this.documentRefs.forEach((documentRef) => {
if (documentRef.isDynamic && documentRef.document.uuid === area.attachment_uuid) {
documentRef.removeArea(area)
}
})
})
}
})
}
},
toggleSelectMode () {
this.isSelectModeRef.value = !this.isSelectModeRef.value
@@ -2512,60 +2547,80 @@ export default {
this.save()
},
removeAreasByAttachmentUuid (attachmentUuid) {
const removedFieldUuids = []
this.selectedAreasRef.value = this.selectedAreasRef.value.filter((area) => area.attachment_uuid !== attachmentUuid)
this.template.fields.forEach((field) => {
[...(field.areas || [])].forEach((area) => {
if (area.attachment_uuid === attachmentUuid) {
field.areas.splice(field.areas.indexOf(area), 1)
removedFieldUuids.push(field.uuid)
}
})
})
this.template.fields = this.template.fields.reduce((acc, field) => {
if (removedFieldUuids.includes(field.uuid) && !field.areas?.length) {
this.removeFieldConditions(field)
} else {
acc.push(field)
}
return acc
}, [])
},
onDocumentRemove (item) {
if (window.confirm(this.t('are_you_sure_'))) {
this.template.schema.splice(this.template.schema.indexOf(item), 1)
const removedFieldUuids = []
this.template.fields.forEach((field) => {
[...(field.areas || [])].forEach((area) => {
if (area.attachment_uuid === item.attachment_uuid) {
field.areas.splice(field.areas.indexOf(area), 1)
removedFieldUuids.push(field.uuid)
}
})
})
this.template.fields = this.template.fields.reduce((acc, f) => {
if (removedFieldUuids.includes(f.uuid) && !f.areas?.length) {
this.removeFieldConditions(f)
} else {
acc.push(f)
}
return acc
}, [])
this.removeAreasByAttachmentUuid(item.attachment_uuid)
this.save()
}
},
onDocumentReplace (data) {
const { replaceSchemaItem, schema, documents } = data
const isReplacingDynamicDocument = !!replaceSchemaItem.dynamic
// eslint-disable-next-line camelcase
const { google_drive_file_id, dynamic, ...cleanedReplaceSchemaItem } = replaceSchemaItem
this.template.schema.splice(this.template.schema.indexOf(replaceSchemaItem), 1, { ...cleanedReplaceSchemaItem, ...schema[0] })
this.template.documents.push(...documents)
if (isReplacingDynamicDocument) {
this.removeAreasByAttachmentUuid(replaceSchemaItem.attachment_uuid)
const dynamicDocumentIndex = this.dynamicDocuments.findIndex((doc) => doc.uuid === replaceSchemaItem.attachment_uuid)
if (dynamicDocumentIndex !== -1) {
this.dynamicDocuments.splice(dynamicDocumentIndex, 1)
}
}
if (data.fields) {
this.template.fields = data.fields
const removedFieldUuids = []
if (isReplacingDynamicDocument) {
this.removeAreasByAttachmentUuid(replaceSchemaItem.attachment_uuid)
} else {
const removedFieldUuids = []
this.template.fields.forEach((field) => {
[...(field.areas || [])].forEach((area) => {
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
field.areas.splice(field.areas.indexOf(area), 1)
this.template.fields.forEach((field) => {
[...(field.areas || [])].forEach((area) => {
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
field.areas.splice(field.areas.indexOf(area), 1)
removedFieldUuids.push(field.uuid)
}
removedFieldUuids.push(field.uuid)
}
})
})
})
this.template.fields =
this.template.fields.filter((f) => !removedFieldUuids.includes(f.uuid) || f.areas?.length)
this.template.fields =
this.template.fields.filter((f) => !removedFieldUuids.includes(f.uuid) || f.areas?.length)
}
}
if (data.submitters) {
@@ -2576,13 +2631,15 @@ export default {
}
}
this.template.fields.forEach((field) => {
(field.areas || []).forEach((area) => {
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
area.attachment_uuid = schema[0].attachment_uuid
}
if (!isReplacingDynamicDocument) {
this.template.fields.forEach((field) => {
(field.areas || []).forEach((area) => {
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
area.attachment_uuid = schema[0].attachment_uuid
}
})
})
})
}
if (this.onUpload) {
this.onUpload(this.template)
@@ -2678,10 +2735,13 @@ export default {
} else {
this.isSaving = true
this.documentRefs.filter((ref) => ref.update).map((ref) => ref.update())
const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic)
dynamicDocumentRefs.map((ref) => ref.update())
this.rebuildVariablesSchema({ disable: false })
const dynamicDocumentSaves = this.documentRefs.filter((ref) => ref.saveBody).map((ref) => ref.saveBody())
const dynamicDocumentSaves = dynamicDocumentRefs.map((ref) => ref.saveBody())
Promise.all([this.save(), ...dynamicDocumentSaves]).then(() => {
window.Turbo.visit(`/templates/${this.template.id}`)
@@ -2694,9 +2754,13 @@ export default {
scrollToArea (area) {
const documentRef = this.documentRefs.find((a) => a.document.uuid === area.attachment_uuid)
documentRef.scrollToArea(area)
if (documentRef.isDynamic) {
this.selectedAreasRef.value = []
} else {
this.selectedAreasRef.value = [area]
}
this.selectedAreasRef.value = [area]
documentRef.scrollToArea(area)
},
baseFetch (path, options = {}) {
return fetch(this.baseUrl + path, {
@@ -2942,92 +3006,34 @@ export default {
}
})
this.reconcileDynamicFields()
this.save()
},
rebuildVariablesSchema ({ disable = true } = {}) {
const parsed = {}
const dynamicDocumentRef = this.documentRefs.find((e) => e.isDynamic)
const dynamicDocumentRef = this.documentRefs.find((e) => e.mergeSchemaProperties)
if (dynamicDocumentRef) {
const parsed = {}
this.documentRefs.forEach((ref) => {
if (ref.updateVariablesSchema) {
ref.updateVariablesSchema()
}
})
this.dynamicDocuments.forEach((doc) => {
if (doc.variables_schema) {
dynamicDocumentRef.mergeSchemaProperties(parsed, doc.variables_schema)
}
})
if (!this.template.variables_schema) {
this.template.variables_schema = parsed
} else {
this.syncVariablesSchema(this.template.variables_schema, parsed, { disable })
}
},
syncVariablesSchema (existing, parsed, { disable = true } = {}) {
for (const key of Object.keys(parsed)) {
if (!existing[key]) {
existing[key] = parsed[key]
}
}
for (const key of Object.keys(existing)) {
if (!parsed[key]) {
if (disable) {
existing[key].disabled = true
} else {
delete existing[key]
this.documentRefs.forEach((ref) => {
if (ref.isDynamic) {
ref.updateVariablesSchema()
}
})
this.dynamicDocuments.forEach((doc) => {
if (doc.variables_schema) {
dynamicDocumentRef.mergeSchemaProperties(parsed, doc.variables_schema)
}
})
if (!this.template.variables_schema) {
this.template.variables_schema = parsed
} else {
delete existing[key].disabled
if (!existing[key].form_type) {
existing[key].type = parsed[key].type
}
if (parsed[key].items) {
if (!existing[key].items) {
existing[key].items = parsed[key].items
} else if (existing[key].items.properties && parsed[key].items.properties) {
this.syncVariablesSchema(existing[key].items.properties, parsed[key].items.properties, { disable })
} else if (!existing[key].items.properties && !parsed[key].items.properties) {
existing[key].items.type = parsed[key].items.type
}
}
if (existing[key].properties && parsed[key].properties) {
this.syncVariablesSchema(existing[key].properties, parsed[key].properties, { disable })
}
dynamicDocumentRef.syncVariablesSchema(this.template.variables_schema, parsed, { disable })
}
} else {
this.template.variables_schema = {}
}
},
reconcileDynamicFields () {
const dynamicFieldUuids = new Set()
this.dynamicDocuments.forEach((doc) => {
const body = doc.body || ''
const uuidRegex = /uuid="([^"]+)"/g
let match
while ((match = uuidRegex.exec(body)) !== null) {
dynamicFieldUuids.add(match[1])
}
})
const toRemove = this.template.fields.filter((field) => {
if (field.areas && field.areas.length > 0) return false
return field.uuid && !dynamicFieldUuids.has(field.uuid)
})
toRemove.forEach((field) => {
this.template.fields.splice(this.template.fields.indexOf(field), 1)
})
this.save()
}
}
}
@@ -5,7 +5,6 @@
:draggable="editable"
:style="[nodeStyle]"
@mousedown="selectArea"
@click.stop
@dragstart="onDragStart"
@contextmenu.prevent.stop="onContextMenu"
>
@@ -25,7 +24,14 @@
<span
v-if="field?.default_value"
class="text-xs overflow-hidden text-ellipsis whitespace-nowrap pr-1 font-normal pl-0.5"
>{{ field.default_value }}</span>
>
<template v-if="field.default_value === '{{date}}'">
{{ t('signing_date') }}
</template>
<template v-else>
{{ field.default_value }}
</template>
</span>
<span
v-else-if="field && !iconOnlyField"
class="text-xs overflow-hidden text-ellipsis whitespace-nowrap pr-1 opacity-70 font-normal pl-0.5"
@@ -227,6 +233,7 @@ export default {
this.onAreaDragStart()
},
onContextMenu (e) {
this.selectArea()
this.onAreaContextMenu(this.area, e)
},
onResizeStart (e) {
@@ -29,18 +29,28 @@
:container="$refs.container"
:editable="editable"
:section="section"
:section-refs="sectionRefs"
:container-width="containerWidth"
:attachments-index="attachmentsIndex"
:selected-submitter="selectedSubmitter"
:drag-field="dragField"
:draw-field="drawField"
:draw-field-type="drawFieldType"
:draw-custom-field="drawCustomField"
:render-html-for-save-ref="renderHtmlForSaveRef"
:draw-option="drawOption"
:attachment-uuid="document.uuid"
@update="onSectionUpdate(section, $event)"
@draw="$emit('draw', $event)"
@add-custom-field="$emit('add-custom-field', $event)"
@set-draw="$emit('set-draw', $event)"
/>
</Teleport>
</div>
</template>
<script>
import { ref } from 'vue'
import DynamicSection from './dynamic_section.vue'
import { dynamicStylesheet, tiptapStylesheet } from './dynamic_editor.js'
import { buildVariablesSchema, mergeSchemaProperties } from './dynamic_variables_schema.js'
@@ -70,9 +80,29 @@ export default {
type: Object,
required: false,
default: null
},
drawField: {
type: Object,
required: false,
default: null
},
drawFieldType: {
type: String,
required: false,
default: ''
},
drawCustomField: {
type: Object,
required: false,
default: null
},
drawOption: {
type: Object,
required: false,
default: null
}
},
emits: ['update'],
emits: ['update', 'draw', 'set-draw', 'add-custom-field'],
data () {
return {
containerWidth: 1040,
@@ -81,6 +111,10 @@ export default {
}
},
computed: {
renderHtmlForSaveRef: () => ref(false),
isDynamic () {
return true
},
attachmentsIndex () {
return (this.document.attachments || []).reduce((acc, att) => {
acc[att.uuid] = att.url
@@ -135,6 +169,15 @@ export default {
},
methods: {
mergeSchemaProperties,
removeArea (area) {
this.sectionRefs.forEach((sectionRef) => {
const pos = sectionRef.findAreaNodePos(area.uuid)
if (pos !== -1) {
sectionRef.editor.chain().focus().deleteRange({ from: pos, to: pos + 1 }).run()
}
})
},
setSectionRefs (ref) {
if (ref) {
this.sectionRefs.push(ref)
@@ -150,11 +193,13 @@ export default {
}
},
scrollToArea (area) {
this.sectionRefs.forEach(({ editor }) => editor.commands.setNodeSelection(0))
this.sectionRefs.forEach(({ editor }) => {
const el = editor.view.dom.querySelector(`[data-area-uuid="${area.uuid}"]`)
if (el) {
editor.chain().focus().setNodeSelection(editor.view.posAtDOM(el, 0)).run()
editor.commands.setNodeSelection(editor.view.posAtDOM(el, 0))
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
@@ -176,7 +221,7 @@ export default {
const target = this.bodyDom.getElementById(section.id)
if (target) {
target.innerHTML = editor.getHTML()
target.innerHTML = this.getHtmlForSave(editor)
}
this.document.body = this.bodyDom.body.innerHTML
@@ -200,7 +245,7 @@ export default {
this.sectionRefs.forEach(({ section, editor }) => {
const target = this.bodyDom.getElementById(section.id)
target.innerHTML = editor.getHTML()
target.innerHTML = this.getHtmlForSave(editor)
})
this.document.body = this.bodyDom.body.innerHTML
@@ -209,6 +254,15 @@ export default {
this.$emit('update', this.document)
},
getHtmlForSave (editor) {
this.renderHtmlForSaveRef.value = true
const result = editor.getHTML()
this.renderHtmlForSaveRef.value = false
return result
},
saveBody () {
clearTimeout(this.saveTimer)
@@ -219,6 +273,43 @@ export default {
body: JSON.stringify({ body: this.bodyDom.body.innerHTML }),
headers: { 'Content-Type': 'application/json' }
})
},
syncVariablesSchema (existing, parsed, { disable = true } = {}) {
for (const key of Object.keys(parsed)) {
if (!existing[key]) {
existing[key] = parsed[key]
}
}
for (const key of Object.keys(existing)) {
if (!parsed[key]) {
if (disable) {
existing[key].disabled = true
} else {
delete existing[key]
}
} else {
delete existing[key].disabled
if (!existing[key].form_type) {
existing[key].type = parsed[key].type
}
if (parsed[key].items) {
if (!existing[key].items) {
existing[key].items = parsed[key].items
} else if (existing[key].items.properties && parsed[key].items.properties) {
this.syncVariablesSchema(existing[key].items.properties, parsed[key].items.properties, { disable })
} else if (!existing[key].items.properties && !parsed[key].items.properties) {
existing[key].items.type = parsed[key].items.type
}
}
if (existing[key].properties && parsed[key].properties) {
this.syncVariablesSchema(existing[key].properties, parsed[key].properties, { disable })
}
}
}
}
}
}
@@ -28,7 +28,7 @@ tiptapStylesheet.replaceSync(
white-space: break-spaces;
-webkit-font-variant-ligatures: none;
font-variant-ligatures: none;
font-feature-settings: "liga" 0; /* the above doesn't seem to work in Edge */
font-feature-settings: "liga" 0;
}
.ProseMirror [contenteditable="false"] {
@@ -89,7 +89,7 @@ img.ProseMirror-separator {
.ProseMirror-focused .ProseMirror-gapcursor {
display: block;
}
.variable-highlight {
dynamic-variable {
background-color: #fef3c7;
}`)
@@ -533,7 +533,7 @@ function buildDecorations (doc) {
const from = pos + match.index
const to = from + match[0].length
decorations.push(Decoration.inline(from, to, { class: 'variable-highlight' }))
decorations.push(Decoration.inline(from, to, { nodeName: 'dynamic-variable' }))
}
})
@@ -584,7 +584,7 @@ const VariableHighlight = Extension.create({
}
})
export function buildEditor ({ dynamicAreaProps, attachmentsIndex, onFieldDrop, onFieldDestroy, editorOptions }) {
export function buildEditor ({ dynamicAreaProps, attachmentsIndex, renderHtmlForSaveRef, onFieldDrop, onFieldDestroy, editorOptions }) {
const FieldNode = Node.create({
name: 'fieldNode',
inline: true,
@@ -617,11 +617,32 @@ export function buildEditor ({ dynamicAreaProps, attachmentsIndex, onFieldDrop,
}]
},
renderHTML ({ node }) {
return ['dynamic-field', {
const attrs = {
uuid: node.attrs.uuid,
'area-uuid': node.attrs.areaUuid,
style: `width: ${node.attrs.width}; height: ${node.attrs.height}; display: ${node.attrs.display}; vertical-align: ${node.attrs.verticalAlign};`
}]
}
if (!renderHtmlForSaveRef.value) {
const fieldArea = dynamicAreaProps.findFieldArea(node.attrs.areaUuid)
if (fieldArea?.field && fieldArea?.area) {
const field = JSON.parse(JSON.stringify(fieldArea.field))
const area = JSON.parse(JSON.stringify(fieldArea.area))
delete field.areas
delete field.uuid
delete field.submitter_uuid
delete area.uuid
delete area.attachment_uuid
attrs['data-field'] = JSON.stringify(field)
attrs['data-area'] = JSON.stringify(area)
attrs['data-template-id'] = dynamicAreaProps.template.id
}
}
return ['dynamic-field', attrs]
},
addNodeView () {
return ({ node, getPos, editor }) => {
@@ -1,11 +1,18 @@
<template>
<div
class="relative bg-white select-none mb-4 before:border before:rounded before:top-0 before:bottom-0 before:left-0 before:right-0 before:absolute"
:class="{ 'cursor-crosshair': isDrawMode && editable }"
>
<div
v-if="isDrawMode && editable && cursorHighlightCoords"
class="absolute pointer-events-none z-10 bg-black"
:style="{ width: '1px', height: cursorHighlightCoords.height + 'px', left: cursorHighlightCoords.x + 'px', top: cursorHighlightCoords.y + 'px' }"
/>
<div :style="{ zoom: containerWidth / sectionWidthPx }">
<section
:id="section.id"
ref="editorElement"
dir="auto"
:class="section.classList.value"
:style="section.style.cssText"
/>
@@ -44,7 +51,10 @@
:field="contextMenuField"
:with-copy-to-all-pages="false"
@close="closeContextMenu"
@copy="onContextMenuCopy"
@delete="onContextMenuDelete"
@add-custom-field="$emit('add-custom-field', $event)"
@set-draw="$emit('set-draw', $event)"
@save="save"
/>
</Teleport>
@@ -53,6 +63,7 @@
<script>
import { shallowRef } from 'vue'
import { DOMSerializer, Fragment } from '@tiptap/pm/model'
import { v4 } from 'uuid'
import FieldContextMenu from './field_context_menu.vue'
import AreaTitle from './area_title.vue'
@@ -72,6 +83,11 @@ export default {
type: Object,
required: true
},
sectionRefs: {
type: Array,
required: false,
default: () => []
},
editable: {
type: Boolean,
required: false,
@@ -100,19 +116,45 @@ export default {
required: false,
default: null
},
drawField: {
type: Object,
required: false,
default: null
},
drawFieldType: {
type: String,
required: false,
default: ''
},
drawCustomField: {
type: Object,
required: false,
default: null
},
renderHtmlForSaveRef: {
type: Object,
required: false,
default: null
},
drawOption: {
type: Object,
required: false,
default: null
},
attachmentUuid: {
type: String,
required: false,
default: null
}
},
emits: ['update'],
emits: ['update', 'draw', 'set-draw', 'add-custom-field'],
data () {
return {
isAreaDrag: false,
areaToolbarCoords: null,
dynamicMenuCoords: null,
contextMenu: null
contextMenu: null,
cursorHighlightCoords: null
}
},
computed: {
@@ -140,6 +182,7 @@ export default {
verification: { width: '150px', height: '80px' },
image: { width: '200px', height: '100px' },
date: { width: '100px', height: this.defaultHeight },
datenow: { width: '100px', height: this.defaultHeight },
text: { width: '120px', height: this.defaultHeight },
cells: { width: '120px', height: this.defaultHeight },
file: { width: '120px', height: this.defaultHeight },
@@ -164,6 +207,9 @@ export default {
isDraggingField () {
return !!(this.fieldsDragFieldRef?.value || this.customDragFieldRef?.value || this.dragField)
},
isDrawMode () {
return !!(this.drawField || this.drawCustomField || this.drawFieldType)
},
selectedArea () {
return this.selectedAreasRef.value[0]
},
@@ -192,45 +238,121 @@ export default {
}
},
mounted () {
this.initEditor()
this.editorRef.value = buildEditor({
dynamicAreaProps: {
template: this.template,
t: this.t,
selectedAreasRef: this.selectedAreasRef,
getFieldTypeIndex: this.getFieldTypeIndex,
findFieldArea: (areaUuid) => this.fieldAreaIndex[areaUuid],
getZoom: () => this.zoom,
onAreaContextMenu: this.onAreaContextMenu,
onAreaResize: this.onAreaResize,
onAreaDragStart: this.onAreaDragStart
},
attachmentsIndex: this.attachmentsIndex,
onFieldDrop: this.onFieldDrop,
onFieldDestroy: this.onFieldDestroy,
renderHtmlForSaveRef: this.renderHtmlForSaveRef,
editorOptions: {
element: this.$refs.editorElement,
editable: this.editable,
content: this.section.innerHTML,
onUpdate: (event) => this.$emit('update', event),
onSelectionUpdate: this.onSelectionUpdate,
onBlur: () => { this.dynamicMenuCoords = null }
}
})
this.editor.view.dom.addEventListener('paste', this.onEditorPaste, true)
this.editor.view.dom.addEventListener('pointerdown', this.onEditorPointerDown, true)
this.editor.view.dom.addEventListener('mousemove', this.onEditorMouseMove)
this.editor.view.dom.addEventListener('mouseleave', this.onEditorMouseLeave)
this.editor.view.dom.addEventListener('keydown', this.onEditorKeyDown)
},
beforeUnmount () {
if (this.editor) {
this.editor.view.dom.removeEventListener('paste', this.onEditorPaste, true)
this.editor.view.dom.removeEventListener('pointerdown', this.onEditorPointerDown, true)
this.editor.view.dom.removeEventListener('mousemove', this.onEditorMouseMove)
this.editor.view.dom.removeEventListener('mouseleave', this.onEditorMouseLeave)
this.editor.view.dom.removeEventListener('keydown', this.onEditorKeyDown)
this.editor.destroy()
}
},
methods: {
async initEditor () {
this.editorRef.value = buildEditor({
dynamicAreaProps: {
template: this.template,
t: this.t,
selectedAreasRef: this.selectedAreasRef,
getFieldTypeIndex: this.getFieldTypeIndex,
findFieldArea: (areaUuid) => this.fieldAreaIndex[areaUuid],
getZoom: () => this.zoom,
onAreaContextMenu: this.onAreaContextMenu,
onAreaResize: this.onAreaResize,
onAreaDragStart: this.onAreaDragStart
},
attachmentsIndex: this.attachmentsIndex,
onFieldDrop: this.onFieldDrop,
onFieldDestroy: this.onFieldDestroy,
editorOptions: {
element: this.$refs.editorElement,
editable: this.editable,
content: this.section.innerHTML,
onUpdate: (event) => this.$emit('update', event),
onSelectionUpdate: this.onSelectionUpdate,
onBlur: () => { this.dynamicMenuCoords = null }
}
})
},
findAreaNodePos (areaUuid) {
const el = this.editor.view.dom.querySelector(`[data-area-uuid="${areaUuid}"]`)
return this.editor.view.posAtDOM(el, 0)
},
getFieldInsertIndex (pos) {
const view = this.editor.view
const fields = this.template.fields || []
if (!fields.length) {
return 0
}
let previousField = null
view.state.doc.nodesBetween(0, pos, (node, nodePos) => {
if (node.type.name !== 'fieldNode') {
return
}
if (nodePos + node.nodeSize <= pos) {
previousField = this.fieldAreaIndex[node.attrs.areaUuid]?.field || previousField
}
})
if (!previousField) {
previousField = this.getPreviousSectionField()
}
if (!previousField) {
return 0
}
const previousFieldIndex = fields.indexOf(previousField)
return previousFieldIndex === -1 ? fields.length : previousFieldIndex + 1
},
insertFieldInTemplate (field, index) {
const currentFieldIndex = this.template.fields.indexOf(field)
if (currentFieldIndex !== -1) {
return currentFieldIndex
}
this.template.fields.splice(index, 0, field)
return index
},
getLastFieldInSection (sectionRef) {
let lastField = null
sectionRef.editor.state.doc.descendants((node) => {
if (node.type.name === 'fieldNode') {
lastField = this.fieldAreaIndex[node.attrs.areaUuid]?.field || lastField
}
})
return lastField
},
getPreviousSectionField () {
const sectionIndex = this.sectionRefs.indexOf(this)
for (let index = sectionIndex - 1; index >= 0; index -= 1) {
const previousField = this.getLastFieldInSection(this.sectionRefs[index])
if (previousField) {
return previousField
}
}
return null
},
removeArea (area) {
const { field } = this.fieldAreaIndex[area.uuid]
const areaIndex = field.areas.indexOf(area)
@@ -361,6 +483,220 @@ export default {
closeContextMenu () {
this.contextMenu = null
},
onEditorMouseLeave () {
this.cursorHighlightCoords = null
},
onEditorKeyDown (event) {
if (event.key === 'Escape') {
this.editor.chain().setNodeSelection(0).blur().run()
this.deselectArea()
}
},
onEditorMouseMove (event) {
if (!this.isDrawMode || !this.editable) {
this.cursorHighlightCoords = null
return
}
const view = this.editor?.view
if (!view) return
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })
if (!pos) {
this.cursorHighlightCoords = null
return
}
const coords = view.coordsAtPos(pos.pos)
const outerRect = this.$el.getBoundingClientRect()
const lineHeight = coords.bottom - coords.top
this.cursorHighlightCoords = {
x: coords.left - outerRect.left,
y: coords.top - outerRect.top,
height: lineHeight
}
},
getFieldNode (areaUuid) {
const pos = this.findAreaNodePos(areaUuid)
return this.editor.state.doc.nodeAt(pos)
},
serializeFieldNodeHtml (areaUuid) {
const node = this.getFieldNode(areaUuid)
if (!node) {
return null
}
const serializer = DOMSerializer.fromSchema(this.editor.state.schema)
const container = document.createElement('div')
container.appendChild(serializer.serializeFragment(Fragment.from(node)))
return container.innerHTML
},
async writeHtmlToClipboard ({ html, text }, clipboardData = null) {
if (clipboardData) {
clipboardData.setData('text/html', html)
clipboardData.setData('text/plain', text || html)
return true
}
if (navigator.clipboard?.write && window.ClipboardItem) {
await navigator.clipboard.write([
new ClipboardItem({
'text/html': new Blob([html], { type: 'text/html' }),
'text/plain': new Blob([text || html], { type: 'text/plain' })
})
])
return true
}
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text || html)
return true
}
return false
},
async copyFieldAreaToClipboard (areaUuid, clipboardData = null) {
const html = this.serializeFieldNodeHtml(areaUuid)
if (!html) {
return false
}
try {
await this.writeHtmlToClipboard({ html, text: html }, clipboardData)
return true
} catch (e) {
console.error('Failed to copy dynamic field:', e)
return false
}
},
async onContextMenuCopy () {
await this.copyFieldAreaToClipboard(this.contextMenu.areaUuid)
this.closeContextMenu()
},
buildCopiedField (payload) {
const field = JSON.parse(JSON.stringify(payload.field))
const area = {
...JSON.parse(JSON.stringify(payload.area)),
uuid: v4(),
attachment_uuid: this.attachmentUuid
}
if (payload.templateId !== this.template.id) {
delete field.conditions
if (field.preferences) {
delete field.preferences.formula
}
}
const newField = {
...field,
uuid: v4(),
submitter_uuid: this.selectedSubmitter.uuid,
areas: [area]
}
if (['radio', 'multiple'].includes(field.type) && field.options?.length) {
const oldOptionUuid = area.option_uuid
const optionsMap = {}
newField.options = field.options.map((opt) => {
const newUuid = v4()
optionsMap[opt.uuid] = newUuid
return { ...opt, uuid: newUuid }
})
area.option_uuid = optionsMap[oldOptionUuid] || newField.options[0]?.uuid
}
return { field: newField, area }
},
onEditorPaste (event) {
const clipboardData = event.clipboardData
if (!clipboardData) {
return
}
const html = clipboardData.getData('text/html')
const text = clipboardData.getData('text/plain')
const clipboardHtml = html || (text.includes('<dynamic-field') ? text : '')
if (!clipboardHtml || !clipboardHtml.includes('data-field=')) {
return
}
const container = document.createElement('div')
container.innerHTML = clipboardHtml
const fieldNodes = [...container.querySelectorAll('dynamic-field[data-field][data-area]')]
if (!fieldNodes.length) {
return
}
event.preventDefault()
const { selection } = this.editor.state
const { from, to } = selection
let insertIndex = this.getFieldInsertIndex(selection.node?.type.name === 'fieldNode' ? to : from)
let lastArea = null
fieldNodes.forEach((fieldNode) => {
const fieldValue = fieldNode.dataset.field
const areaValue = fieldNode.dataset.area
const templateId = fieldNode.dataset.templateId
if (!fieldValue || !areaValue) {
return
}
const { field, area } = this.buildCopiedField({
field: JSON.parse(fieldValue),
area: JSON.parse(areaValue),
templateId: Number(templateId)
})
this.insertFieldInTemplate(field, insertIndex)
insertIndex += 1
fieldNode.setAttribute('uuid', field.uuid)
fieldNode.setAttribute('area-uuid', area.uuid)
fieldNode.removeAttribute('data-field')
fieldNode.removeAttribute('data-area')
fieldNode.removeAttribute('data-template-id')
lastArea = area
})
this.editor.chain().focus().insertContentAt({ from, to }, container.innerHTML).run()
if (lastArea) {
this.editor.commands.setNodeSelection(this.findAreaNodePos(lastArea.uuid))
}
this.closeContextMenu()
this.save()
},
onContextMenuDelete () {
const menu = this.contextMenu
const fieldArea = this.fieldAreaIndex[menu.areaUuid]
@@ -398,89 +734,167 @@ export default {
if (!pos) return false
const fieldType = draggedField.type || 'text'
const dims = this.defaultSizes[fieldType] || this.defaultSizes.text
const areaUuid = v4()
const existingField = this.fieldsDragFieldRef?.value
if (existingField) {
if (!this.template.fields.includes(existingField)) {
this.template.fields.push(existingField)
}
existingField.areas = existingField.areas || []
existingField.areas.push({ uuid: areaUuid, attachment_uuid: this.attachmentUuid })
const nodeType = view.state.schema.nodes.fieldNode
const fieldNode = nodeType.create({
uuid: existingField.uuid,
areaUuid,
width: dims.width,
height: dims.height
})
const tr = view.state.tr.insert(pos.pos, fieldNode)
view.dispatch(tr)
} else {
const newField = {
name: draggedField.name || '',
uuid: v4(),
required: fieldType !== 'checkbox',
submitter_uuid: this.selectedSubmitter.uuid,
type: fieldType,
areas: [{ uuid: areaUuid, attachment_uuid: this.attachmentUuid }]
}
if (['select', 'multiple', 'radio'].includes(fieldType)) {
if (draggedField.options?.length) {
newField.options = draggedField.options.map((opt) => ({
value: typeof opt === 'string' ? opt : opt.value,
uuid: v4()
}))
} else {
newField.options = [{ value: '', uuid: v4() }, { value: '', uuid: v4() }]
}
}
if (fieldType === 'datenow') {
newField.type = 'date'
newField.readonly = true
newField.default_value = '{{date}}'
}
if (['stamp', 'heading', 'strikethrough'].includes(fieldType)) {
newField.readonly = true
if (fieldType === 'strikethrough') {
newField.default_value = true
}
}
this.template.fields.push(newField)
const nodeType = view.state.schema.nodes.fieldNode
const fieldNode = nodeType.create({
uuid: newField.uuid,
areaUuid,
width: dims.width,
height: dims.height
})
const tr = view.state.tr.insert(pos.pos, fieldNode)
view.dispatch(tr)
}
this.insertFieldAtRange({
sourceField: draggedField,
existingField: this.fieldsDragFieldRef?.value,
from: pos.pos
})
this.fieldsDragFieldRef.value = null
this.customDragFieldRef.value = null
this.editor.chain().focus().setNodeSelection(pos.pos).run()
return true
},
onEditorPointerDown (event) {
if (!this.isDrawMode || !this.editable || this.isDraggingField) {
return
}
if (event.button === 2) {
return
}
const view = this.editor?.view
if (!view) {
return
}
const selection = view.state.selection
const isTextRangeSelection = !selection.empty && !selection.node
let from = selection.from
let to = selection.to
if (!isTextRangeSelection) {
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })
if (!pos) {
return
}
from = pos.pos
to = pos.pos
}
const sourceField = this.drawField || this.drawCustomField || { type: this.drawFieldType }
event.preventDefault()
event.stopPropagation()
if (this.drawOption && this.drawField) {
const areaWithoutOption = this.drawField.areas?.find((a) => !a.option_uuid)
if (areaWithoutOption && !this.drawField.areas.find((a) => a.option_uuid === this.drawField.options[0].uuid)) {
areaWithoutOption.option_uuid = this.drawField.options[0].uuid
}
}
const inserted = this.insertFieldAtRange({
sourceField,
existingField: this.drawField,
optionUuid: this.drawOption?.uuid,
from,
to
})
if (inserted) {
this.$emit('draw', inserted)
}
},
buildFieldArea ({ optionUuid = null } = {}) {
const area = {
uuid: v4(),
attachment_uuid: this.attachmentUuid
}
if (optionUuid) {
area.option_uuid = optionUuid
}
return area
},
buildNewField (sourceField, area) {
const fieldType = sourceField?.type || 'text'
const newField = {
name: sourceField?.name || '',
uuid: v4(),
required: fieldType !== 'checkbox',
submitter_uuid: this.selectedSubmitter.uuid,
type: fieldType,
areas: [area]
}
if (['select', 'multiple', 'radio'].includes(fieldType)) {
if (sourceField?.options?.length) {
newField.options = sourceField.options.map((opt) => ({
value: typeof opt === 'string' ? opt : opt.value,
uuid: v4()
}))
} else {
newField.options = [{ value: '', uuid: v4() }, { value: '', uuid: v4() }]
}
}
if (fieldType === 'datenow') {
newField.type = 'date'
newField.readonly = true
newField.default_value = '{{date}}'
}
if (['stamp', 'heading', 'strikethrough'].includes(fieldType)) {
newField.readonly = true
if (fieldType === 'strikethrough') {
newField.default_value = true
}
}
return newField
},
insertFieldAtRange ({ sourceField, existingField = null, optionUuid = null, from, to = from }) {
if (!sourceField) {
return null
}
const fieldType = sourceField.type || existingField?.type || 'text'
const dims = this.defaultSizes[fieldType]
const area = this.buildFieldArea({ optionUuid })
const insertIndex = this.getFieldInsertIndex(from)
let field = existingField
const view = this.editor.view
if (field) {
if (!this.template.fields.includes(field)) {
this.insertFieldInTemplate(field, insertIndex)
}
field.areas = field.areas || []
field.areas.push(area)
} else {
field = this.buildNewField(sourceField, area)
this.insertFieldInTemplate(field, insertIndex)
}
const nodeType = view.state.schema.nodes.fieldNode
const fieldNode = nodeType.create({
uuid: field.uuid,
areaUuid: area.uuid,
width: dims.width,
height: dims.height
})
const tr = from !== to
? view.state.tr.replaceWith(from, to, fieldNode)
: view.state.tr.insert(from, fieldNode)
view.dispatch(tr)
this.editor.chain().focus().setNodeSelection(from).run()
this.save()
return true
return { area, field, pos: from }
}
}
}
@@ -22,28 +22,30 @@
class="border-base-300"
>
<label class="peer flex items-center py-1.5 cursor-pointer select-none">
<input
type="checkbox"
class="hidden peer"
checked
>
<IconChevronDown
class="hidden peer-checked:block"
:width="14"
:stroke-width="1.6"
/>
<IconChevronRight
class="block peer-checked:hidden"
:width="14"
:stroke-width="1.6"
/>
<span class="w-5 flex justify-center items-center">
<input
type="checkbox"
class="hidden peer"
checked
>
<IconChevronDown
class="hidden peer-checked:block ml-0.5"
:width="14"
:stroke-width="1.6"
/>
<IconChevronRight
class="block peer-checked:hidden ml-0.5"
:width="14"
:stroke-width="1.6"
/>
</span>
<span class="ml-1">{{ key }}</span>
<span
v-if="node.type === 'array'"
class="text-xs bg-base-200 rounded px-1 ml-1"
>{{ t('list') }}</span>
</label>
<div class="hidden peer-has-[:checked]:block pl-3.5">
<div class="hidden peer-has-[:checked]:block pl-5">
<template
v-for="[varNode, varPath] in nestedVariables(node, key)"
:key="varPath"
@@ -101,7 +103,7 @@ export default {
},
methods: {
isGroup (node) {
return (node.type === 'object' && node.properties) || (node.type === 'array' && node.items?.properties)
return (node.type === 'object' && node.properties) || (node.type === 'array' && node.items?.type === 'object' && node.items?.properties)
},
nestedVariables (node, groupKey) {
const properties = node.type === 'array' ? node.items?.properties : node.properties
@@ -353,7 +353,26 @@ function extractConditionVariables (node, acc = []) {
return acc
}
const IRREGULAR_PLURALS = {
people: 'person',
men: 'man',
women: 'woman',
children: 'child',
teeth: 'tooth',
feet: 'foot',
mice: 'mouse',
geese: 'goose',
oxen: 'ox',
criteria: 'criterion',
phenomena: 'phenomenon',
alumni: 'alumnus',
data: 'datum',
media: 'medium'
}
function singularize (word) {
if (IRREGULAR_PLURALS[word]) return IRREGULAR_PLURALS[word]
if (word.endsWith('ies')) return word.slice(0, -3) + 'y'
if (word.endsWith('ches') || word.endsWith('shes')) return word.slice(0, -2)
if (word.endsWith('ses') || word.endsWith('xes') || word.endsWith('zes')) return word.slice(0, -2)
@@ -513,6 +532,12 @@ function processOperators (operators, propertiesHash = {}, parentProperties = {}
assignNestedSchema(propertiesHash, parentProperties, op.variableName, { type: 'array', items: itemProperties })
processOperators(op.children, propertiesHash, { ...parentProperties, [singularKey]: itemProperties })
if (itemProperties.type === 'object' && itemProperties.properties && Object.keys(itemProperties.properties).length === 0) {
delete itemProperties.properties
itemProperties.type = 'string'
}
break
}
}
+5 -1
View File
@@ -501,7 +501,7 @@ export default {
default: false
}
},
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-draw-custom-field', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder', 'select-submitter', 'rebuild-variables-schema'],
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-draw-custom-field', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder', 'select-submitter', 'rebuild-variables-schema', 'remove-field', 'remove-submitter'],
data () {
return {
fieldPagesLoaded: null,
@@ -805,6 +805,8 @@ export default {
this.$emit('change-submitter', this.submitters[0])
}
this.$emit('remove-submitter', submitter)
this.save()
},
removeField (field, save = true) {
@@ -830,6 +832,8 @@ export default {
this.selectedAreasRef.value.splice(this.selectedAreasRef.value.indexOf(area), 1)
})
this.$emit('remove-field', field)
if (save) {
this.save()
}
+10 -7
View File
@@ -274,13 +274,16 @@ export default {
makeDynamic () {
this.isMakeDynamicLoading = true
this.baseFetch(`/templates/${this.template.id}/dynamic_documents`, {
method: 'POST',
body: JSON.stringify({ uuid: this.document.uuid }),
headers: {
'Content-Type': 'application/json'
}
}).then(async (resp) => {
Promise.all([
this.baseFetch(`/templates/${this.template.id}/dynamic_documents`, {
method: 'POST',
body: JSON.stringify({ uuid: this.document.uuid }),
headers: {
'Content-Type': 'application/json'
}
}),
import(/* webpackChunkName: "dynamic-editor" */ './dynamic_document')
]).then(async ([resp, _]) => {
const dynamicDocument = await resp.json()
this.template.schema.find((item) => item.attachment_uuid === dynamicDocument.uuid).dynamic = true
@@ -0,0 +1,38 @@
# frozen_string_literal: true
class SendTemplateArchivedWebhookRequestJob
include Sidekiq::Job
sidekiq_options queue: :webhooks
MAX_ATTEMPTS = 10
def perform(params = {})
template = Template.find_by(id: params['template_id'])
return unless template
webhook_url = WebhookUrl.find_by(id: params['webhook_url_id'])
return unless webhook_url
attempt = params['attempt'].to_i
return if webhook_url.url.blank? || webhook_url.events.exclude?('template.archived')
resp = SendWebhookRequest.call(webhook_url, event_type: 'template.archived',
event_uuid: params['event_uuid'],
record: template,
attempt:,
data: template.as_json(only: %i[id archived_at]))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan))
SendTemplateArchivedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
end
+2
View File
@@ -5,6 +5,8 @@ class ApplicationMailer < ActionMailer::Base
layout 'mailer'
register_interceptor ActionMailerConfigsInterceptor
register_interceptor HtmlToPlainTextInterceptor
register_preview_interceptor HtmlToPlainTextInterceptor
register_observer ActionMailerEventsObserver
+1
View File
@@ -30,6 +30,7 @@ class AccountConfig < ApplicationRecord
ALLOW_TYPED_SIGNATURE = 'allow_typed_signature'
ALLOW_TO_RESUBMIT = 'allow_to_resubmit'
ALLOW_TO_DECLINE_KEY = 'allow_to_decline'
ALLOW_TO_DELEGATE_KEY = 'allow_to_delegate'
ALLOW_TO_PARTIAL_DOWNLOAD_KEY = 'allow_to_partial_download'
SUBMITTER_REMINDERS = 'submitter_reminders'
ENFORCE_SIGNING_ORDER_KEY = 'enforce_signing_order'
+1 -1
View File
@@ -125,7 +125,7 @@ class Submission < ApplicationRecord
dynamic_count = template_schema&.count { |e| e['dynamic'] }.to_i
if template.variables_schema.blank?
if variables_schema.blank?
if dynamic_count > 0
if dynamic_count == template_schema.size
template_schema_dynamic_document_attachments
+1
View File
@@ -64,6 +64,7 @@ class SubmissionEvent < ApplicationRecord
invite_party: 'invite_party',
complete_form: 'complete_form',
decline_form: 'decline_form',
delegate_form: 'delegate_form',
api_complete_form: 'api_complete_form'
}, scope: false
+1
View File
@@ -44,6 +44,7 @@ class Submitter < ApplicationRecord
belongs_to :account
has_one :template, through: :submission
has_one :search_entry, as: :record, inverse_of: :record, dependent: :destroy if SearchEntry.table_exists?
has_many :submitter_versions, dependent: :destroy
attribute :values, :string, default: -> { {} }
attribute :preferences, :string, default: -> { {} }
+27
View File
@@ -0,0 +1,27 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: submitter_versions
#
# id :bigint not null, primary key
# email :string
# name :string
# phone :string
# slug :string not null
# created_at :datetime not null
# updated_at :datetime not null
# submitter_id :bigint not null
#
# Indexes
#
# index_submitter_versions_on_slug (slug)
# index_submitter_versions_on_submitter_id (submitter_id)
#
# Foreign Keys
#
# fk_rails_... (submitter_id => submitters.id)
#
class SubmitterVersion < ApplicationRecord
belongs_to :submitter
end
+1
View File
@@ -34,6 +34,7 @@ class WebhookUrl < ApplicationRecord
submission.archived
template.created
template.updated
template.archived
].freeze
belongs_to :account
+32 -3
View File
@@ -142,9 +142,38 @@
<%= svg_icon('info_circle', class: 'hidden md:inline-block w-4 h-4 shrink-0') %>
</span>
</div>
<submit-form data-on="change" class="flex">
<%= f.check_box :value, class: 'toggle', checked: account_config.value != false %>
</submit-form>
<% if !Docuseal.multitenant? || can?(:manage, :disable_decline) %>
<submit-form data-on="change" class="flex">
<%= 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">
<%= f.check_box :value, class: 'toggle pointer-events-none', checked: account_config.value != false, disabled: true %>
</a>
<% end %>
</div>
<% end %>
<% end %>
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::ALLOW_TO_DELEGATE_KEY) %>
<% if can?(:manage, account_config) %>
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
<%= f.hidden_field :key %>
<div class="flex items-center justify-between 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>
</div>
<% if !Docuseal.multitenant? || can?(:manage, :delegate_form) %>
<submit-form data-on="change" class="flex">
<%= 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">
<%= f.check_box :value, class: 'toggle pointer-events-none', checked: account_config.value == true, disabled: true %>
</a>
<% end %>
</div>
<% end %>
<% end %>
+1
View File
@@ -0,0 +1 @@
<svg 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" class="<%= local_assigns[:class] %>"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" /><path d="M6 21v-2a4 4 0 0 1 4 -4h3" /><path d="M16 22l5 -5" /><path d="M21 21.5v-4.5h-4.5" /></svg>

After

Width:  |  Height:  |  Size: 416 B

@@ -1,4 +1,4 @@
<% account_config = AccountConfig.where(account: current_account, key: AccountConfig::FORM_WITH_CONFETTI_KEY).first_or_initialize(value: true) %>
<% account_config = AccountConfig.where(account: current_account, key: AccountConfig::FORM_WITH_CONFETTI_KEY).first_or_initialize(value: false) %>
<% if can?(:manage, account_config) %>
<div class="px-1 mt-2">
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
@@ -8,7 +8,7 @@
<%= t('show_confetti_on_successful_completion') %>
</span>
<submit-form data-on="change" class="flex">
<%= f.check_box :value, { class: 'toggle', checked: account_config.value != false }, '1', '0' %>
<%= f.check_box :value, { class: 'toggle', checked: account_config.value == true }, '1', '0' %>
</submit-form>
</div>
<% end %>
+7 -1
View File
@@ -20,10 +20,13 @@
<% end %>
</p>
</li>
<% submitter_versions_index = @submission.submitters.preload(:submitter_versions).each_with_object({}) { |s, h| h[s.id] = s.submitter_versions.to_a.sort_by(&:created_at) } %>
<% @submission.submission_events.order(:event_timestamp).each do |event| %>
<% submitter = @submission.submitters.find { |e| e.id == event.submitter_id } %>
<% bg_class = event_colors[submitters_uuids.index(submitter.uuid) % event_colors.length] %>
<% submitter_name = event.event_type.include?('sms') || event.event_type.include?('phone') ? (event.data['phone'] || submitter.phone) : (submitter.name || submitter.email || submitter.phone) %>
<% versions = submitter_versions_index[submitter.id] || [] %>
<% active_version = versions.find { |v| v.created_at > event.event_timestamp } %>
<% submitter_name = event.event_type.include?('sms') || event.event_type.include?('phone') ? (event.data['phone'] || active_version&.phone || submitter.phone) : (active_version&.name || active_version&.email || active_version&.phone || submitter.name || submitter.email || submitter.phone) %>
<li class="ml-7">
<span class="absolute flex items-center justify-center w-7 h-7 rounded-full -start-3.5 ring-8 ring-base-100 text-base-content <%= bg_class %>">
<%= svg_icon(SubmissionEventsController::SUBMISSION_EVENT_ICONS.fetch(event.event_type, 'circle_dot'), class: 'w-4 h-4') %>
@@ -43,6 +46,9 @@
<%= t('submission_event_names.complete_verification_by_html', provider: event.data['method'], submitter_name:) %>
<% elsif event.event_type == 'invite_party' && (invited_submitter = @submission.submitters.find { |e| e.uuid == event.data['uuid'] }) && (name = @submission.template_submitters.find { |e| e['uuid'] == event.data['uuid'] }&.dig('name')) %>
<%= t('submission_event_names.invite_party_by_html', invited_submitter_name: [invited_submitter.name || invited_submitter.email || invited_submitter.phone, name].join(' '), submitter_name:) %>
<% elsif event.event_type == 'delegate_form' %>
<% delegate_from = event.data['old_email'].presence || (versions.reverse.find { |v| v.created_at <= event.event_timestamp }&.then { |v| v.name || v.phone }) %>
<%= t('submission_event_names.delegate_form_by_html', from: delegate_from, to: event.data['email']) %>
<% elsif event.event_type.include?('send_') %>
<%= t("submission_event_names.#{event.event_type}_to_html", submitter_name:) %>
<% elsif event.event_type.start_with?('bounce_') || event.event_type.start_with?('complaint_') %>
@@ -3,7 +3,7 @@
<% submitters = template.submitters.reject { |e| e['invite_by_uuid'].present? || e['optional_invite_by_uuid'].present? || e['invite_via_field_uuid'].present? } %>
<dynamic-list class="space-y-4">
<div class="space-y-4">
<div class="<%= 'p-4 rounded-xl bg-base-300/40' unless local_assigns[:variables_form] %>" data-targets="dynamic-list.items">
<div class="relative <%= 'p-4 rounded-xl bg-base-300/40' unless local_assigns[:variables_form] %>" data-targets="dynamic-list.items">
<div>
<div class="absolute right-4 top-5">
<a href="#" data-action="click:dynamic-list#removeItem" class="<%= submitters.size == 1 ? 'right-2 top-1' : '-top-3' %> relative block w-6 h-6 rounded-lg text-neutral-700 text-center bg-base-300 p-1 hidden hover:bg-neutral hover:text-white">
+1 -1
View File
@@ -34,7 +34,7 @@
<% else %>
<dynamic-list class="space-y-4">
<div class="space-y-4">
<div class="<%= 'p-4 rounded-xl bg-base-300/40' unless local_assigns[:variables_form] %>" data-targets="dynamic-list.items">
<div class="relative <%= 'p-4 rounded-xl bg-base-300/40' unless local_assigns[:variables_form] %>" data-targets="dynamic-list.items">
<div>
<div class="absolute right-4 top-5">
<a href="#" data-action="click:dynamic-list#removeItem" class="-top-3 relative block w-6 h-6 rounded-lg text-neutral-700 text-center bg-base-300 p-1 hidden hover:bg-neutral hover:text-white">
+1 -1
View File
@@ -2,7 +2,7 @@
<% submitters = template.submitters.reject { |e| e['invite_by_uuid'].present? || e['optional_invite_by_uuid'].present? || e['invite_via_field_uuid'].present? } %>
<dynamic-list class="space-y-4">
<div class="space-y-4">
<div class="<%= 'p-4 rounded-xl bg-base-300/40' unless local_assigns[:variables_form] %>" data-targets="dynamic-list.items">
<div class="relative <%= 'p-4 rounded-xl bg-base-300/40' unless local_assigns[:variables_form] %>" data-targets="dynamic-list.items">
<div>
<div class="absolute right-4 top-5">
<a href="#" data-action="click:dynamic-list#removeItem" class="<%= submitters.size == 1 ? 'right-2 top-1' : '-top-3' %> relative block w-6 h-6 rounded-lg text-neutral-700 text-center bg-base-300 p-1 hidden hover:bg-neutral hover:text-white">
@@ -1,4 +1,4 @@
<% if template.preferences['submitters_order'] == 'preserved' || template.submitters.any? { |s| s['order'] } %>
<% if template.preferences['submitters_order'] == 'preserved' || template.submitters.any? { |s| s['order'] } || (template.submitters.size > 1 && template.fields.any? { |f| f['type'] == 'verification' }) %>
<%= f.hidden_field :preserve_order, value: '1' %>
<% elsif template.submitters.size > 1 %>
<div class="form-control">
+13 -11
View File
@@ -8,18 +8,20 @@
<% variables_form = render 'variables_form', schema: @template.variables_schema if @template.variables_schema.present? && @template.variables_schema.any? { |_, v| !v['disabled'] } %>
<%= render 'shared/turbo_modal_large', title: params[:selfsign] ? t('add_recipients') : t('add_new_recipients') do %>
<% options = [only_detailed ? nil : [t('via_email'), 'email'], only_detailed ? nil : [t('via_phone'), 'phone'], [t('detailed'), 'detailed'], with_list ? [t('upload_list'), 'list'] : nil].compact %>
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="relative text-center px-2 mt-4 block">
<div class="flex justify-center">
<% options.each_with_index do |(label, value), index| %>
<%= content_tag(value == 'list' ? 'span' : 'toggle-cookies', data: { value:, key: 'add_recipients_tab' }) do %>
<%= radio_button_tag 'option', value, value == (only_detailed ? 'detailed' : default_tab), class: 'peer hidden', data: { action: 'change:toggle-visible#trigger' } %>
<label for="option_<%= value %>" class="block bg-base-200 md:min-w-[112px] text-sm font-semibold whitespace-nowrap py-1.5 px-4 peer-checked:bg-base-300 <%= 'hidden sm:inline-block' if value == 'list' %> <%= 'rounded-l-3xl' if index.zero? %> <%= 'rounded-r-3xl sm:rounded-r-none' if value == 'detailed' %> <%= 'rounded-r-3xl' if index == options.size - 1 %>">
<%= label %>
</label>
<% if options.size > 1 %>
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="relative text-center px-2 mt-4 block">
<div class="flex justify-center">
<% options.each_with_index do |(label, value), index| %>
<%= content_tag(value == 'list' ? 'span' : 'toggle-cookies', data: { value:, key: 'add_recipients_tab' }) do %>
<%= radio_button_tag 'option', value, value == (only_detailed ? 'detailed' : default_tab), class: 'peer hidden', data: { action: 'change:toggle-visible#trigger' } %>
<label for="option_<%= value %>" class="block bg-base-200 md:min-w-[112px] text-sm font-semibold whitespace-nowrap py-1.5 px-4 peer-checked:bg-base-300 <%= 'hidden sm:inline-block' if value == 'list' %> <%= 'rounded-l-3xl' if index.zero? %> <%= 'rounded-r-3xl sm:rounded-r-none' if value == 'detailed' && index != options.size - 1 %> <%= 'rounded-r-3xl' if index == options.size - 1 %>">
<%= label %>
</label>
<% end %>
<% end %>
<% end %>
</div>
</toggle-visible>
</div>
</toggle-visible>
<% end %>
<div class="px-5 mb-5 mt-4">
<% unless only_detailed %>
<div id="email" class="<%= 'hidden' if default_tab != 'email' %>">
@@ -0,0 +1,9 @@
<%= form_for '', url: submit_form_delegate_index_path(submitter.slug), method: :post do |f| %>
<div class="form-control mt-2">
<%= f.label :email, t(:enter_the_email_address_of_the_person_you_want_to_delegate_to), class: 'label' %>
<%= f.email_field :email, required: true, class: 'base-input w-full', dir: 'auto', placeholder: t('email') %>
</div>
<toggle-submit dir="auto" class="form-control mt-4">
<%= f.button button_title(title: t(:delegate)), class: 'base-button' %>
</toggle-submit>
<% end %>
+21
View File
@@ -0,0 +1,21 @@
<div class="max-w-md mx-auto px-2 mt-12 mb-4">
<div class="space-y-6 mx-auto">
<div class="space-y-6">
<div class="flex items-center justify-center">
<%= render 'start_form/banner' %>
</div>
<div class="flex items-center bg-base-200 rounded-xl p-4 mb-4">
<div class="flex items-center">
<div class="mr-3">
<%= svg_icon('user_share', class: 'w-10 h-10') %>
</div>
<div dir="auto">
<p class="text-lg font-bold mb-1"><%= @submitter.submission.name || @submitter.submission.template.name %></p>
<p class="text-sm"><%= t('document_has_been_delegated_on_html', time: l(@submitter.submission_events.order(event_timestamp: :desc).find_by!(event_type: :delegate_form).event_timestamp, format: :long)) %></p>
</div>
</div>
</div>
</div>
</div>
</div>
<%= render 'shared/attribution', link_path: '/start', account: @submitter.account %>
+75 -26
View File
@@ -9,6 +9,7 @@
<% schema = Submissions.filtered_conditions_schema(@submitter.submission, values:, include_submitter_uuid: @submitter.uuid) %>
<% font_scale = 1000.0 / PdfUtils::US_LETTER_W %>
<% decline_modal_checkbox_uuid = nil %>
<% delegate_modal_checkbox_uuid = nil %>
<div style="max-height: -webkit-fill-available;">
<div id="scrollbox">
<div class="mx-auto block pb-72" style="max-width: 1000px">
@@ -20,42 +21,85 @@
<%= @submitter.submission.name || @submitter.submission.template.name %>
</div>
<div class="flex items-center space-x-2" style="margin-left: 20px; flex-shrink: 0">
<% if @form_configs[:with_decline] %>
<label id="decline_button" for="<%= decline_modal_checkbox_uuid = SecureRandom.uuid %>" class="btn btn-sm !px-5"><%= t(:decline) %></label>
<% end %>
<% if @form_configs[:with_partial_download] %>
<download-button data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm !px-4">
<span class="flex items-center justify-center" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6 inline md:hidden') %>
<span class="hidden md:inline"><%= t('download') %></span>
</span>
<span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-6 h-6 animate-spin') %>
<span class="hidden md:inline"><%= t('downloading') %></span>
</span>
</download-button>
<% if @form_configs[:with_delegate] %>
<label id="delegate_button" for="<%= delegate_modal_checkbox_uuid = SecureRandom.uuid %>" class="btn btn-sm !px-5"><%= t(:delegate) %></label>
<% if @form_configs[:with_decline] %>
<label id="decline_button" for="<%= decline_modal_checkbox_uuid = SecureRandom.uuid %>" class="btn btn-sm px-2" title="<%= t(:decline) %>">
<%= svg_icon('x', class: 'w-5 h-5') %>
</label>
<% end %>
<% if @form_configs[:with_partial_download] %>
<download-button data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2" title="<%= t('download') %>">
<span data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-5 h-5') %>
</span>
<span class="hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-5 h-5 animate-spin') %>
</span>
</download-button>
<% end %>
<% else %>
<% if @form_configs[:with_decline] %>
<label id="decline_button" for="<%= decline_modal_checkbox_uuid = SecureRandom.uuid %>" class="btn btn-sm !px-5"><%= t(:decline) %></label>
<% end %>
<% if @form_configs[:with_partial_download] %>
<download-button data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm !px-4">
<span class="flex items-center justify-center" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6 inline md:hidden') %>
<span class="hidden md:inline"><%= t('download') %></span>
</span>
<span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-6 h-6 animate-spin') %>
<span class="hidden md:inline"><%= t('downloading') %></span>
</span>
</download-button>
<% end %>
<% end %>
</div>
</div>
<scroll-buttons class="fixed right-5 top-2 hidden md:flex gap-1 z-50 ease-in-out opacity-0 -translate-y-10">
<% if @form_configs[:with_decline] %>
<label id="decline_button" for="<%= decline_modal_checkbox_uuid %>" class="btn btn-sm px-0">
<% if @form_configs[:with_delegate] %>
<label id="delegate_button" for="<%= delegate_modal_checkbox_uuid %>" class="btn btn-sm px-0">
<span class="min-[1366px]:inline hidden px-3">
<%= t(:decline) %>
<%= t(:delegate) %>
</span>
<span class="inline min-[1366px]:hidden px-2">
<%= svg_icon('x', class: 'w-5 h-5') %>
<%= svg_icon('user_share', class: 'w-5 h-5') %>
</span>
</label>
<% if @form_configs[:with_decline] %>
<label id="decline_button" for="<%= decline_modal_checkbox_uuid %>" class="btn btn-sm px-2">
<%= svg_icon('x', class: 'w-5 h-5') %>
</label>
<% end %>
<download-button data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2">
<span data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-5 h-5') %>
</span>
<span class="hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-5 h-5 animate-spin') %>
</span>
</download-button>
<% else %>
<% if @form_configs[:with_decline] %>
<label id="decline_button" for="<%= decline_modal_checkbox_uuid %>" class="btn btn-sm px-0">
<span class="min-[1366px]:inline hidden px-3">
<%= t(:decline) %>
</span>
<span class="inline min-[1366px]:hidden px-2">
<%= svg_icon('x', class: 'w-5 h-5') %>
</span>
</label>
<% end %>
<download-button data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2">
<span data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-5 h-5') %>
</span>
<span class="hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-5 h-5 animate-spin') %>
</span>
</download-button>
<% end %>
<download-button data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2">
<span data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-5 h-5') %>
</span>
<span class="hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-5 h-5 animate-spin') %>
</span>
</download-button>
</scroll-buttons>
<% end %>
<% schema.each do |item| %>
@@ -110,4 +154,9 @@
<%= render 'submit_form/decline_form', submitter: @submitter %>
<% end %>
<% end %>
<% if @form_configs[:with_delegate] %>
<%= render 'shared/html_modal', title: t(:delegate), uuid: delegate_modal_checkbox_uuid do %>
<%= render 'submit_form/delegate_form', submitter: @submitter %>
<% end %>
<% end %>
<%= render 'scripts/autosize_field' %>
+1 -1
View File
@@ -49,7 +49,7 @@
<% end %>
</div>
<% end %>
<%= link_to template_share_link_path(template), class: "absolute md:relative bottom-0 right-0 btn btn-xs md:btn-sm whitespace-nowrap btn-neutral text-white mt-1 px-2 #{'btn-disabled text-base-100' if template.variables_schema.present?}", data: { turbo_frame: :modal } do %>
<%= link_to template_share_link_path(template), class: "absolute md:relative bottom-0 right-0 btn btn-xs md:btn-sm whitespace-nowrap btn-neutral text-white mt-1 px-2", data: { turbo_frame: :modal } do %>
<span class="flex items-center justify-center space-x-2">
<%= svg_icon('link', class: 'w-4 h-4 md:w-6 md:h-6') %>
<span><%= t('link') %></span>
+8 -2
View File
@@ -2,6 +2,12 @@
<% enough_defined_submitters = Templates.filter_undefined_submitters(@template.submitters).size < 2 %>
<%= render 'shared/turbo_modal_large', title: t('share_link') do %>
<div class="mt-2 mb-4 px-5">
<% if @template.variables_schema.present? %>
<div class="alert items-start bg-base-100 border-base-300 mt-4">
<%= svg_icon('info_circle', class: 'stroke-current shrink-0 h-6 w-6') %>
<div><%= t('dynamic_template_with_variables_cannot_be_used_via_shared_link') %></div>
</div>
<% end %>
<%= form_for @template, url: template_share_link_path(@template), method: :post, html: { id: 'shared_link_form', autocomplete: 'off', class: 'mt-3' }, data: { close_on_submit: false } do |f| %>
<% if @template.preferences&.dig('require_email_2fa') || @template.preferences&.dig('require_phone_2fa') %>
<label for="template_shared_link" class="tooltip tooltip-bottom flex items-center my-4 justify-between gap-1 alert bg-base-100 border-base-300" data-tip="<%= t(:templates_that_require_email_or_phone_2fa_cannot_be_used_via_a_shared_link) %>">
@@ -12,7 +18,7 @@
<label for="template_shared_link" class="flex items-center my-4 justify-between gap-1 alert bg-base-100 border-base-300">
<span><%= t('enable_shared_link') %></span>
<submit-form data-on="change" class="flex">
<%= f.check_box :shared_link, { disabled: !can?(:update, @template), class: 'toggle' }, 'true', 'false' %>
<%= f.check_box :shared_link, { disabled: !can?(:update, @template) || @template.variables_schema.present?, class: 'toggle' }, 'true', 'false' %>
</submit-form>
</label>
<% end %>
@@ -23,7 +29,7 @@
</check-on-click>
</div>
<% end %>
<% if multiple_submitters && !enough_defined_submitters %>
<% if multiple_submitters && !enough_defined_submitters && @template.variables_schema.blank? %>
<div class="alert items-start bg-base-100 border-base-300 mt-4">
<%= svg_icon('info_circle', class: 'stroke-current shrink-0 h-6 w-6 mt-1') %>
<div><%= t('this_template_has_multiple_parties_which_prevents_the_use_of_a_sharing_link') %></div>
+3 -3
View File
@@ -85,9 +85,9 @@
<div class="mt-6">
<h2 id="log" class="text-3xl font-bold"><%= t('events_log') %></h2>
<div class="tabs border-b mt-4">
<%= link_to t('all'), url_for(params: request.query_parameters.except('status')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status].blank? ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
<%= link_to t('succeeded'), url_for(params: request.query_parameters.merge('status' => 'success')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status] == 'success' ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
<%= link_to t('failed'), url_for(params: request.query_parameters.merge('status' => 'error')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status] == 'error' ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
<%= link_to t('all'), url_for(params: request.query_parameters.except('status', 'page')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status].blank? ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
<%= link_to t('succeeded'), url_for(params: request.query_parameters.except('page').merge('status' => 'success')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status] == 'success' ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
<%= link_to t('failed'), url_for(params: request.query_parameters.except('page').merge('status' => 'error')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status] == 'error' ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
</div>
<% if @webhook_events.present? %>
<div class="divide-y divide-base-300 rounded-lg">
+2
View File
@@ -88,6 +88,8 @@ Rails.application.configure do
openssl_verify_mode: ENV['SMTP_SSL_VERIFY'] == 'false' ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER,
authentication: ENV.fetch('SMTP_PASSWORD', nil).present? ? ENV.fetch('SMTP_AUTHENTICATION', 'plain') : nil,
enable_starttls: ENV['SMTP_ENABLE_STARTTLS'] != 'false',
ssl: ENV['SMTP_ENABLE_SSL'] == 'true',
tls: ENV['SMTP_ENABLE_TLS'] == 'true',
open_timeout: ENV.fetch('SMTP_OPEN_TIMEOUT', '15').to_i,
read_timeout: ENV.fetch('SMTP_READ_TIMEOUT', '25').to_i
}.compact
+11
View File
@@ -0,0 +1,11 @@
# frozen_string_literal: true
module SilenceErrors
def log_error(request, wrapper)
return if wrapper.status_code == 404
super
end
end
ActionDispatch::DebugExceptions.prepend(SilenceErrors)
+147
View File
@@ -163,10 +163,13 @@ en: &en
download: Download
decline: Decline
declined: Declined
delegate: Delegate
enter_the_email_address_of_the_person_you_want_to_delegate_to: Enter the email address of the person you want to delegate to
decline_reason: Decline reason
provide_a_reason: Provide a reason
notify_the_sender_with_the_reason_you_declined: Notify the sender with the reason you declined
form_has_been_declined_on_html: 'Form has been declined on <span class="font-semibold">%{time}</span>'
document_has_been_delegated_on_html: 'Document has been delegated on <span class="font-semibold">%{time}</span>'
name_declined_by_submitter: '"%{name}" declined by %{submitter}'
name_declined_by_submitter_with_the_following_reason: '"%{name}" has been declined by %{submitter} with the following reason:'
completed_successfully: Completed Successfully
@@ -201,6 +204,7 @@ en: &en
allow_typed_text_signatures: Allow typed text signatures
allow_to_resubmit_completed_forms: Allow to resubmit completed forms
allow_to_decline_documents: Allow to decline documents
allow_to_delegate_documents: Allow to delegate documents
remember_and_pre_fill_signatures: Remember and pre-fill signatures
require_authentication_for_file_download_links: Require authentication for file download links
combine_completed_documents_and_audit_log: Combine completed documents and Audit Log
@@ -787,6 +791,7 @@ en: &en
share_link: Share link
enable_shared_link: Enable shared link
share_link_is_currently_disabled: Share link is currently disabled
dynamic_template_with_variables_cannot_be_used_via_shared_link: Dynamic template with variables can't be used via a shared link
select_data_residency: Select data residency
account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: '%{account_name} has invited you to fill and sign documents online effortlessly with a secure, fast, and user-friendly digital document signing solution.'
review_or_download_completed_documents_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: Review or download completed documents. Fill and sign documents online effortlessly with a secure, fast, and user-friendly digital document signing solution.
@@ -841,6 +846,7 @@ en: &en
allow_signers_to_create_signatures_by_typing_their_name_instead_of_drawing_or_uploading_one: Allow signers to create signatures by typing their name instead of drawing or uploading one.
allow_signers_to_resubmit_forms_after_completion_useful_when_corrections_or_multiple_submissions_are_needed: Allow signers to resubmit forms after completion, useful when corrections or multiple submissions are needed.
allow_recipients_to_decline_signing_a_document_the_decline_reason_notification_will_be_sent_to_the_signature_requester: Allow recipients to decline signing a document. The decline reason notification will be sent to the signature requester.
allow_recipients_to_delegate_documents: Allow recipients to delegate documents to a different person via email. This can be useful when the original recipient needs to pass the document to the authorized signer.
save_a_users_signature_and_automatically_pre_fill_it_in_future_signing_sessions: "Save a user's signature and automatically pre-fill it in future signing sessions."
make_document_download_links_expire_after_40_minutes_to_prevent_long_term_access_and_enhance_security: Make document download links expire after 40 minutes to prevent long-term access and enhance security.
require_authentication_with_user_login_or_api_key_to_access_the_document_download_links: Require authentication with user login or API key to access the document download links.
@@ -883,6 +889,13 @@ en: &en
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} email address is awaiting confirmation. Follow the link in the email to confirm."
please_confirm_your_email_address_using_the_link_below_: 'Please confirm your email address using the link below:'
confirm_email: Confirm email
please_verify_your_email_address_to_continue: Please verify your email address to continue.
verification_code_sent_click_link_or_enter_code: A verification code has been sent to your email. Click the email link or enter the one time code to confirm your email.
use_otp_code_to_verify_email_or_click_link_below_html: 'Use <b>%{code}</b> code to verify your email or click the link below:'
verify_your_email: Verify your email
your_email_has_been_confirmed: Your email has been confirmed.
invalid_or_expired_verification_code: Invalid or expired verification code.
didnt_receive_an_email: Did not receive an email?
unconfirmed: Unconfirmed
you_requested_to_reset_your_password_use_the_link_below_to_continue: You requested to reset your password. Use the link below to continue
if_you_didnt_request_this_you_can_ignore_this_email: "If you didn't request this, please ignore this email."
@@ -907,6 +920,9 @@ en: &en
connect_to_docuseal_mcp: Connect to DocuSeal MCP
add_the_following_to_your_mcp_client_configuration: Add the following to your MCP client configuration
works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Works with Claude Desktop, Cursor, Windsurf, VS Code, and any MCP-compatible client.
your_email_address_has_been_changed: Your email address has been changed
the_email_address_for_your_account_has_been_changed_to_new_email: The email address for your account has been changed to %{new_email}.
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: If you did not make this change, please contact us by replying to this email.
devise:
confirmations:
confirmed: Your email address has been successfully confirmed.
@@ -944,6 +960,8 @@ en: &en
complete_kba_by_html: '<b>KBA completed</b> by %{submitter_name}'
fail_kba_by_html: '<b>KBA failed</b> by %{submitter_name}'
api_complete_form_by_html: '<b>Submission completed via API</b> by %{submitter_name}'
decline_form_by_html: '<b>Form declined</b> by %{submitter_name}'
delegate_form_by_html: '<b>Delegated</b> from %{from} to %{to}'
import_list:
select_worksheet: Select Worksheet
open: Open
@@ -1182,11 +1200,14 @@ es: &es
downloading: Descargando
download: Descargar
decline: Rechazar
delegate: Delegar
enter_the_email_address_of_the_person_you_want_to_delegate_to: Ingrese la dirección de correo electrónico de la persona a quien desea delegar
declined: Rechazado
decline_reason: Motivo de rechazo
provide_a_reason: Proporcione un motivo
notify_the_sender_with_the_reason_you_declined: Notifique al remitente con el motivo por el que rechazó
form_has_been_declined_on_html: 'El formulario ha sido rechazado el <span class="font-semibold">%{time}</span>'
document_has_been_delegated_on_html: 'El documento ha sido delegado el <span class="font-semibold">%{time}</span>'
name_declined_by_submitter: '"%{name}" rechazado por %{submitter}'
name_declined_by_submitter_with_the_following_reason: '"%{name}" ha sido rechazado por %{submitter} con el siguiente motivo:'
completed_successfully: Completado exitosamente
@@ -1221,6 +1242,7 @@ es: &es
allow_typed_text_signatures: Permitir firmas de texto mecanografiadas
allow_to_resubmit_completed_forms: Permitir reenviar formularios completados
allow_to_decline_documents: Permitir rechazar documentos
allow_to_delegate_documents: Permitir delegar documentos
remember_and_pre_fill_signatures: Recordar y prellenar firmas
require_authentication_for_file_download_links: Requerir autenticación para enlaces de descarga de archivos
combine_completed_documents_and_audit_log: Combinar documentos completados y Registro de Auditoría
@@ -1804,6 +1826,7 @@ es: &es
share_link: Enlace para compartir
enable_shared_link: Habilitar enlace compartido
share_link_is_currently_disabled: El enlace compartido está deshabilitado actualmente
dynamic_template_with_variables_cannot_be_used_via_shared_link: La plantilla dinámica con variables no se puede usar mediante un enlace compartido
select_data_residency: Seleccionar ubicación de datos
account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: '%{account_name} te ha invitado a completar y firmar documentos en línea fácilmente con una solución de firma digital segura, rápida y fácil de usar.'
review_or_download_completed_documents_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: Revisa o descarga los documentos completados. Completa y firma documentos en línea fácilmente con una solución de firma digital segura, rápida y fácil de usar.
@@ -1858,6 +1881,7 @@ es: &es
allow_signers_to_create_signatures_by_typing_their_name_instead_of_drawing_or_uploading_one: Permitir que los firmantes creen firmas escribiendo su nombre en lugar de dibujar o subir una.
allow_signers_to_resubmit_forms_after_completion_useful_when_corrections_or_multiple_submissions_are_needed: Permitir que los firmantes vuelvan a enviar formularios después de completarlos, útil cuando se necesitan correcciones o múltiples envíos.
allow_recipients_to_decline_signing_a_document_the_decline_reason_notification_will_be_sent_to_the_signature_requester: Permitir que los destinatarios rechacen firmar un documento. La notificación del motivo del rechazo se enviará al solicitante de la firma.
allow_recipients_to_delegate_documents: Permitir que los destinatarios deleguen documentos a otra persona por correo electrónico. Esto puede ser útil cuando el destinatario original necesita pasar el documento al firmante autorizado.
save_a_users_signature_and_automatically_pre_fill_it_in_future_signing_sessions: Guardar la firma de un usuario y rellenarla automáticamente en futuras sesiones de firma.
make_document_download_links_expire_after_40_minutes_to_prevent_long_term_access_and_enhance_security: Hacer que los enlaces de descarga de documentos caduquen después de 40 minutos para evitar el acceso a largo plazo y mejorar la seguridad.
require_authentication_with_user_login_or_api_key_to_access_the_document_download_links: Requerir autenticación con inicio de sesión de usuario o clave API para acceder a los enlaces de descarga de documentos.
@@ -1900,6 +1924,13 @@ es: &es
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} está pendiente de confirmación. Sigue el enlace en el correo para confirmarla."
please_confirm_your_email_address_using_the_link_below_: 'Por favor, confirma tu dirección de correo electrónico utilizando el enlace a continuación:'
confirm_email: Confirmar correo
please_verify_your_email_address_to_continue: Por favor, verifica tu dirección de correo electrónico para continuar.
verification_code_sent_click_link_or_enter_code: Se ha enviado un código de verificación a tu correo. Puedes hacer clic en el enlace del correo o ingresar el código a continuación.
use_otp_code_to_verify_email_or_click_link_below_html: 'Usa el código <b>%{code}</b> para verificar tu correo electrónico o haz clic en el enlace a continuación:'
verify_your_email: Verificar tu correo electrónico
your_email_has_been_confirmed: Tu correo electrónico ha sido confirmado.
invalid_or_expired_verification_code: Código de verificación inválido o expirado.
didnt_receive_an_email: "¿No recibiste un correo electrónico?"
unconfirmed: No confirmado
you_requested_to_reset_your_password_use_the_link_below_to_continue: Solicitaste restablecer tu contraseña. Usa el enlace a continuación para continuar.
if_you_didnt_request_this_you_can_ignore_this_email: "Si no solicitaste esto, puedes ignorar este correo electrónico."
@@ -1924,6 +1955,9 @@ es: &es
connect_to_docuseal_mcp: Conectar a DocuSeal MCP
add_the_following_to_your_mcp_client_configuration: Agregue lo siguiente a la configuración de su cliente MCP
works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Funciona con Claude Desktop, Cursor, Windsurf, VS Code y cualquier cliente compatible con MCP.
your_email_address_has_been_changed: Tu dirección de correo electrónico ha sido cambiada
the_email_address_for_your_account_has_been_changed_to_new_email: La dirección de correo electrónico de tu cuenta ha sido cambiada a %{new_email}.
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Si no realizaste este cambio, contáctanos respondiendo a este correo electrónico.
devise:
confirmations:
confirmed: Tu dirección de correo electrónico ha sido confirmada correctamente.
@@ -1956,6 +1990,8 @@ es: &es
invite_party_by_html: '<b>Invitado</b> %{invited_submitter_name} por %{submitter_name}'
complete_form_by_html: '<b>Envío completado</b> por %{submitter_name}'
api_complete_form_by_html: '<b>Envío completado vía API</b> por %{submitter_name}'
decline_form_by_html: '<b>Formulario rechazado</b> por %{submitter_name}'
delegate_form_by_html: '<b>Delegado</b> de %{from} a %{to}'
start_verification_by_html: '<b>Verificación de identidad iniciada</b> por %{submitter_name}'
complete_verification_by_html: '<b>Verificación de identidad completada</b> por %{submitter_name} con %{provider}'
start_kba_by_html: '<b>KBA iniciado</b> por %{submitter_name}'
@@ -2199,11 +2235,14 @@ it: &it
downloading: Scaricamento
download: Scarica
decline: Rifiuta
delegate: Delega
enter_the_email_address_of_the_person_you_want_to_delegate_to: Inserisci l'indirizzo email della persona a cui vuoi delegare
declined: Rifiutato
decline_reason: Motivo del rifiuto
provide_a_reason: Fornisci un motivo
notify_the_sender_with_the_reason_you_declined: Informare il mittente del motivo per cui hai rifiutato
form_has_been_declined_on_html: 'Il modulo è stato rifiutato il <span class="font-semibold">%{time}</span>'
document_has_been_delegated_on_html: 'Il documento è stato delegato il <span class="font-semibold">%{time}</span>'
name_declined_by_submitter: '"%{name}" rifiutato da %{submitter}'
name_declined_by_submitter_with_the_following_reason: '"%{name}" è stato rifiutato da %{submitter} per il seguente motivo:'
completed_successfully: Completato con successo
@@ -2238,6 +2277,7 @@ it: &it
allow_typed_text_signatures: Permettere firme con testo digitato
allow_to_resubmit_completed_forms: Permettere di reinviare i moduli completati
allow_to_decline_documents: Permettere di rifiutare i documenti
allow_to_delegate_documents: Permettere di delegare i documenti
remember_and_pre_fill_signatures: Ricordare e precompilare le firme
require_authentication_for_file_download_links: "Richiedere l'autenticazione per i link di download dei file"
combine_completed_documents_and_audit_log: Combinare i documenti completati e il Registro di Audit
@@ -2821,6 +2861,7 @@ it: &it
share_link: Link di condivisione
enable_shared_link: Abilita link condiviso
share_link_is_currently_disabled: Il link condiviso è attualmente disabilitato
dynamic_template_with_variables_cannot_be_used_via_shared_link: Il template dinamico con variabili non può essere utilizzato tramite link condiviso
select_data_residency: Seleziona la residenza dei dati
account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: '%{account_name} ti ha invitato a compilare e firmare documenti online con facilità utilizzando una soluzione di firma digitale sicura, veloce e facile da usare.'
review_or_download_completed_documents_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: Rivedi o scarica i documenti completati. Compila e firma documenti online facilmente con una soluzione di firma digitale sicura, veloce e facile da usare.
@@ -2876,6 +2917,7 @@ it: &it
allow_signers_to_create_signatures_by_typing_their_name_instead_of_drawing_or_uploading_one: Consentire ai firmatari di creare firme digitando il proprio nome invece di disegnarle o caricarle.
allow_signers_to_resubmit_forms_after_completion_useful_when_corrections_or_multiple_submissions_are_needed: Consentire ai firmatari di reinviare i moduli dopo il completamento, utile quando sono necessarie correzioni o più invii.
allow_recipients_to_decline_signing_a_document_the_decline_reason_notification_will_be_sent_to_the_signature_requester: Consentire ai destinatari di rifiutare di firmare un documento. La notifica del motivo del rifiuto verrà inviata al richiedente della firma.
allow_recipients_to_delegate_documents: Consentire ai destinatari di delegare i documenti a un'altra persona tramite e-mail. Ciò può essere utile quando il destinatario originale deve passare il documento al firmatario autorizzato.
save_a_users_signature_and_automatically_pre_fill_it_in_future_signing_sessions: Salvare la firma di un utente e compilarla automaticamente nelle future sessioni di firma.
make_document_download_links_expire_after_40_minutes_to_prevent_long_term_access_and_enhance_security: "Fare in modo che i link di download dei documenti scadano dopo 40 minuti per prevenire l'accesso a lungo termine e migliorare la sicurezza."
require_authentication_with_user_login_or_api_key_to_access_the_document_download_links: "Richiedere l'autenticazione con login utente o chiave API per accedere ai link di download dei documenti."
@@ -2918,6 +2960,13 @@ it: &it
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} è in attesa di conferma. Segui il link nell'email per confermare."
please_confirm_your_email_address_using_the_link_below_: 'Conferma il tuo indirizzo email utilizzando il link qui sotto:'
confirm_email: Conferma email
please_verify_your_email_address_to_continue: Verifica il tuo indirizzo email per continuare.
verification_code_sent_click_link_or_enter_code: "È stato inviato un codice di verifica alla tua email. Puoi cliccare il link nell'email o inserire il codice qui sotto."
use_otp_code_to_verify_email_or_click_link_below_html: 'Usa il codice <b>%{code}</b> per verificare la tua email o clicca il link qui sotto:'
verify_your_email: Verifica la tua email
your_email_has_been_confirmed: La tua email è stata confermata.
invalid_or_expired_verification_code: Codice di verifica non valido o scaduto.
didnt_receive_an_email: Non hai ricevuto un'email?
unconfirmed: Non confermato
you_requested_to_reset_your_password_use_the_link_below_to_continue: Hai richiesto di reimpostare la tua password. Usa il link qui sotto per continuare.
if_you_didnt_request_this_you_can_ignore_this_email: "Se non hai richiesto questo, puoi ignorare questa email."
@@ -2942,6 +2991,9 @@ it: &it
connect_to_docuseal_mcp: Connetti a DocuSeal MCP
add_the_following_to_your_mcp_client_configuration: Aggiungi quanto segue alla configurazione del tuo client MCP
works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Funziona con Claude Desktop, Cursor, Windsurf, VS Code e qualsiasi client compatibile con MCP.
your_email_address_has_been_changed: Il tuo indirizzo email è stato modificato
the_email_address_for_your_account_has_been_changed_to_new_email: L'indirizzo email del tuo account è stato modificato in %{new_email}.
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Se non hai effettuato questa modifica, contattaci rispondendo a questa email.
devise:
confirmations:
confirmed: Il tuo indirizzo email è stato confermato con successo.
@@ -2974,6 +3026,8 @@ it: &it
invite_party_by_html: '<b>Invitato</b> %{invited_submitter_name} da %{submitter_name}'
complete_form_by_html: '<b>Invio completato</b> da %{submitter_name}'
api_complete_form_by_html: '<b>Invio completato tramite API</b> da %{submitter_name}'
decline_form_by_html: '<b>Modulo rifiutato</b> da %{submitter_name}'
delegate_form_by_html: '<b>Delegato</b> da %{from} a %{to}'
start_verification_by_html: "<b>Verifica dell'identità iniziata</b> da %{submitter_name}"
complete_verification_by_html: "<b>Verifica dell'identità completata</b> da %{submitter_name} con %{provider}"
start_kba_by_html: "<b>KBA avviata</b> da %{submitter_name}"
@@ -3217,11 +3271,14 @@ fr: &fr
downloading: Téléchargement
download: Télécharger
decline: Refuser
delegate: Déléguer
enter_the_email_address_of_the_person_you_want_to_delegate_to: Saisissez l'adresse e-mail de la personne à qui vous souhaitez déléguer
declined: Refusé
decline_reason: Motif du refus
provide_a_reason: Fournissez un motif
notify_the_sender_with_the_reason_you_declined: Notifier lexpéditeur avec le motif du refus
form_has_been_declined_on_html: Le formulaire a été refusé le <span class="font-semibold">%{time}</span>
document_has_been_delegated_on_html: Le document a été délégué le <span class="font-semibold">%{time}</span>
name_declined_by_submitter: '"%{name}" refusé par %{submitter}'
name_declined_by_submitter_with_the_following_reason: '"%{name}" a été refusé par %{submitter} pour le motif suivant :'
completed_successfully: Terminé avec succès
@@ -3256,6 +3313,7 @@ fr: &fr
allow_typed_text_signatures: Autoriser les signatures tapées
allow_to_resubmit_completed_forms: Autoriser la nouvelle soumission des formulaires complétés
allow_to_decline_documents: Autoriser le refus de documents
allow_to_delegate_documents: Autoriser la délégation de documents
remember_and_pre_fill_signatures: Mémoriser et pré-remplir les signatures
require_authentication_for_file_download_links: Exiger une authentification pour les liens de téléchargement
combine_completed_documents_and_audit_log: Combiner les documents complétés et le journal daudit
@@ -3836,6 +3894,7 @@ fr: &fr
share_link: Lien de partage
enable_shared_link: Activer le lien partagé
share_link_is_currently_disabled: Le lien de partage est actuellement désactivé
dynamic_template_with_variables_cannot_be_used_via_shared_link: Le modèle dynamique avec des variables ne peut pas être utilisé via un lien partagé
select_data_residency: Sélectionner la résidence des données
account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: "%{account_name} vous a invité à remplir et signer des documents en ligne facilement, grâce à une solution de signature numérique sécurisée, rapide et conviviale."
review_or_download_completed_documents_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: Consultez ou téléchargez les documents complétés. Remplissez et signez des documents en ligne facilement, grâce à une solution de signature numérique sécurisée, rapide et conviviale.
@@ -3889,6 +3948,7 @@ fr: &fr
allow_signers_to_create_signatures_by_typing_their_name_instead_of_drawing_or_uploading_one: Autoriser les signataires à créer des signatures en tapant leur nom au lieu de dessiner ou téléverser une signature.
allow_signers_to_resubmit_forms_after_completion_useful_when_corrections_or_multiple_submissions_are_needed: Autoriser les signataires à soumettre à nouveau les formulaires après finalisation, utile pour des corrections ou soumissions multiples.
allow_recipients_to_decline_signing_a_document_the_decline_reason_notification_will_be_sent_to_the_signature_requester: Autoriser les destinataires à refuser de signer un document. Le motif du refus sera notifié au demandeur de signature.
allow_recipients_to_delegate_documents: Autoriser les destinataires à déléguer des documents à une autre personne par e-mail. Cela peut être utile lorsque le destinataire d'origine doit transmettre le document au signataire autorisé.
save_a_users_signature_and_automatically_pre_fill_it_in_future_signing_sessions: Mémoriser la signature dun utilisateur et la pré‑remplir automatiquement lors de futures sessions de signature.
make_document_download_links_expire_after_40_minutes_to_prevent_long_term_access_and_enhance_security: Faire expirer les liens de téléchargement des documents après 40 minutes pour éviter un accès à long terme et renforcer la sécurité.
require_authentication_with_user_login_or_api_key_to_access_the_document_download_links: Exiger une authentification par connexion utilisateur ou clé API pour accéder aux liens de téléchargement des documents.
@@ -3932,6 +3992,13 @@ fr: &fr
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} est en attente de confirmation. Suivez le lien dans l'e-mail pour la confirmer."
please_confirm_your_email_address_using_the_link_below_: 'Veuillez confirmer votre adresse e-mail en utilisant le lien ci-dessous :'
confirm_email: "Confirmer l'e-mail"
please_verify_your_email_address_to_continue: "Veuillez vérifier votre adresse e-mail pour continuer."
verification_code_sent_click_link_or_enter_code: "Un code de vérification a été envoyé à votre adresse e-mail. Vous pouvez cliquer sur le lien dans l'e-mail ou saisir le code ci-dessous."
use_otp_code_to_verify_email_or_click_link_below_html: "Utilisez le code <b>%{code}</b> pour vérifier votre e-mail ou cliquez sur le lien ci-dessous :"
verify_your_email: "Vérifier votre e-mail"
your_email_has_been_confirmed: "Votre adresse e-mail a été confirmée."
invalid_or_expired_verification_code: "Code de vérification invalide ou expiré."
didnt_receive_an_email: "Vous n'avez pas reçu d'e-mail ?"
unconfirmed: Non confirmé
you_requested_to_reset_your_password_use_the_link_below_to_continue: Vous avez demandé à réinitialiser votre mot de passe. Utilisez le lien ci-dessous pour continuer.
if_you_didnt_request_this_you_can_ignore_this_email: "Si vous n'avez pas fait cette demande, veuillez ignorer cet e-mail."
@@ -3956,6 +4023,9 @@ fr: &fr
connect_to_docuseal_mcp: Se connecter à DocuSeal MCP
add_the_following_to_your_mcp_client_configuration: Ajoutez ce qui suit à la configuration de votre client MCP
works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Fonctionne avec Claude Desktop, Cursor, Windsurf, VS Code et tout client compatible MCP.
your_email_address_has_been_changed: Votre adresse e-mail a été modifiée
the_email_address_for_your_account_has_been_changed_to_new_email: "L'adresse e-mail de votre compte a été modifiée en %{new_email}."
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Si vous n'avez pas effectué ce changement, veuillez nous contacter en répondant à cet e-mail.
devise:
confirmations:
confirmed: Votre adresse e-mail a été confirmée avec succès.
@@ -3990,6 +4060,8 @@ fr: &fr
start_verification_by_html: "<b>Vérification didentité démarrée</b> par %{submitter_name}"
complete_verification_by_html: "<b>Vérification didentité terminée</b> par %{submitter_name} avec %{provider}"
api_complete_form_by_html: "<b>Soumission terminée via API</b> par %{submitter_name}"
decline_form_by_html: "<b>Formulaire refusé</b> par %{submitter_name}"
delegate_form_by_html: "<b>Délégué</b> de %{from} à %{to}"
start_kba_by_html: "<b>KBA démarré</b> par %{submitter_name}"
complete_kba_by_html: "<b>KBA terminé</b> par %{submitter_name}"
fail_kba_by_html: "<b>KBA échoué</b> par %{submitter_name}"
@@ -4231,11 +4303,14 @@ pt: &pt
downloading: Baixando
download: Baixar
decline: Recusar
delegate: Delegar
enter_the_email_address_of_the_person_you_want_to_delegate_to: Insira o endereço de e-mail da pessoa para quem deseja delegar
declined: Recusado
decline_reason: Motivo da recusa
provide_a_reason: Forneça um motivo
notify_the_sender_with_the_reason_you_declined: Notifique o remetente com o motivo da sua recusa
form_has_been_declined_on_html: "O formulário foi recusado em <span class='font-semibold'>%{time}</span>"
document_has_been_delegated_on_html: "O documento foi delegado em <span class='font-semibold'>%{time}</span>"
name_declined_by_submitter: '"%{name}" foi recusado por %{submitter}'
name_declined_by_submitter_with_the_following_reason: '"%{name}" foi recusado por %{submitter} com o seguinte motivo:'
completed_successfully: Concluído com sucesso
@@ -4270,6 +4345,7 @@ pt: &pt
allow_typed_text_signatures: Permitir assinaturas digitadas
allow_to_resubmit_completed_forms: Permitir reenviar formulários concluídos
allow_to_decline_documents: Permitir recusar documentos
allow_to_delegate_documents: Permitir delegar documentos
remember_and_pre_fill_signatures: Lembrar e preencher assinaturas automaticamente
require_authentication_for_file_download_links: Requerer autenticação para links de download de arquivos
combine_completed_documents_and_audit_log: Combinar documentos concluídos e log de auditoria
@@ -4853,6 +4929,7 @@ pt: &pt
share_link: Link de compartilhamento
enable_shared_link: Ativar link compartilhado
share_link_is_currently_disabled: O link compartilhado está desativado no momento
dynamic_template_with_variables_cannot_be_used_via_shared_link: O template dinâmico com variáveis não pode ser usado por meio de link compartilhado
select_data_residency: Selecionar local dos dados
account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: '%{account_name} convidou você para preencher e assinar documentos online com facilidade usando uma solução de assinatura digital segura, rápida e fácil de usar.'
review_or_download_completed_documents_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: Reveja ou baixe os documentos concluídos. Preencha e assine documentos online com facilidade usando uma solução de assinatura digital segura, rápida e fácil de usar.
@@ -4907,6 +4984,7 @@ pt: &pt
allow_signers_to_create_signatures_by_typing_their_name_instead_of_drawing_or_uploading_one: Permitir que os signatários criem assinaturas digitando seu nome em vez de desenhá-las ou carregá-las.
allow_signers_to_resubmit_forms_after_completion_useful_when_corrections_or_multiple_submissions_are_needed: Permitir que os signatários reenviem formulários após o término, útil quando são necessárias correções ou múltiplos envios.
allow_recipients_to_decline_signing_a_document_the_decline_reason_notification_will_be_sent_to_the_signature_requester: Permitir que os destinatários recusem assinar um documento. A notificação do motivo da recusa será enviada ao solicitante da assinatura.
allow_recipients_to_delegate_documents: Permitir que os destinatários deleguem documentos a outra pessoa por e-mail. Isso pode ser útil quando o destinatário original precisa passar o documento ao signatário autorizado.
save_a_users_signature_and_automatically_pre_fill_it_in_future_signing_sessions: Salvar a assinatura de um usuário e preenchê-la automaticamente em futuras sessões de assinatura.
make_document_download_links_expire_after_40_minutes_to_prevent_long_term_access_and_enhance_security: Fazer os links de download de documentos expirarem após 40 minutos para evitar o acesso prolongado e aumentar a segurança.
require_authentication_with_user_login_or_api_key_to_access_the_document_download_links: Exigir autenticação com login do usuário ou chave API para acessar os links de download de documentos.
@@ -4949,6 +5027,13 @@ pt: &pt
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} está aguardando confirmação. Siga o link enviado para esse endereço de e-mail para confirmar."
please_confirm_your_email_address_using_the_link_below_: 'Por favor, confirme seu endereço de e-mail usando o link abaixo:'
confirm_email: Confirmar e-mail
please_verify_your_email_address_to_continue: Verifique seu endereço de e-mail para continuar.
verification_code_sent_click_link_or_enter_code: Um código de verificação foi enviado para seu e-mail. Você pode clicar no link do e-mail ou inserir o código abaixo.
use_otp_code_to_verify_email_or_click_link_below_html: 'Use o código <b>%{code}</b> para verificar seu e-mail ou clique no link abaixo:'
verify_your_email: Verificar seu e-mail
your_email_has_been_confirmed: Seu e-mail foi confirmado.
invalid_or_expired_verification_code: Código de verificação inválido ou expirado.
didnt_receive_an_email: Não recebeu um e-mail?
unconfirmed: Não confirmado
you_requested_to_reset_your_password_use_the_link_below_to_continue: Você solicitou a redefinição da sua senha. Use o link abaixo para continuar.
if_you_didnt_request_this_you_can_ignore_this_email: "Se você não solicitou isso, pode ignorar este e-mail."
@@ -4973,6 +5058,9 @@ pt: &pt
connect_to_docuseal_mcp: Conectar ao DocuSeal MCP
add_the_following_to_your_mcp_client_configuration: Adicione o seguinte à configuração do seu cliente MCP
works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Funciona com Claude Desktop, Cursor, Windsurf, VS Code e qualquer cliente compatível com MCP.
your_email_address_has_been_changed: Seu endereço de e-mail foi alterado
the_email_address_for_your_account_has_been_changed_to_new_email: O endereço de e-mail da sua conta foi alterado para %{new_email}.
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Se você não fez essa alteração, entre em contato conosco respondendo a este e-mail.
devise:
confirmations:
confirmed: Seu endereço de e-mail foi confirmado com sucesso.
@@ -5007,6 +5095,8 @@ pt: &pt
start_verification_by_html: '<b>Verificação de identidade iniciada</b> por %{submitter_name}'
complete_verification_by_html: '<b>Verificação de identidade concluída</b> por %{submitter_name} com %{provider}'
api_complete_form_by_html: '<b>Submissão concluída via API</b> por %{submitter_name}'
decline_form_by_html: '<b>Formulário recusado</b> por %{submitter_name}'
delegate_form_by_html: '<b>Delegado</b> de %{from} para %{to}'
start_kba_by_html: '<b>KBA iniciada</b> por %{submitter_name}'
complete_kba_by_html: '<b>KBA concluída</b> por %{submitter_name}'
fail_kba_by_html: '<b>KBA reprovada</b> por %{submitter_name}'
@@ -5248,11 +5338,14 @@ de: &de
downloading: Wird heruntergeladen
download: Download
decline: Ablehnen
delegate: Delegieren
enter_the_email_address_of_the_person_you_want_to_delegate_to: Geben Sie die E-Mail-Adresse der Person ein, an die Sie delegieren möchten
declined: Abgelehnt
decline_reason: Ablehnungsgrund
provide_a_reason: Geben Sie einen Grund an
notify_the_sender_with_the_reason_you_declined: Den Absender über den Grund Ihrer Ablehnung benachrichtigen
form_has_been_declined_on_html: "Das Formular wurde am <span class='font-semibold'>%{time}</span> abgelehnt"
document_has_been_delegated_on_html: "Das Dokument wurde am <span class='font-semibold'>%{time}</span> delegiert"
name_declined_by_submitter: '"%{name}" wurde von %{submitter} abgelehnt'
name_declined_by_submitter_with_the_following_reason: '"%{name}" wurde von %{submitter} mit folgendem Grund abgelehnt:'
completed_successfully: Erfolgreich abgeschlossen
@@ -5287,6 +5380,7 @@ de: &de
allow_typed_text_signatures: Getippte Unterschriften zulassen
allow_to_resubmit_completed_forms: Erneutes Einreichen abgeschlossener Formulare zulassen
allow_to_decline_documents: Ablehnen von Dokumenten erlauben
allow_to_delegate_documents: Delegieren von Dokumenten erlauben
remember_and_pre_fill_signatures: Signaturen merken und vorab ausfüllen
require_authentication_for_file_download_links: Authentifizierung für Dateidownload-Links erforderlich
combine_completed_documents_and_audit_log: Abgeschlossene Dokumente und Prüfprotokoll kombinieren
@@ -5870,6 +5964,7 @@ de: &de
share_link: Freigabelink
enable_shared_link: Freigabelink aktivieren
share_link_is_currently_disabled: Freigabelink ist derzeit deaktiviert
dynamic_template_with_variables_cannot_be_used_via_shared_link: Dynamische Vorlage mit Variablen kann nicht über einen Freigabelink verwendet werden
select_data_residency: Datenstandort auswählen
account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: '%{account_name} hat Sie eingeladen, Dokumente mühelos online mit einer sicheren, schnellen und benutzerfreundlichen digitalen Signaturlösung auszufüllen und zu unterschreiben.'
review_or_download_completed_documents_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: Überprüfen oder laden Sie abgeschlossene Dokumente herunter. Füllen und unterschreiben Sie Dokumente mühelos online mit einer sicheren, schnellen und benutzerfreundlichen digitalen Signaturlösung.
@@ -5924,6 +6019,7 @@ de: &de
allow_signers_to_create_signatures_by_typing_their_name_instead_of_drawing_or_uploading_one: Unterzeichner dürfen Signaturen erstellen, indem sie ihren Namen eingeben, statt eine Signatur zu zeichnen oder hochzuladen.
allow_signers_to_resubmit_forms_after_completion_useful_when_corrections_or_multiple_submissions_are_needed: Unterzeichner dürfen Formulare nach Abschluss erneut einreichen nützlich für Korrekturen oder mehrere Einreichungen.
allow_recipients_to_decline_signing_a_document_the_decline_reason_notification_will_be_sent_to_the_signature_requester: Empfänger dürfen die Unterzeichnung eines Dokuments ablehnen. Die Benachrichtigung mit dem Ablehnungsgrund wird an den Anforderer der Signatur gesendet.
allow_recipients_to_delegate_documents: Empfängern erlauben, Dokumente per E-Mail an eine andere Person weiterzuleiten. Dies kann nützlich sein, wenn der ursprüngliche Empfänger das Dokument an den autorisierten Unterzeichner weiterleiten muss.
save_a_users_signature_and_automatically_pre_fill_it_in_future_signing_sessions: Die Signatur eines Benutzers speichern und sie in zukünftigen Signatursitzungen automatisch vorausfüllen.
make_document_download_links_expire_after_40_minutes_to_prevent_long_term_access_and_enhance_security: Download-Links für Dokumente nach 40 Minuten ablaufen lassen, um langfristigen Zugriff zu verhindern und die Sicherheit zu erhöhen.
require_authentication_with_user_login_or_api_key_to_access_the_document_download_links: Authentifizierung mit Benutzeranmeldung oder API-Schlüssel erforderlich, um auf die Download-Links für Dokumente zuzugreifen.
@@ -5966,6 +6062,13 @@ de: &de
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} wartet auf Bestätigung. Folgen Sie dem Link in der E-Mail, um sie zu bestätigen."
please_confirm_your_email_address_using_the_link_below_: 'Bitte bestätigen Sie Ihre E-Mail-Adresse über den folgenden Link:'
confirm_email: E-Mail bestätigen
please_verify_your_email_address_to_continue: Bitte bestätigen Sie Ihre E-Mail-Adresse, um fortzufahren.
verification_code_sent_click_link_or_enter_code: Ein Verifizierungscode wurde an Ihre E-Mail gesendet. Sie können auf den Link in der E-Mail klicken oder den Code unten eingeben.
use_otp_code_to_verify_email_or_click_link_below_html: 'Verwenden Sie den Code <b>%{code}</b>, um Ihre E-Mail zu bestätigen, oder klicken Sie auf den Link unten:'
verify_your_email: E-Mail bestätigen
your_email_has_been_confirmed: Ihre E-Mail-Adresse wurde bestätigt.
invalid_or_expired_verification_code: Ungültiger oder abgelaufener Verifizierungscode.
didnt_receive_an_email: Keine E-Mail erhalten?
unconfirmed: Unbestätigt
you_requested_to_reset_your_password_use_the_link_below_to_continue: Sie haben angefordert, Ihr Passwort zurückzusetzen. Verwenden Sie den untenstehenden Link, um fortzufahren.
if_you_didnt_request_this_you_can_ignore_this_email: "Wenn Sie dies nicht angefordert haben, können Sie diese E-Mail ignorieren."
@@ -5990,6 +6093,9 @@ de: &de
connect_to_docuseal_mcp: Mit DocuSeal MCP verbinden
add_the_following_to_your_mcp_client_configuration: Fügen Sie Folgendes zu Ihrer MCP-Client-Konfiguration hinzu
works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Funktioniert mit Claude Desktop, Cursor, Windsurf, VS Code und jedem MCP-kompatiblen Client.
your_email_address_has_been_changed: Ihre E-Mail-Adresse wurde geändert
the_email_address_for_your_account_has_been_changed_to_new_email: Die E-Mail-Adresse Ihres Kontos wurde in %{new_email} geändert.
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Wenn Sie diese Änderung nicht vorgenommen haben, kontaktieren Sie uns bitte, indem Sie auf diese E-Mail antworten.
devise:
confirmations:
confirmed: Ihre E-Mail-Adresse wurde erfolgreich bestätigt.
@@ -6024,6 +6130,8 @@ de: &de
start_verification_by_html: '<b>Identitätsüberprüfung gestartet</b> von %{submitter_name}'
complete_verification_by_html: '<b>Identitätsüberprüfung abgeschlossen</b> von %{submitter_name} mit %{provider}'
api_complete_form_by_html: '<b>Einreichung über API abgeschlossen</b> von %{submitter_name}'
decline_form_by_html: '<b>Formular abgelehnt</b> von %{submitter_name}'
delegate_form_by_html: '<b>Delegiert</b> von %{from} an %{to}'
start_kba_by_html: '<b>KBA gestartet</b> von %{submitter_name}'
complete_kba_by_html: '<b>KBA abgeschlossen</b> von %{submitter_name}'
fail_kba_by_html: '<b>KBA fehlgeschlagen</b> von %{submitter_name}'
@@ -6130,11 +6238,14 @@ pl:
hi_there: Cześć,
download: Pobierz
decline: Odrzuć
delegate: Deleguj
enter_the_email_address_of_the_person_you_want_to_delegate_to: Wprowadź adres e-mail osoby, do której chcesz delegować
declined: Odrzucono
decline_reason: Powód odrzucenia
provide_a_reason: Podaj powód
notify_the_sender_with_the_reason_you_declined: Powiadom nadawcę o powodzie, dla którego odrzuciłeś
form_has_been_declined_on_html: 'Formularz został odrzucony o <span class="font-semibold">%{time}</span>'
document_has_been_delegated_on_html: 'Dokument został oddelegowany o <span class="font-semibold">%{time}</span>'
name_declined_by_submitter: '"%{name}" odrzucono przez %{submitter}'
name_declined_by_submitter_with_the_following_reason: '"%{name}" został odrzucony przez %{submitter} z następującym powodem:'
email: E-mail
@@ -6227,11 +6338,14 @@ uk:
hi_there: Привіт,
download: Завантажити
decline: Відхилити
delegate: Делегувати
enter_the_email_address_of_the_person_you_want_to_delegate_to: Введіть адресу електронної пошти особи, якій ви хочете делегувати
declined: Відхилено
decline_reason: Причина відхилення
provide_a_reason: Надайте причину
notify_the_sender_with_the_reason_you_declined: Повідомте відправника про причину, з якої ви відхилили
form_has_been_declined_on_html: 'Форму було відхилено о <span class="font-semibold">%{time}</span>'
document_has_been_delegated_on_html: 'Документ було делеговано о <span class="font-semibold">%{time}</span>'
name_declined_by_submitter: '"%{name}" відхилено %{submitter}'
name_declined_by_submitter_with_the_following_reason: '"%{name}" було відхилено %{submitter} з такою причиною:'
email: E-mail
@@ -6324,11 +6438,14 @@ cs:
hi_there: Ahoj,
download: Stáhnout
decline: Odmítnout
delegate: Delegovat
enter_the_email_address_of_the_person_you_want_to_delegate_to: Zadejte e-mailovou adresu osoby, na kterou chcete delegovat
declined: Odmítnuto
decline_reason: Důvod odmítnutí
provide_a_reason: Uveďte důvod
notify_the_sender_with_the_reason_you_declined: Informujte odesílatele o důvodu odmítnutí
form_has_been_declined_on_html: 'Formulář byl odmítnut dne <span class="font-semibold">%{time}</span>'
document_has_been_delegated_on_html: 'Dokument byl delegován dne <span class="font-semibold">%{time}</span>'
name_declined_by_submitter: '"%{name}" odmítnuto %{submitter}'
name_declined_by_submitter_with_the_following_reason: '"%{name}" bylo odmítnuto %{submitter} s následujícím důvodem:'
email: E-mail
@@ -6421,11 +6538,14 @@ he:
hi_there: שלום,
download: הורד
decline: דחייה
delegate: הואלה
enter_the_email_address_of_the_person_you_want_to_delegate_to: הזן את כתובת הדוא"ל של האדם שברצונך להאציל אליו
declined: דחוי
decline_reason: סיבת דחייה
provide_a_reason: אנא ספק סיבה
notify_the_sender_with_the_reason_you_declined: הודע לשולח על הסיבה לדחייה
form_has_been_declined_on_html: 'הטופס נדחה בתאריך <span class="font-semibold">%{time}</span>'
document_has_been_delegated_on_html: 'המסמך הועבר בתאריך <span class="font-semibold">%{time}</span>'
name_declined_by_submitter: '"%{name}" נדחה על ידי %{submitter}'
name_declined_by_submitter_with_the_following_reason: '"%{name}" נדחה על ידי %{submitter} עם הסיבה הבאה:'
email: דוא"ל
@@ -6654,11 +6774,14 @@ nl: &nl
downloading: Downloaden
download: Downloaden
decline: Weigeren
delegate: Delegeren
enter_the_email_address_of_the_person_you_want_to_delegate_to: Voer het e-mailadres in van de persoon aan wie u wilt delegeren
declined: Geweigerd
decline_reason: Reden van weigering
provide_a_reason: Geef een reden op
notify_the_sender_with_the_reason_you_declined: Informeer de verzender over de reden van uw weigering
form_has_been_declined_on_html: Formulier is geweigerd op <span class="font-semibold">%{time}</span>
document_has_been_delegated_on_html: Document is gedelegeerd op <span class="font-semibold">%{time}</span>
name_declined_by_submitter: '"%{name}" geweigerd door %{submitter}'
name_declined_by_submitter_with_the_following_reason: '"%{name}" is geweigerd door %{submitter} met de volgende reden:'
completed_successfully: Succesvol voltooid
@@ -6693,6 +6816,7 @@ nl: &nl
allow_typed_text_signatures: Handtekeningen met getypte tekst toestaan
allow_to_resubmit_completed_forms: Opnieuw indienen van voltooide formulieren toestaan
allow_to_decline_documents: Weigeren van documenten toestaan
allow_to_delegate_documents: Delegeren van documenten toestaan
remember_and_pre_fill_signatures: Handtekeningen onthouden en vooraf invullen
require_authentication_for_file_download_links: Authenticatie vereisen voor links naar bestandsdownloads
combine_completed_documents_and_audit_log: Voltooide documenten en auditlogboek combineren
@@ -7273,6 +7397,7 @@ nl: &nl
share_link: Deellink
enable_shared_link: Deellink inschakelen
share_link_is_currently_disabled: Deellink is momenteel uitgeschakeld
dynamic_template_with_variables_cannot_be_used_via_shared_link: Dynamisch sjabloon met variabelen kan niet worden gebruikt via een deellink
select_data_residency: Gegevensresidentie selecteren
account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: "%{account_name} heeft u uitgenodigd om documenten online moeiteloos in te vullen en te ondertekenen met een veilige, snelle en gebruiksvriendelijke digitale ondertekenoplossing."
review_or_download_completed_documents_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution: Bekijk of download voltooide documenten. Vul en onderteken documenten online moeiteloos met een veilige, snelle en gebruiksvriendelijke digitale ondertekenoplossing.
@@ -7326,6 +7451,7 @@ nl: &nl
allow_signers_to_create_signatures_by_typing_their_name_instead_of_drawing_or_uploading_one: Sta ondertekenaars toe handtekeningen te maken door hun naam te typen in plaats van te tekenen of te uploaden.
allow_signers_to_resubmit_forms_after_completion_useful_when_corrections_or_multiple_submissions_are_needed: Sta ondertekenaars toe formulieren na voltooiing opnieuw in te dienen; nuttig wanneer correcties of meerdere inzendingen nodig zijn.
allow_recipients_to_decline_signing_a_document_the_decline_reason_notification_will_be_sent_to_the_signature_requester: Sta ontvangers toe het ondertekenen van een document te weigeren. De melding met de reden van weigering wordt naar de aanvrager van de handtekening gestuurd.
allow_recipients_to_delegate_documents: Sta ontvangers toe documenten per e-mail te delegeren aan een andere persoon. Dit kan handig zijn wanneer de oorspronkelijke ontvanger het document moet doorgeven aan de gemachtigde ondertekenaar.
save_a_users_signature_and_automatically_pre_fill_it_in_future_signing_sessions: Sla de handtekening van een gebruiker op en vul deze automatisch vooraf in bij toekomstige ondertekeningssessies.
make_document_download_links_expire_after_40_minutes_to_prevent_long_term_access_and_enhance_security: Laat links voor het downloaden van documenten na 40 minuten verlopen om langdurige toegang te voorkomen en de beveiliging te verbeteren.
require_authentication_with_user_login_or_api_key_to_access_the_document_download_links: Authenticatie met gebruikerslogin of API-sleutel vereisen om toegang te krijgen tot de documentdownloadlinks.
@@ -7368,6 +7494,13 @@ nl: &nl
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} wacht op bevestiging. Volg de link in de e-mail om te bevestigen."
please_confirm_your_email_address_using_the_link_below_: 'Bevestig je e-mailadres via de onderstaande link:'
confirm_email: E-mailadres bevestigen
please_verify_your_email_address_to_continue: Bevestig je e-mailadres om door te gaan.
verification_code_sent_click_link_or_enter_code: Er is een verificatiecode naar je e-mail gestuurd. Je kunt op de link in de e-mail klikken of de code hieronder invoeren.
use_otp_code_to_verify_email_or_click_link_below_html: 'Gebruik code <b>%{code}</b> om je e-mail te verifiëren of klik op de link hieronder:'
verify_your_email: Je e-mail verifiëren
your_email_has_been_confirmed: Je e-mailadres is bevestigd.
invalid_or_expired_verification_code: Ongeldige of verlopen verificatiecode.
didnt_receive_an_email: Geen e-mail ontvangen?
unconfirmed: Onbevestigd
you_requested_to_reset_your_password_use_the_link_below_to_continue: Je hebt gevraagd je wachtwoord te resetten. Gebruik de onderstaande link om verder te gaan.
if_you_didnt_request_this_you_can_ignore_this_email: "Als je dit niet hebt aangevraagd, kun je deze e-mail negeren."
@@ -7392,6 +7525,9 @@ nl: &nl
connect_to_docuseal_mcp: Verbinden met DocuSeal MCP
add_the_following_to_your_mcp_client_configuration: Voeg het volgende toe aan uw MCP-clientconfiguratie
works_with_claude_desktop_cursor_windsurf_vs_code_and_any_mcp_compatible_client: Werkt met Claude Desktop, Cursor, Windsurf, VS Code en elke MCP-compatibele client.
your_email_address_has_been_changed: Je e-mailadres is gewijzigd
the_email_address_for_your_account_has_been_changed_to_new_email: Het e-mailadres van je account is gewijzigd naar %{new_email}.
if_you_did_not_make_this_change_please_contact_us_by_replying_to_this_email: Als je deze wijziging niet hebt aangebracht, neem dan contact met ons op door op deze e-mail te antwoorden.
devise:
confirmations:
confirmed: Je e-mailadres is succesvol bevestigd.
@@ -7426,6 +7562,8 @@ nl: &nl
start_verification_by_html: "<b>Identiteitsverificatie gestart</b> door %{submitter_name}"
complete_verification_by_html: "<b>Identiteitsverificatie voltooid</b> door %{submitter_name} met %{provider}"
api_complete_form_by_html: "<b>Inzending via API voltooid</b> door %{submitter_name}"
decline_form_by_html: "<b>Formulier geweigerd</b> door %{submitter_name}"
delegate_form_by_html: "<b>Gedelegeerd</b> van %{from} naar %{to}"
start_kba_by_html: "<b>KBA gestart</b> door %{submitter_name}"
complete_kba_by_html: "<b>KBA voltooid</b> door %{submitter_name}"
fail_kba_by_html: "<b>KBA mislukt</b> door %{submitter_name}"
@@ -7532,11 +7670,14 @@ ar:
hi_there: مرحبا,
download: تحميل
decline: رفض
delegate: تفويض
enter_the_email_address_of_the_person_you_want_to_delegate_to: أدخل عنوان البريد الإلكتروني للشخص الذي تريد التفويض إليه
declined: مرفوض
decline_reason: سبب الرفض
provide_a_reason: قدم سببًا
notify_the_sender_with_the_reason_you_declined: أخطر المرسل بسبب الرفض
form_has_been_declined_on_html: 'تم رفض النموذج في <span class="font-semibold">%{time}</span>'
document_has_been_delegated_on_html: 'تم تفويض المستند في <span class="font-semibold">%{time}</span>'
name_declined_by_submitter: '"%{name}" مرفوض بواسطة %{submitter}'
name_declined_by_submitter_with_the_following_reason: '"%{name}" تم رفضه بواسطة %{submitter} مع السبب التالي:'
email: البريد الإلكتروني
@@ -7629,11 +7770,14 @@ ko:
hi_there: 안녕하세요,
download: 다운로드
decline: 거절
delegate: 위임
enter_the_email_address_of_the_person_you_want_to_delegate_to: 위임할 사람의 이메일 주소를 입력하세요
declined: 거절됨
decline_reason: 거절 사유
provide_a_reason: 사유를 제공하세요
notify_the_sender_with_the_reason_you_declined: 거절한 이유로 발신자에게 알리기
form_has_been_declined_on_html: '양식이 <span class="font-semibold">%{time}</span>에 거절되었습니다.'
document_has_been_delegated_on_html: '문서가 <span class="font-semibold">%{time}</span>에 위임되었습니다.'
name_declined_by_submitter: '"%{name}"이(가) %{submitter}에 의해 거절되었습니다.'
name_declined_by_submitter_with_the_following_reason: '"%{name}"이(가) %{submitter}에 의해 다음과 같은 사유로 거절되었습니다:'
email: 이메일
@@ -7726,11 +7870,14 @@ ja:
hi_there: こんにちは
download: ダウンロード
decline: 辞退
delegate: 委任
enter_the_email_address_of_the_person_you_want_to_delegate_to: 委任したい人のメールアドレスを入力してください
declined: 辞退済み
decline_reason: 辞退の理由
provide_a_reason: 理由を入力してください
notify_the_sender_with_the_reason_you_declined: 辞退理由を送信者に通知してください
form_has_been_declined_on_html: '<span class="font-semibold">%{time}</span> にフォームが辞退されました'
document_has_been_delegated_on_html: '<span class="font-semibold">%{time}</span> にドキュメントが委任されました'
name_declined_by_submitter: '"%{name}" は %{submitter} により辞退されました'
name_declined_by_submitter_with_the_following_reason: '"%{name}" は %{submitter} により次の理由で辞退されました:'
email: メール
+2
View File
@@ -145,8 +145,10 @@ Rails.application.routes.draw do
resources :values, only: %i[index], controller: 'submit_form_values'
resources :download, only: %i[index], controller: 'submit_form_download'
resources :decline, only: %i[create], controller: 'submit_form_decline'
resources :delegate, only: %i[create], controller: 'submit_form_delegate'
resources :invite, only: %i[create], controller: 'submit_form_invite'
get :completed
get :delegated
end
resources :submit_form_draw_signature, only: %i[show], path: 'p', param: 'slug'
@@ -0,0 +1,17 @@
# frozen_string_literal: true
class CreateSubmitterVersions < ActiveRecord::Migration[8.1]
def change
create_table :submitter_versions do |t|
t.references :submitter, null: false, foreign_key: true
t.string :slug, null: false
t.string :email
t.string :name
t.string :phone
t.timestamps
end
add_index :submitter_versions, :slug
end
end
+16 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_02_26_193537) do
ActiveRecord::Schema[8.1].define(version: 2026_03_27_100000) do
# These are extensions that must be enabled in order to support this database
enable_extension "btree_gin"
enable_extension "plpgsql"
@@ -364,6 +364,18 @@ ActiveRecord::Schema[8.1].define(version: 2026_02_26_193537) do
t.index ["template_id"], name: "index_submissions_on_template_id"
end
create_table "submitter_versions", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "email"
t.string "name"
t.string "phone"
t.string "slug", null: false
t.bigint "submitter_id", null: false
t.datetime "updated_at", null: false
t.index ["slug"], name: "index_submitter_versions_on_slug"
t.index ["submitter_id"], name: "index_submitter_versions_on_submitter_id"
end
create_table "submitters", force: :cascade do |t|
t.bigint "submission_id", null: false
t.string "uuid", null: false
@@ -541,12 +553,14 @@ ActiveRecord::Schema[8.1].define(version: 2026_02_26_193537) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "document_generation_events", "submitters"
add_foreign_key "dynamic_document_versions", "dynamic_documents"
add_foreign_key "dynamic_documents", "templates"
add_foreign_key "email_events", "accounts"
add_foreign_key "email_messages", "accounts"
add_foreign_key "email_messages", "users", column: "author_id"
add_foreign_key "encrypted_configs", "accounts"
add_foreign_key "encrypted_user_configs", "users"
add_foreign_key "mcp_tokens", "users"
add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id"
add_foreign_key "oauth_access_grants", "users", column: "resource_owner_id"
add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id"
@@ -556,6 +570,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_02_26_193537) do
add_foreign_key "submission_events", "submitters"
add_foreign_key "submissions", "templates"
add_foreign_key "submissions", "users", column: "created_by_user_id"
add_foreign_key "submitter_versions", "submitters"
add_foreign_key "submitters", "submissions"
add_foreign_key "template_accesses", "templates"
add_foreign_key "template_folders", "accounts"
+1
View File
@@ -175,6 +175,7 @@ module Accounts
def can_send_emails?(_account, **_params)
return true if Docuseal.multitenant?
return true if Rails.env.development?
return true if ENV['SMTP_ADDRESS'].present?
EncryptedConfig.exists?(key: EncryptedConfig::EMAIL_SMTP_KEY)
+1 -1
View File
@@ -20,7 +20,7 @@ module EmailVerificationCodes
def build_totp_secret(value)
ROTP::Base32.encode(
Digest::SHA1.digest(
[Rails.application.secret_key_base, value].join(':')
[Rails.application.secret_key_base, 'form_email_2fa', value].join(':')
)
)
end
+119
View File
@@ -0,0 +1,119 @@
# frozen_string_literal: true
module HtmlToPlainText
module_function
def call(html, line_length = 65)
return '' if html.nil? || html.strip.empty?
cleaned = html.gsub(%r{<!-- start text/html -->.*?<!-- end text/html -->}m, '')
doc = Nokogiri::HTML.fragment(cleaned)
doc.css('script').each(&:remove)
result = process_nodes(doc, line_length)
result.gsub!(/\r\n?/, "\n")
result.gsub!(/[ \t]*\u00A0+[ \t]*/, ' ')
result.gsub!(/\n[ \t]+/, "\n")
result.gsub!(/[ \t]+\n/, "\n")
result.gsub!(/\n{3,}/, "\n\n")
result = word_wrap(result, line_length)
result.gsub!(/\(([ \n])(http[^)]+)([\n ])\)/) do
"#{"\n" if ::Regexp.last_match(1) == "\n"}( #{::Regexp.last_match(2)} )#{"\n" if ::Regexp.last_match(3) == "\n"}"
end
result.strip
end
def process_nodes(node, line_length)
result = +''
node.children.each do |child|
case child
when Nokogiri::XML::Text
result << child.text
when Nokogiri::XML::Comment
next
when Nokogiri::XML::Element
result << process_element(child, line_length)
end
end
result
end
def process_element(node, line_length)
case node.name
when 'br'
"\n"
when 'p', 'div'
inner = process_nodes(node, line_length)
inner.strip.empty? ? '' : "#{inner}\n\n"
when 'img'
node['alt'] || ''
when 'a'
process_link(node, line_length)
when /\Ah([1-6])\z/
process_heading(node, ::Regexp.last_match(1).to_i, line_length)
when 'li'
inner = process_nodes(node, line_length)
"* #{inner.strip}\n"
else
process_nodes(node, line_length)
end
end
def process_link(node, line_length)
text = process_nodes(node, line_length).strip
return '' if text.empty?
href = node['href']
href = href.sub(/\Amailto:/i, '') if href
if href.nil? || text.casecmp(href.strip) == 0
text
else
"#{text} ( #{href.strip} )"
end
end
def process_heading(node, level, line_length)
text = +''
node.children.each do |child|
text << if child.name == 'br'
"\n"
else
child.text
end
end
text.strip!
hlength = text.each_line.map { |l| l.strip.length }.max || 0
hlength = line_length if hlength > line_length
decorated = case level
when 1
"#{'*' * hlength}\n#{text}\n#{'*' * hlength}"
when 2
"#{'-' * hlength}\n#{text}\n#{'-' * hlength}"
else
"#{text}\n#{'-' * hlength}"
end
"\n\n#{decorated}\n\n"
end
def word_wrap(txt, line_length)
txt.split("\n").map do |line|
if line.length > line_length
line.gsub(/(.{1,#{line_length}})(\s+|$)/, "\\1\n").strip
else
line
end
end.join("\n")
end
end
+65
View File
@@ -0,0 +1,65 @@
# frozen_string_literal: true
module HtmlToPlainTextInterceptor
module_function
def delivering_email(message)
process(message)
end
def previewing_email(message)
process(message)
end
def process(message)
return message unless html_part(message)
return message if message.text_part
add_text_part(message)
message
end
def add_text_part(message)
html = html_part(message).decoded
text = HtmlToPlainText.call(html)
text_part = Mail::Part.new do
content_type 'text/plain; charset=UTF-8'
body text
end
if pure_html_message?(message)
message.body = nil
message.content_type = 'multipart/alternative'
message.add_part(text_part)
message.add_part(Mail::Part.new do
content_type 'text/html; charset=UTF-8'
body html
end)
else
alternative = Mail::Part.new(content_type: 'multipart/alternative')
alternative.add_part(text_part)
alternative.add_part(message.html_part)
replace_part(message.parts, message.html_part, alternative)
end
end
def pure_html_message?(message)
message.content_type.to_s.include?('text/html')
end
def html_part(message)
pure_html_message?(message) ? message : message.html_part
end
def replace_part(parts, old_part, new_part)
if (index = parts.index(old_part))
parts[index] = new_part
else
parts.each do |part|
replace_part(part.parts, old_part, new_part) if part.respond_to?(:parts)
end
end
end
end
+3
View File
@@ -2,6 +2,7 @@
module LoadIco
BI_RGB = 0
PNG_SIGNATURE = "\x89PNG".b
module_function
@@ -42,6 +43,8 @@ module LoadIco
raise ArgumentError, 'Unable to load' unless image_data_bytes && image_data_bytes.bytesize == best_entry[:size]
return Vips::Image.new_from_buffer(image_data_bytes, '') if image_data_bytes.start_with?(PNG_SIGNATURE)
image = load_image_entry(image_data_bytes, best_entry[:width], best_entry[:height])
raise ArgumentError, 'Unable to load' unless image
+1 -13
View File
@@ -3,8 +3,6 @@
module Submissions
DEFAULT_SUBMITTERS_ORDER = 'random'
PRELOAD_ALL_PAGES_AMOUNT = 200
module_function
def search(current_user, submissions, keyword, search_values: false, search_template: false)
@@ -81,19 +79,9 @@ module Submissions
def preload_with_pages(submission)
ActiveRecord::Associations::Preloader.new(
records: submission.schema_documents,
associations: [:blob]
associations: [:blob, { preview_images_attachments: :blob }]
).call
total_pages =
submission.schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i }
if total_pages < PRELOAD_ALL_PAGES_AMOUNT
ActiveRecord::Associations::Preloader.new(
records: submission.schema_documents,
associations: [{ preview_images_attachments: :blob }]
).call
end
submission
end
+28 -24
View File
@@ -246,33 +246,25 @@ module Submissions
next if submitter.blank?
completed_event =
submission.submission_events.find { |e| e.submitter_id == submitter.id && e.complete_form? } ||
SubmissionEvent.new
submission_events = submission.submission_events.select { |e| e.submitter_id == submitter.id }
click_email_event =
submission.submission_events.find { |e| e.submitter_id == submitter.id && e.click_email? }
delegated_event = submission_events.select(&:delegate_form?).max_by(&:event_timestamp)
verify_email_event =
submission.submission_events.find { |e| e.submitter_id == submitter.id && e.email_verified? }
if delegated_event
submission_events = submission_events.select { |e| e.event_timestamp > delegated_event.event_timestamp }
end
is_phone_verified =
submission.template_fields.any? do |e|
e['type'] == 'phone' && e['submitter_uuid'] == submitter.uuid && submitter.values[e['uuid']].present?
end
completed_event = submission_events.find(&:complete_form?) || SubmissionEvent.new
verify_phone_event =
submission.submission_events.find { |e| e.submitter_id == submitter.id && e.phone_verified? }
click_email_event = submission_events.find(&:click_email?)
is_id_verified =
submission.template_fields.any? do |e|
e['type'] == 'verification' && e['submitter_uuid'] == submitter.uuid && submitter.values[e['uuid']].present?
end
verify_email_event = submission_events.find(&:email_verified?)
is_kba_passed =
submission.template_fields.any? do |e|
e['type'] == 'kba' && e['submitter_uuid'] == submitter.uuid && submitter.values[e['uuid']].present?
end
verify_phone_event = submission_events.find(&:phone_verified?)
is_id_verified = submission_events.any?(&:complete_verification?)
is_kba_passed = submission_events.any?(&:complete_kba?)
info_rows = [
[
@@ -291,7 +283,7 @@ module Submissions
submitter.email && (click_email_event || verify_email_event) && {
text: "#{I18n.t('email_verification')}: #{I18n.t('verified')}\n"
},
submitter.phone && (is_phone_verified || verify_phone_event) && {
submitter.phone && verify_phone_event && {
text: "#{I18n.t('phone_verification')}: #{I18n.t('verified')}\n"
},
is_id_verified && {
@@ -446,15 +438,23 @@ module Submissions
composer.text(I18n.t('event_log'), font_size: 12, padding: [10, 0, 20, 0])
submitter_versions_index = submission.submitters.preload(:submitter_versions).each_with_object({}) do |s, h|
h[s.id] = s.submitter_versions.to_a.sort_by(&:created_at)
end
events_data = submission.submission_events.sort_by(&:event_timestamp).filter_map do |event|
next if event.event_type.in?(%w[bounce_email complaint_email])
submitter = submission.submitters.find { |e| e.id == event.submitter_id }
versions = submitter_versions_index[submitter.id] || []
active_version = versions.find { |v| v.created_at > event.event_timestamp }
submitter_name =
if event.event_type.include?('sms') || event.event_type.include?('phone')
event.data['phone'] || submitter.phone
event.data['phone'] || active_version&.phone || submitter.phone
else
submitter.name || submitter.email || submitter.phone
active_version&.name || active_version&.email || active_version&.phone ||
submitter.name || submitter.email || submitter.phone
end
text =
@@ -473,6 +473,10 @@ module Submissions
I18n.t("submission_event_names.#{event.event_type}_to_html", submitter_name:),
"<b>#{I18n.t(:from)}</b> #{submission.created_by_user.full_name} #{submission.created_by_user.email}"
].join("\n")
elsif event.event_type == 'delegate_form'
from = event.data['old_email'].presence ||
versions.reverse.find { |v| v.created_at <= event.event_timestamp }&.then { |v| v.name || v.phone }
I18n.t('submission_event_names.delegate_form_by_html', from:, to: event.data['email'])
elsif event.event_type.include?('send_')
I18n.t("submission_event_names.#{event.event_type}_to_html", submitter_name:)
else
@@ -37,7 +37,7 @@ module Submissions
bold_italic: FONT_BOLD_NAME
}.freeze
SIGN_REASON = 'Signed by %<name>s with DocuSeal.com'
SIGN_REASON = 'Signed with DocuSeal.com'
RTL_REGEXP = TextUtils::RTL_REGEXP
@@ -236,9 +236,10 @@ module Submissions
page[:Annots] ||= []
page[:Annots] = page[:Annots].try(:reject) do |e|
next if e.is_a?(Integer) || e.is_a?(Symbol)
next if e.is_a?(Integer) || e.is_a?(Symbol) || e.is_a?(HexaPDF::PDFArray)
e.present? && e[:A] && e[:A][:URI].to_s.starts_with?('file:///docuseal_field')
e.present? && e[:A] && !e[:A].is_a?(HexaPDF::PDFArray) &&
e[:A][:URI].to_s.starts_with?('file:///docuseal_field')
end || page[:Annots]
width = page.box.width
-1
View File
@@ -2,7 +2,6 @@
module Submitters
TRUE_VALUES = ['1', 'true', true].freeze
PRELOAD_ALL_PAGES_AMOUNT = 200
FIELD_NAME_WEIGHTS = {
'email' => 'A',
+4 -2
View File
@@ -8,6 +8,7 @@ module Submitters
AccountConfig::FORM_PREFILL_SIGNATURE_KEY,
AccountConfig::WITH_SIGNATURE_ID,
AccountConfig::ALLOW_TO_DECLINE_KEY,
AccountConfig::ALLOW_TO_DELEGATE_KEY,
AccountConfig::ENFORCE_SIGNING_ORDER_KEY,
AccountConfig::REQUIRE_SIGNING_REASON_KEY,
AccountConfig::REUSE_SIGNATURE_KEY,
@@ -27,10 +28,11 @@ module Submitters
completed_button = find_safe_value(configs, AccountConfig::FORM_COMPLETED_BUTTON_KEY) || {}
completed_message = find_safe_value(configs, AccountConfig::FORM_COMPLETED_MESSAGE_KEY) || {}
with_typed_signature = find_safe_value(configs, AccountConfig::ALLOW_TYPED_SIGNATURE) != false
with_confetti = find_safe_value(configs, AccountConfig::FORM_WITH_CONFETTI_KEY) != false
with_confetti = find_safe_value(configs, AccountConfig::FORM_WITH_CONFETTI_KEY) == true
prefill_signature = find_safe_value(configs, AccountConfig::FORM_PREFILL_SIGNATURE_KEY) != false
reuse_signature = find_safe_value(configs, AccountConfig::REUSE_SIGNATURE_KEY) != false
with_decline = find_safe_value(configs, AccountConfig::ALLOW_TO_DECLINE_KEY) != false
with_delegate = find_safe_value(configs, AccountConfig::ALLOW_TO_DELEGATE_KEY) == true
with_partial_download = find_safe_value(configs, AccountConfig::ALLOW_TO_PARTIAL_DOWNLOAD_KEY) != false
with_signature_id = find_safe_value(configs, AccountConfig::WITH_SIGNATURE_ID) == true
require_signing_reason = find_safe_value(configs, AccountConfig::REQUIRE_SIGNING_REASON_KEY) == true
@@ -42,7 +44,7 @@ module Submitters
policy_links = find_safe_value(configs, AccountConfig::POLICY_LINKS_KEY)
attrs = { completed_button:, with_typed_signature:, with_confetti:,
reuse_signature:, with_decline:, with_partial_download:,
reuse_signature:, with_decline:, with_delegate:, with_partial_download:,
policy_links:, enforce_signing_order:, completed_message:,
require_signing_reason:, prefill_signature:, with_submitter_timezone:,
with_signature_id_reason:, with_signature_id:, with_field_labels:, with_timestamp_seconds: }
+1 -1
View File
@@ -10,7 +10,7 @@ module Templates
pdf.pages.flat_map.with_index do |page, index|
(page[:Annots] || []).filter_map do |annot|
next if annot.blank?
next if annot.is_a?(Integer) || annot.is_a?(Symbol)
next if annot.is_a?(Integer) || annot.is_a?(Symbol) || annot.is_a?(HexaPDF::PDFArray)
next if annot[:A].blank? || annot[:A][:URI].blank?
next unless annot[:Subtype] == :Link
next if !annot[:A][:URI].starts_with?('https://') && !annot[:A][:URI].starts_with?('http://')
+2 -1
View File
@@ -11,7 +11,8 @@ module WebhookUrls
'submission.expired' => SendSubmissionExpiredWebhookRequestJob,
'submission.archived' => SendSubmissionArchivedWebhookRequestJob,
'template.created' => SendTemplateCreatedWebhookRequestJob,
'template.updated' => SendTemplateUpdatedWebhookRequestJob
'template.updated' => SendTemplateUpdatedWebhookRequestJob,
'template.archived' => SendTemplateArchivedWebhookRequestJob
}.freeze
EVENT_TYPE_ID_KEYS = {
@@ -0,0 +1,100 @@
# frozen_string_literal: true
RSpec.describe SendTemplateArchivedWebhookRequestJob do
let(:account) { create(:account) }
let(:user) { create(:user, account:) }
let(:template) { create(:template, account:, author: user) }
let(:webhook_url) { create(:webhook_url, account:, events: ['template.archived']) }
before do
create(:encrypted_config, key: EncryptedConfig::ESIGN_CERTS_KEY,
value: GenerateCertificate.call.transform_values(&:to_pem))
end
describe '#perform' do
around do |example|
freeze_time { example.run }
end
before do
stub_request(:post, webhook_url.url).to_return(status: 200)
end
it 'sends a webhook request' do
described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id,
'event_uuid' => SecureRandom.uuid)
expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: {
'event_type' => 'template.archived',
'timestamp' => /.*/,
'data' => template.reload.as_json(only: %i[id archived_at])
},
headers: {
'Content-Type' => 'application/json',
'User-Agent' => 'DocuSeal.com Webhook'
}
).once
end
it 'sends a webhook request with the secret' do
webhook_url.update(secret: { 'X-Secret-Header' => 'secret_value' })
described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id,
'event_uuid' => SecureRandom.uuid)
expect(WebMock).to have_requested(:post, webhook_url.url).with(
body: {
'event_type' => 'template.archived',
'timestamp' => /.*/,
'data' => template.reload.as_json(only: %i[id archived_at])
},
headers: {
'Content-Type' => 'application/json',
'User-Agent' => 'DocuSeal.com Webhook',
'X-Secret-Header' => 'secret_value'
}
).once
end
it "doesn't send a webhook request if the event is not in the webhook's events" do
webhook_url.update!(events: ['template.updated'])
described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id,
'event_uuid' => SecureRandom.uuid)
expect(WebMock).not_to have_requested(:post, webhook_url.url)
end
it 'sends again if the response status is 400 or higher' do
stub_request(:post, webhook_url.url).to_return(status: 401)
event_uuid = SecureRandom.uuid
expect do
described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id,
'event_uuid' => event_uuid)
end.to change(described_class.jobs, :size).by(1)
expect(WebMock).to have_requested(:post, webhook_url.url).once
args = described_class.jobs.last['args'].first
expect(args['attempt']).to eq(1)
expect(args['last_status']).to eq(401)
expect(args['event_uuid']).to eq(event_uuid)
expect(args['webhook_url_id']).to eq(webhook_url.id)
expect(args['template_id']).to eq(template.id)
end
it "doesn't send again if the max attempts is reached" do
stub_request(:post, webhook_url.url).to_return(status: 401)
expect do
described_class.new.perform('template_id' => template.id, 'webhook_url_id' => webhook_url.id,
'event_uuid' => SecureRandom.uuid, 'attempt' => 11)
end.not_to change(described_class.jobs, :size)
expect(WebMock).to have_requested(:post, webhook_url.url).once
end
end
end
+2 -2
View File
@@ -23,7 +23,7 @@ RSpec.describe 'Sign In' do
fill_in 'Password', with: 'wrong_password'
click_button 'Sign In'
expect(page).to have_content('Invalid Email or password')
expect(page).to have_content('Invalid email or password')
expect(page).not_to have_content('Document Templates')
end
end
@@ -51,7 +51,7 @@ RSpec.describe 'Sign In' do
fill_in 'Two-Factor Code from Authenticator App', with: '123456'
click_button 'Sign In'
expect(page).to have_content('Invalid Email or password')
expect(page).to have_content('Invalid email or password')
expect(page).not_to have_content('Document Templates')
end
end
+44
View File
@@ -1159,6 +1159,50 @@ RSpec.describe 'Signing Form' do
end
end
context 'when decline is enabled' do
let(:template) { create(:template, account:, author:, only_field_types: %w[text]) }
let(:submission) { create(:submission, template:) }
let(:submitter) { create(:submitter, submission:, uuid: template.submitters.first['uuid'], account:) }
it 'declines the form and shows the declined page' do
visit submit_form_path(slug: submitter.slug)
find('#decline_button').click
fill_in 'reason', with: 'I do not agree with the terms'
click_button 'Decline'
expect(page).to have_content('Form has been declined')
submitter.reload
expect(submitter.declined_at).to be_present
end
end
context 'when delegate is enabled' do
let(:template) { create(:template, account:, author:, only_field_types: %w[text]) }
let(:submission) { create(:submission, template:) }
let(:submitter) { create(:submitter, submission:, uuid: template.submitters.first['uuid'], account:) }
before do
create(:account_config, account:, key: AccountConfig::ALLOW_TO_DELEGATE_KEY, value: true)
end
it 'delegates the form to another email and shows the delegated page' do
visit submit_form_path(slug: submitter.slug)
find('#delegate_button').click
fill_in 'email', with: 'delegate@example.com'
click_button 'Delegate'
expect(page).to have_content('Document has been delegated')
submitter.reload
expect(submitter.email).to eq('delegate@example.com')
end
end
context 'when the 2FA email verification is enabled', sidekiq: :inline do
let(:template) { create(:template, account:, author:, only_field_types: %w[text]) }
let(:submission) { create(:submission, template:) }