Compare commits

..
132 Commits
Author SHA1 Message Date
Pete Matsyburka 3034c00bb3 use sidekiq 2024-06-24 21:10:58 +03:00
Pete Matsyburka 9539c476c0 remove legacy tracking param 2024-06-24 14:33:33 +03:00
Pete Matsyburka 4552c72860 add index on external id 2024-06-24 13:27:14 +03:00
Pete Matsyburka 85f8a92721 optimize query 2024-06-23 12:20:01 +03:00
Pete Matsyburka 049b9c78c2 add retries 2024-06-23 09:16:34 +03:00
Pete Matsyburka acf06084bf simplify job params 2024-06-22 10:50:07 +03:00
Pete Matsyburka 61da477733 reorder 2024-06-22 10:50:07 +03:00
Pete Matsyburka fdeec548a3 adjust message 2024-06-22 10:50:07 +03:00
Pete Matsyburka ff0d486831 fix clone template folder 2024-06-22 10:50:07 +03:00
Pete Matsyburka d5bf54ee26 adjust tempalte preferences 2024-06-22 10:50:07 +03:00
Pete Matsyburka 7616774613 use latest alpine 2024-06-21 00:46:11 +03:00
Pete Matsyburka 27c4dc7eca font size in the UI 2024-06-20 22:15:45 +03:00
Pete Matsyburka 4ae834d6f7 skip optional signature 2024-06-20 17:52:40 +03:00
Pete Matsyburka b71a66a082 add font size preference 2024-06-20 16:14:18 +03:00
Pete Matsyburka 80cc38542c fix email attachments 2024-06-18 20:09:58 +03:00
Pete Matsyburka 185aecec42 increase signature size 2024-06-18 17:18:23 +03:00
Pete Matsyburka 8c092eba78 allow to permanently delete submissions 2024-06-18 16:54:15 +03:00
Pete Matsyburka 781ccc701d optimize large files upload 2024-06-16 11:27:24 +03:00
Pete Matsyburka 009e05584a remove submitter signature assoc 2024-06-15 20:14:09 +03:00
Pete Matsyburka f9d52e56a2 prefill signature 2024-06-15 16:23:27 +03:00
Pete Matsyburka 9e66f8412c add extra user fields 2024-06-14 22:59:11 +03:00
Pete Matsyburka 181e42c4b3 refactor 2024-06-14 16:55:20 +03:00
Pete Matsyburka 15e60fc5e7 add emails copy toggle 2024-06-14 12:19:16 +03:00
Pete Matsyburka ee357e58ac fix prefill signature 2024-06-14 10:00:25 +03:00
Pete Matsyburka 0d9a709fa3 disable prefill signature 2024-06-14 09:09:56 +03:00
Pete Matsyburka 7eae09bdb0 assign default values on api complete 2024-06-13 23:45:53 +03:00
Pete Matsyburka e8e32e5d1f add log 2024-06-13 18:05:11 +03:00
Pete Matsyburka a3d54db4c6 adjust list import 2024-06-13 15:20:34 +03:00
Pete Matsyburka 9686ae6250 add field validation 2024-06-13 14:32:49 +03:00
Pete Matsyburka 044195f07b blur on field move 2024-06-13 10:54:10 +03:00
Pete Matsyburka 35f159266a paste raw text 2024-06-13 10:26:36 +03:00
Pete Matsyburka 3b717ae3de double click to edit default value 2024-06-12 22:46:50 +03:00
Pete Matsyburka 2389c3fc40 prefill signature 2024-06-11 19:42:26 +03:00
Pete Matsyburka 92da790e77 fix email tracking params 2024-06-11 16:59:15 +03:00
Pete Matsyburka 14418b93b1 full error log in prod 2024-06-10 17:17:45 +03:00
Pete Matsyburka 1289aff493 parse date using format 2024-06-10 12:08:56 +03:00
Pete Matsyburka 7568f563cb adjust payment step 2024-06-09 23:56:43 +03:00
Pete Matsyburka 5ae56bd50c add field preferences param 2024-06-09 20:25:19 +03:00
Pete Matsyburka c2ded81959 update hmtl lang attr 2024-06-09 16:39:41 +03:00
Pete Matsyburka f2c307ef63 adjust conditions modal 2024-06-09 16:37:28 +03:00
Pete Matsyburka af787887d8 i18n sign in 2024-06-08 23:02:29 +03:00
Pete Matsyburka 54272d83eb add integrations placeholder 2024-06-08 21:30:04 +03:00
Pete Matsyburka 3070fc2074 clear condition values 2024-06-08 12:32:41 +03:00
Pete Matsyburka 6a31d2c722 alert message on missing submitter fields 2024-06-08 10:01:14 +03:00
Pete Matsyburka f26bfe9acc remove button from archived 2024-06-07 21:54:37 +03:00
Pete Matsyburka a729fd6234 fix webhook url on testing 2024-06-06 22:03:15 +03:00
Pete Matsyburka c2051c156e adjust signature step 2024-06-06 17:14:05 +03:00
Pete Matsyburka 3852870c09 add payment field condition 2024-06-05 21:09:08 +03:00
Pete Matsyburka a58253a6a1 add devise emails events 2024-06-05 09:29:00 +03:00
Pete Matsyburka 30c98a970d fix audit trail 2024-06-04 19:34:09 +03:00
Pete Matsyburka 8f2edef09e wrap long email is stamp 2024-06-04 19:31:55 +03:00
Pete Matsyburka 9e465ea47e use field title in audit 2024-06-04 18:33:24 +03:00
Pete Matsyburka cdda55d528 add email events 2024-06-04 17:41:07 +03:00
Pete Matsyburka db302858c4 add template folder name to webhook 2024-06-03 18:13:13 +03:00
Pete Matsyburka b81cc83027 fix signature toggle 2024-06-03 12:31:40 +03:00
Pete Matsyburka 766d653349 fix build 2024-06-03 12:01:09 +03:00
Pete Matsyburka 98c04eac15 normalize client ip 2024-06-03 08:57:14 +03:00
Pete Matsyburka c35dfea154 add log 2024-06-02 21:24:21 +03:00
Pete Matsyburka 37a9a86a1d fix naming 2024-06-02 18:36:59 +03:00
Pete Matsyburka e9a759055b allow to upload user initials 2024-06-02 17:06:09 +03:00
Pete Matsyburka f6c061a57b allow to draw signature on mobile 2024-06-02 16:02:18 +03:00
Pete Matsyburka 91f3d8ed7e adjust meta tags 2024-05-31 22:35:59 +03:00
Pete Matsyburka 3b18502d3d remove onsubmit html event 2024-05-31 14:44:36 +03:00
Pete Matsyburka df8e79683a eager load in test 2024-05-31 13:19:33 +03:00
Alex Turchyn 7080e8ea33 fix js errors 2024-05-31 13:08:02 +03:00
Pete Matsyburka c56f8cb38f fix autocomplete 2024-05-30 23:38:38 +03:00
Pete Matsyburka 1ceac968b0 fix move existing user 2024-05-30 22:18:32 +03:00
Pete Matsyburka 938ee99cfc show full error trace in dev 2024-05-29 16:14:07 +03:00
Pete Matsyburka 2367040e45 add slug to submissions response 2024-05-28 00:25:59 +03:00
Pete Matsyburka df19aabfba adjust users table 2024-05-27 20:36:52 +03:00
Pete Matsyburka f7b5bcd52f add webhooks queue 2024-05-27 14:40:12 +03:00
Pete Matsyburka a249b0a678 fix pdf form flatten fields 2024-05-27 00:47:14 +03:00
Pete Matsyburka 99bc46b40f fix erblint 2024-05-26 22:51:16 +03:00
Pete Matsyburka c1e6f0970d add file types 2024-05-26 22:38:24 +03:00
Pete Matsyburka e8c2316bb2 add users count 2024-05-26 20:51:02 +03:00
Pete Matsyburka 9866e5468c add download combined pdf button 2024-05-26 11:43:47 +03:00
Pete Matsyburka fc60df7974 fix erblint 2024-05-25 16:59:22 +03:00
Pete Matsyburka 08784d28c7 adjust log ip 2024-05-25 16:25:59 +03:00
Pete Matsyburka 0440ea652f increase attachment url ttl 2024-05-25 16:25:56 +03:00
Pete Matsyburka e466562391 use rollbar 2024-05-24 13:35:28 +03:00
Pete Matsyburka b2c8667c53 safe register element 2024-05-23 14:02:48 +03:00
Pete Matsyburka d6893079f7 remove breadcrumbs 2024-05-23 13:39:16 +03:00
Pete Matsyburka ac1d973605 fix loop 2024-05-23 12:45:01 +03:00
Pete Matsyburka 1405fefc85 add sentry 2024-05-23 12:25:25 +03:00
Pete Matsyburka 2a75055fb6 fix pdf parsing 2024-05-22 21:37:23 +03:00
Pete Matsyburka f84fdd51de fix builder areas 2024-05-22 15:39:46 +03:00
Alex Turchyn b38073126e fix dropdown styles 2024-05-22 15:20:04 +03:00
Alex Turchyn c6e681195d upgrade daisyUI to v3.9.4 2024-05-22 13:39:43 +03:00
Pete Matsyburka 53d924be0b fix build 2024-05-20 20:07:17 +03:00
Pete Matsyburka d6cf027d5d fix erblint 2024-05-19 19:44:38 +03:00
Alex Turchyn ec332bab88 flatten pdf for preview images 2024-05-19 19:25:53 +03:00
Alex Turchyn 8b3b90a7e2 replace emojis 2024-05-19 19:12:17 +03:00
Pete Matsyburka 9047e3bed3 add dry run prop 2024-05-18 22:54:26 +03:00
Pete Matsyburka 72b7391245 add documents copy email to template preferences 2024-05-18 22:03:20 +03:00
Pete Matsyburka 2df05f0327 adjust initials height 2024-05-18 17:51:22 +03:00
Pete Matsyburka 95f3eb0e5d display filters 2024-05-18 13:58:21 +03:00
Pete Matsyburka ce8d964ce6 fix ensure result generated 2024-05-17 16:07:08 +03:00
Pete Matsyburka f0d5fc4e2d add submission created webhook event 2024-05-17 13:28:10 +03:00
Pete Matsyburka b4b17d847e add view archived to template page 2024-05-17 12:08:46 +03:00
Alex Turchyn fd537e7ac0 upgrade rack 3 2024-05-16 23:26:30 +03:00
Pete Matsyburka 8c271104e1 single digital signature by default 2024-05-16 23:18:48 +03:00
Pete Matsyburka 51edd60d96 update gem 2024-05-16 15:16:59 +03:00
Pete Matsyburka a265ca1716 remove pdf processor 2024-05-16 13:37:23 +03:00
Pete Matsyburka a773f63a65 remove pdf reader 2024-05-16 11:56:41 +03:00
Pete Matsyburka 576c8a2e24 fix ensure result generated 2024-05-15 23:49:36 +03:00
Pete Matsyburka 5829de9ee4 adjust required fields alert 2024-05-15 11:42:51 +03:00
Pete Matsyburka b202941b79 fix tsa settings 2024-05-15 00:21:52 +03:00
Pete Matsyburka 6f6fb74988 adjust plans settings nav 2024-05-14 23:32:11 +03:00
Pete Matsyburka 0137bdbe4c optimize tsa settings controller 2024-05-12 19:32:09 +03:00
Pete Matsyburka 55d9511c01 add disclosure message 2024-05-12 12:35:20 +03:00
Pete Matsyburka 929d29576d add template webhooks 2024-05-12 11:37:33 +03:00
Pete Matsyburka 6f7128a51c add number format 2024-05-12 11:12:07 +03:00
Pete Matsyburka fc5c4ff2fc adjust reminder options 2024-05-11 16:24:13 +03:00
Pete Matsyburka a81595db2b allow legacy algos 2024-05-10 22:55:21 +03:00
Pete Matsyburka a5615d500d fix missing field steps 2024-05-10 22:24:44 +03:00
Pete Matsyburka da9266befc fix time format 2024-05-10 09:24:09 +03:00
Pete Matsyburka 5b3cffeb73 fix submission archived webhook 2024-05-08 09:53:37 +03:00
Pete Matsyburka 8f7b8a5fdf fix signature size 2024-05-08 09:38:16 +03:00
Pete Matsyburka 6d93116993 fix checkbox add on missclick 2024-05-05 18:18:07 +03:00
Pete Matsyburka a0f4916d3b add field settings dropdown to area 2024-05-05 16:58:19 +03:00
Pete Matsyburka ace897eaf9 update SECURITY.md 2024-05-04 23:15:04 +03:00
Pete Matsyburka b8667c5408 fix audit log on dev 2024-05-04 11:09:48 +03:00
Pete Matsyburka d87d66632e add field-area-active class 2024-05-02 23:33:44 +03:00
Alex Turchyn 3015c2d156 convert BMP images to PNG before uploading in the signing form 2024-05-02 22:10:23 +03:00
Pete Matsyburka 52b9e9ee8e fix underscore field name 2024-05-02 16:33:41 +03:00
Pete Matsyburka 5bfad0271c fix audit log font 2024-05-02 13:50:43 +03:00
Pete Matsyburka 58797891c7 fix trusted cert env 2024-05-02 13:39:47 +03:00
Pete Matsyburka 897750ba1e fix build 2024-05-02 12:43:13 +03:00
Pete Matsyburka 99341e773f refactor sign params 2024-05-01 23:40:09 +03:00
Pete Matsyburka 71319c1548 fix pdf generation 2024-05-01 11:51:40 +03:00
Pete Matsyburka c0a42f6bfc do now show default fields on preview 2024-04-29 15:42:11 +03:00
Pete Matsyburka d5abb46112 fix phone value assignment 2024-04-28 22:09:02 +03:00
192 changed files with 4551 additions and 1214 deletions

No files matched your search

+1
View File
@@ -36,3 +36,4 @@ yarn-debug.log*
/attachments /attachments
/docuseal /docuseal
/ee /ee
dump.rdb
+3
View File
@@ -76,5 +76,8 @@ Rails/SkipsModelValidations:
Rails/ApplicationController: Rails/ApplicationController:
Enabled: false Enabled: false
Rails/Output:
Enabled: false
Capybara/ClickLinkOrButtonStyle: Capybara/ClickLinkOrButtonStyle:
Enabled: false Enabled: false
+20 -6
View File
@@ -1,12 +1,12 @@
FROM ruby:3.3.1-alpine3.18 as fonts FROM ruby:3.3.1-alpine as fonts
WORKDIR /fonts WORKDIR /fonts
RUN apk --no-cache add fontforge wget && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && wget https://github.com/impallari/DancingScript/raw/master/OFL.txt RUN apk --no-cache add fontforge wget && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && wget https://github.com/impallari/DancingScript/raw/master/fonts/v2031/DancingScript-Regular.otf && wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && wget https://github.com/impallari/DancingScript/raw/master/OFL.txt
RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.ttf")' RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.ttf")'
FROM ruby:3.3.1-alpine3.18 as webpack FROM ruby:3.3.1-alpine as webpack
ENV RAILS_ENV=production ENV RAILS_ENV=production
ENV NODE_ENV=production ENV NODE_ENV=production
@@ -32,18 +32,32 @@ COPY ./app/views ./app/views
RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker
FROM ruby:3.3.1-alpine3.18 as app FROM ruby:3.3.1-alpine as app
ENV RAILS_ENV=production ENV RAILS_ENV=production
ENV BUNDLE_WITHOUT="development:test" ENV BUNDLE_WITHOUT="development:test"
ENV LD_PRELOAD=/lib/libgcompat.so.0
ENV OPENSSL_CONF=/app/openssl_legacy.cnf
WORKDIR /app WORKDIR /app
RUN apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils vips-heif libc6-compat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf RUN apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils redis vips-heif gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
RUN echo $'.include = /etc/ssl/openssl.cnf\n\
\n\
[provider_sect]\n\
default = default_sect\n\
legacy = legacy_sect\n\
\n\
[default_sect]\n\
activate = 1\n\
\n\
[legacy_sect]\n\
activate = 1' >> /app/openssl_legacy.cnf
COPY ./Gemfile ./Gemfile.lock ./ COPY ./Gemfile ./Gemfile.lock ./
RUN apk add --no-cache build-base && bundle update --bundler && bundle install && apk del build-base && 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 RUN apk add --no-cache build-base && bundle install && apk del build-base && 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
COPY ./bin ./bin COPY ./bin ./bin
COPY ./app ./app COPY ./app ./app
+3 -4
View File
@@ -24,11 +24,10 @@ gem 'lograge'
gem 'mysql2', require: false gem 'mysql2', require: false
gem 'oj' gem 'oj'
gem 'pagy' gem 'pagy'
gem 'pdf-reader'
gem 'pg', require: false gem 'pg', require: false
gem 'premailer-rails' gem 'premailer-rails'
gem 'pretender' gem 'pretender'
gem 'puma' gem 'puma', require: false
gem 'rack' gem 'rack'
gem 'rails' gem 'rails'
gem 'rails_autolink' gem 'rails_autolink'
@@ -38,8 +37,8 @@ gem 'rqrcode'
gem 'ruby-vips' gem 'ruby-vips'
gem 'rubyXL' gem 'rubyXL'
gem 'shakapacker' gem 'shakapacker'
gem 'sidekiq', require: ENV.key?('REDIS_URL') gem 'sidekiq'
gem 'sqlite3', require: false gem 'sqlite3', require: false, force_ruby_platform: true
gem 'strip_attributes' gem 'strip_attributes'
gem 'turbo-rails' gem 'turbo-rails'
gem 'twitter_cldr', require: false gem 'twitter_cldr', require: false
+22 -33
View File
@@ -1,7 +1,6 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
Ascii85 (1.1.0)
actioncable (7.1.3.2) actioncable (7.1.3.2)
actionpack (= 7.1.3.2) actionpack (= 7.1.3.2)
activesupport (= 7.1.3.2) activesupport (= 7.1.3.2)
@@ -78,7 +77,6 @@ GEM
tzinfo (~> 2.0) tzinfo (~> 2.0)
addressable (2.8.6) addressable (2.8.6)
public_suffix (>= 2.0.2, < 6.0) public_suffix (>= 2.0.2, < 6.0)
afm (0.2.2)
annotate (3.2.0) annotate (3.2.0)
activerecord (>= 3.2, < 8.0) activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0) rake (>= 10.4, < 14.0)
@@ -117,7 +115,7 @@ GEM
erubi (~> 1.4) erubi (~> 1.4)
parser (>= 2.4) parser (>= 2.4)
smart_properties smart_properties
bigdecimal (3.1.7) bigdecimal (3.1.8)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.18.3) bootsnap (1.18.3)
msgpack (~> 1.2) msgpack (~> 1.2)
@@ -262,21 +260,20 @@ GEM
os (>= 0.9, < 2.0) os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a) signet (>= 0.16, < 2.a)
hashdiff (1.1.0) hashdiff (1.1.0)
hashery (2.1.2) hexapdf (0.42.0)
hexapdf (0.40.0)
cmdparse (~> 3.0, >= 3.0.3) cmdparse (~> 3.0, >= 3.0.3)
geom2d (~> 0.4, >= 0.4.1) geom2d (~> 0.4, >= 0.4.1)
openssl (>= 2.2.1) openssl (>= 2.2.1)
htmlentities (4.3.4) htmlentities (4.3.4)
httpclient (2.8.3) httpclient (2.8.3)
i18n (1.14.4) i18n (1.14.5)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
image_processing (1.12.2) image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5) mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3) ruby-vips (>= 2.0.17, < 3)
io-console (0.7.2) io-console (0.7.2)
irb (1.12.0) irb (1.13.1)
rdoc rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jmespath (1.6.2) jmespath (1.6.2)
json (2.7.2) json (2.7.2)
@@ -312,7 +309,7 @@ GEM
mini_magick (4.12.0) mini_magick (4.12.0)
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.6) mini_portile2 (2.8.6)
minitest (5.22.3) minitest (5.23.0)
msgpack (1.7.2) msgpack (1.7.2)
multi_json (1.15.0) multi_json (1.15.0)
multipart-post (2.4.0) multipart-post (2.4.0)
@@ -330,10 +327,12 @@ GEM
net-smtp (0.4.0) net-smtp (0.4.0)
net-protocol net-protocol
nio4r (2.7.1) nio4r (2.7.1)
nokogiri (1.16.4) nokogiri (1.16.5)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.16.4-arm64-darwin) nokogiri (1.16.5-aarch64-linux)
racc (~> 1.4)
nokogiri (1.16.5-x86_64-linux)
racc (~> 1.4) racc (~> 1.4)
oj (3.16.3) oj (3.16.3)
bigdecimal (>= 3.0) bigdecimal (>= 3.0)
@@ -346,12 +345,6 @@ GEM
parser (3.3.1.0) parser (3.3.1.0)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
pdf-reader (2.12.0)
Ascii85 (~> 1.0)
afm (~> 0.2.1)
hashery (~> 2.0)
ruby-rc4
ttfunk
pg (1.5.6) pg (1.5.6)
premailer (1.23.0) premailer (1.23.0)
addressable addressable
@@ -373,17 +366,17 @@ GEM
public_suffix (5.0.5) public_suffix (5.0.5)
puma (6.4.2) puma (6.4.2)
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.7.3) racc (1.8.0)
rack (2.2.9) rack (3.0.11)
rack-proxy (0.7.7) rack-proxy (0.7.7)
rack rack
rack-session (1.0.2) rack-session (2.0.0)
rack (< 3) rack (>= 3.0.0)
rack-test (2.1.0) rack-test (2.1.0)
rack (>= 1.3) rack (>= 1.3)
rackup (1.0.0) rackup (2.1.0)
rack (< 3) rack (>= 3)
webrick webrick (~> 1.8)
rails (7.1.3.2) rails (7.1.3.2)
actioncable (= 7.1.3.2) actioncable (= 7.1.3.2)
actionmailbox (= 7.1.3.2) actionmailbox (= 7.1.3.2)
@@ -427,7 +420,7 @@ GEM
redis-client (0.22.0) redis-client (0.22.0)
connection_pool connection_pool
regexp_parser (2.9.0) regexp_parser (2.9.0)
reline (0.5.1) reline (0.5.7)
io-console (~> 0.5) io-console (~> 0.5)
representable (3.2.0) representable (3.2.0)
declarative (< 0.1.0) declarative (< 0.1.0)
@@ -495,7 +488,6 @@ GEM
rubocop-rspec_rails (2.28.3) rubocop-rspec_rails (2.28.3)
rubocop (~> 1.40) rubocop (~> 1.40)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
ruby-rc4 (0.1.5)
ruby-vips (2.2.1) ruby-vips (2.2.1)
ffi (~> 1.12) ffi (~> 1.12)
ruby2_keywords (0.0.5) ruby2_keywords (0.0.5)
@@ -504,7 +496,7 @@ GEM
rubyzip (>= 1.3.0) rubyzip (>= 1.3.0)
rubyzip (2.3.2) rubyzip (2.3.2)
semantic_range (3.0.0) semantic_range (3.0.0)
shakapacker (7.2.3) shakapacker (8.0.0)
activesupport (>= 5.2) activesupport (>= 5.2)
package_json package_json
rack-proxy (>= 0.6.1) rack-proxy (>= 0.6.1)
@@ -529,15 +521,12 @@ GEM
smart_properties (1.17.0) smart_properties (1.17.0)
sqlite3 (1.7.3) sqlite3 (1.7.3)
mini_portile2 (~> 2.8.0) mini_portile2 (~> 2.8.0)
sqlite3 (1.7.3-arm64-darwin)
stringio (3.1.0) stringio (3.1.0)
strip_attributes (1.13.0) strip_attributes (1.13.0)
activemodel (>= 3.0, < 8.0) activemodel (>= 3.0, < 8.0)
thor (1.3.1) thor (1.3.1)
timeout (0.4.1) timeout (0.4.1)
trailblazer-option (0.1.2) trailblazer-option (0.1.2)
ttfunk (1.8.0)
bigdecimal (~> 3.1)
turbo-rails (2.0.5) turbo-rails (2.0.5)
actionpack (>= 6.0.0) actionpack (>= 6.0.0)
activejob (>= 6.0.0) activejob (>= 6.0.0)
@@ -570,11 +559,12 @@ GEM
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
zeitwerk (2.6.13) zeitwerk (2.6.14)
PLATFORMS PLATFORMS
arm64-darwin-22 aarch64-linux
ruby ruby
x86_64-linux
DEPENDENCIES DEPENDENCIES
annotate annotate
@@ -607,7 +597,6 @@ DEPENDENCIES
mysql2 mysql2
oj oj
pagy pagy
pdf-reader
pg pg
premailer-rails premailer-rails
pretender pretender
+9
View File
@@ -3,3 +3,12 @@
If you come across any security concern or vulnarability, please report the information via email to security@docuseal.co instead of opening a GitHub issue. We will promptly respond and will collaborate with you to validate the issue, and resolve it ASAP. If you come across any security concern or vulnarability, please report the information via email to security@docuseal.co instead of opening a GitHub issue. We will promptly respond and will collaborate with you to validate the issue, and resolve it ASAP.
**We have a bug bounty program to reward security researchers.** **We have a bug bounty program to reward security researchers.**
Out of scope vulnerabilities:
- CSRF
- DNSSEC, CAA, CSP headers
- DNS or email security related
- Rate Limiting
Note: We reserve the right to classify any reported vulnerability as out of scope for the bug bounty program.
@@ -8,6 +8,7 @@ class AccountConfigsController < ApplicationController
AccountConfig::ALLOW_TYPED_SIGNATURE, AccountConfig::ALLOW_TYPED_SIGNATURE,
AccountConfig::FORCE_MFA, AccountConfig::FORCE_MFA,
AccountConfig::ALLOW_TO_RESUBMIT, AccountConfig::ALLOW_TO_RESUBMIT,
AccountConfig::FORM_PREFILL_SIGNATURE_KEY,
AccountConfig::ESIGNING_PREFERENCE_KEY, AccountConfig::ESIGNING_PREFERENCE_KEY,
AccountConfig::FORM_WITH_CONFETTI_KEY, AccountConfig::FORM_WITH_CONFETTI_KEY,
AccountConfig::DOWNLOAD_LINKS_AUTH_KEY, AccountConfig::DOWNLOAD_LINKS_AUTH_KEY,
+24 -3
View File
@@ -1,16 +1,37 @@
# frozen_string_literal: true # frozen_string_literal: true
module Api module Api
class AttachmentsController < ApiBaseController class AttachmentsController < ActionController::API
skip_before_action :authenticate_user! include ActionController::Cookies
skip_authorization_check include ActiveStorage::SetCurrent
COOKIE_STORE_LIMIT = 10
def create def create
submitter = Submitter.find_by!(slug: params[:submitter_slug]) submitter = Submitter.find_by!(slug: params[:submitter_slug])
attachment = Submitters.create_attachment!(submitter, params) attachment = Submitters.create_attachment!(submitter, params)
if params[:remember_signature] == 'true' && submitter.email.present?
cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(submitter, attachment)
end
render json: attachment.as_json(only: %i[uuid], methods: %i[url filename content_type]) render json: attachment.as_json(only: %i[uuid], methods: %i[url filename content_type])
end end
def build_new_cookie_signatures_json(submitter, attachment)
values =
begin
JSON.parse(cookies.encrypted[:signature_uuids].presence || '{}')
rescue JSON::ParserError
{}
end
values[submitter.email] = attachment.uuid
values = values.to_a.last(COOKIE_STORE_LIMIT).to_h if values.size > COOKIE_STORE_LIMIT
values.to_json
end
end end
end end
+15 -45
View File
@@ -22,7 +22,7 @@ module Api
audit_trail_attachment: :blob)) audit_trail_attachment: :blob))
render json: { render json: {
data: submissions.as_json(serialize_params), data: submissions.as_json(Submissions::SerializeForApi::SERIALIZE_PARAMS),
pagination: { pagination: {
count: submissions.size, count: submissions.size,
next: submissions.last&.id, next: submissions.last&.id,
@@ -44,29 +44,7 @@ module Api
@submission.audit_trail_attachment = Submissions::GenerateAuditTrail.call(@submission) @submission.audit_trail_attachment = Submissions::GenerateAuditTrail.call(@submission)
end end
serialized_submitters = submitters.map { |submitter| Submitters::SerializeForApi.call(submitter) } render json: Submissions::SerializeForApi.call(@submission, submitters)
json = @submission.as_json(
serialize_params.deep_merge(
include: { submission_events: { only: %i[id submitter_id event_type event_timestamp] } }
)
)
if submitters.all?(&:completed_at?)
last_submitter = submitters.max_by(&:completed_at)
json[:documents] = serialized_submitters.find { |e| e['id'] == last_submitter.id }['documents']
json[:status] = 'completed'
json[:completed_at] = last_submitter.completed_at
else
json[:documents] = []
json[:status] = 'pending'
json[:completed_at] = nil
end
json[:submitters] = serialized_submitters
render json:
end end
def create def create
@@ -85,11 +63,15 @@ module Api
submissions = create_submissions(@template, params) submissions = create_submissions(@template, params)
submissions.each do |submission|
SendSubmissionCreatedWebhookRequestJob.perform_async({ 'submission_id' => submission.id })
end
Submissions.send_signature_requests(submissions) Submissions.send_signature_requests(submissions)
submissions.each do |submission| submissions.each do |submission|
if submission.submitters.all?(&:completed_at?) && submission.submitters.last if submission.submitters.all?(&:completed_at?) && submission.submitters.last
ProcessSubmitterCompletionJob.perform_later(submission.submitters.last) ProcessSubmitterCompletionJob.perform_async({ 'submitter_id' => submission.submitters.last.id })
end end
end end
@@ -107,11 +89,15 @@ module Api
end end
def destroy def destroy
@submission.update!(archived_at: Time.current) if params[:permanently] == 'true'
@submission.destroy!
else
@submission.update!(archived_at: Time.current)
SendSubmissionArchivedWebhookRequestJob.perform_later(@submission) SendSubmissionArchivedWebhookRequestJob.perform_async('submission_id' => @submission.id)
end
render json: @submission.as_json(only: %i[id], methods: %i[archived_at]) render json: @submission.as_json(only: %i[id archived_at])
end end
private private
@@ -153,22 +139,6 @@ module Api
end end
end end
def serialize_params
{
only: %i[id source submitters_order created_at updated_at archived_at],
methods: %i[audit_log_url],
include: {
submitters: { only: %i[id slug uuid name email phone
completed_at opened_at sent_at
created_at updated_at external_id metadata],
methods: %i[status application_key] },
template: { only: %i[id name external_id created_at updated_at],
methods: %i[folder_name] },
created_by_user: { only: %i[id email first_name last_name] }
}
}
end
def submissions_params def submissions_params
permitted_attrs = [ permitted_attrs = [
:send_email, :send_sms, :bcc_completed, :completed_redirect_url, :reply_to, :go_to_last, :send_email, :send_sms, :bcc_completed, :completed_redirect_url, :reply_to, :go_to_last,
@@ -179,7 +149,7 @@ module Api
{ metadata: {}, values: {}, readonly_fields: [], message: %i[subject body], { metadata: {}, values: {}, readonly_fields: [], message: %i[subject body],
fields: [:name, :uuid, :default_value, :value, :title, :description, fields: [:name, :uuid, :default_value, :value, :title, :description,
:readonly, :redacted, :validation_pattern, :invalid_message, :readonly, :redacted, :validation_pattern, :invalid_message,
{ default_value: [], value: [] }] }]] { default_value: [], value: [], preferences: {} }] }]]
} }
] ]
@@ -13,7 +13,7 @@ module Api
SubmissionEvents.create_with_tracking_data(submitter, 'view_form', request) SubmissionEvents.create_with_tracking_data(submitter, 'view_form', request)
SendFormViewedWebhookRequestJob.perform_later(submitter) SendFormViewedWebhookRequestJob.perform_async({ 'submitter_id' => submitter.id })
render json: {} render json: {}
end end
+47 -9
View File
@@ -66,7 +66,7 @@ module Api
end end
if @submitter.completed_at? if @submitter.completed_at?
ProcessSubmitterCompletionJob.perform_later(@submitter) ProcessSubmitterCompletionJob.perform_async({ 'submitter_id' => @submitter.id })
elsif normalized_params[:send_email] || normalized_params[:send_sms] elsif normalized_params[:send_email] || normalized_params[:send_sms]
Submitters.send_signature_requests([@submitter]) Submitters.send_signature_requests([@submitter])
end end
@@ -85,7 +85,7 @@ module Api
{ metadata: {}, values: {}, readonly_fields: [], message: %i[subject body], { metadata: {}, values: {}, readonly_fields: [], message: %i[subject body],
fields: [[:name, :uuid, :default_value, :value, fields: [[:name, :uuid, :default_value, :value,
:readonly, :redacted, :validation_pattern, :invalid_message, :readonly, :redacted, :validation_pattern, :invalid_message,
{ default_value: [], value: [] }]] } { default_value: [], value: [], preferences: {} }]] }
) )
end end
@@ -104,20 +104,58 @@ module Api
end end
def assign_submitter_attrs(submitter, attrs) def assign_submitter_attrs(submitter, attrs)
submitter.email = Submissions.normalize_email(attrs[:email]) if attrs.key?(:email) values = attrs[:values]&.to_unsafe_h || {}
submitter.phone = attrs[:phone].to_s.gsub(/[^0-9+]/, '') if attrs.key?(:phone)
submitter.values = submitter.values.merge(attrs[:values].to_unsafe_h) if attrs[:values].present?
submitter.completed_at = attrs[:completed] ? Time.current : submitter.completed_at
submitter.external_id = attrs[:application_key] if attrs.key?(:application_key)
submitter.external_id = attrs[:external_id] if attrs.key?(:external_id)
submitter.metadata = attrs[:metadata] if attrs.key?(:metadata)
assign_submission_fields(submitter.submission) assign_submission_fields(submitter.submission)
phone_field_uuid = submitter.submission.template_fields.find do |f|
values[f['uuid']].present? && f['type'] == 'phone'
end&.dig('uuid')
submitter.email = Submissions.normalize_email(attrs[:email]) if attrs.key?(:email)
if attrs.key?(:phone)
submitter.phone = attrs[:phone].to_s.gsub(/[^0-9+]/, '')
elsif values[phone_field_uuid].present?
submitter.phone = values[phone_field_uuid].to_s.gsub(/[^0-9+]/, '')
end
values = values.except(phone_field_uuid)
submitter.values = submitter.values.merge(values) if values.present?
submitter.metadata = attrs[:metadata] if attrs.key?(:metadata)
maybe_assign_completed_attributes(submitter, attrs)
assign_external_id(submitter, attrs)
assign_preferences(submitter, attrs) assign_preferences(submitter, attrs)
submitter submitter
end end
def maybe_assign_completed_attributes(submitter, attrs)
submitter.completed_at = attrs[:completed] ? Time.current : submitter.completed_at
if attrs[:completed]
submitter.values = Submitters::SubmitValues.merge_default_values(submitter)
submitter.values = Submitters::SubmitValues.merge_formula_values(submitter)
submitter.values = Submitters::SubmitValues.maybe_remove_condition_values(submitter)
submitter.values = submitter.values.transform_values do |v|
v == '{{date}}' ? Time.current.in_time_zone(submitter.account.timezone).to_date.to_s : v
end
end
submitter
end
def assign_external_id(submitter, attrs)
submitter.external_id = attrs[:application_key] if attrs.key?(:application_key)
submitter.external_id = attrs[:external_id] if attrs.key?(:external_id)
submitter
end
def assign_submission_fields(submission) def assign_submission_fields(submission)
submission.template_fields ||= submission.template.fields submission.template_fields ||= submission.template.fields
submission.template_schema ||= submission.template.schema submission.template_schema ||= submission.template.schema
@@ -20,6 +20,8 @@ module Api
Templates::CloneAttachments.call(template: cloned_template, original_template: @template) Templates::CloneAttachments.call(template: cloned_template, original_template: @template)
SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => cloned_template.id)
render json: Templates::SerializeForApi.call(cloned_template) render json: Templates::SerializeForApi.call(cloned_template)
end end
end end
+3 -1
View File
@@ -65,11 +65,13 @@ module Api
@template.update!(template_params) @template.update!(template_params)
SendTemplateUpdatedWebhookRequestJob.perform_async('template_id' => @template.id)
render json: @template.as_json(only: %i[id updated_at]) render json: @template.as_json(only: %i[id updated_at])
end end
def destroy def destroy
if params[:permanently] == 'true' && !Docuseal.multitenant? if params[:permanently] == 'true'
@template.destroy! @template.destroy!
else else
@template.update!(archived_at: Time.current) @template.update!(archived_at: Time.current)
+2 -1
View File
@@ -52,7 +52,8 @@ class ApplicationController < ActionController::Base
private private
def with_browser_locale(&) def with_browser_locale(&)
locale = request.env['HTTP_ACCEPT_LANGUAGE'].to_s[BROWSER_LOCALE_REGEXP].to_s locale = params[:lang].presence
locale ||= request.env['HTTP_ACCEPT_LANGUAGE'].to_s[BROWSER_LOCALE_REGEXP].to_s
locale = locale =
if locale.starts_with?('en-') && locale != 'en-US' if locale.starts_with?('en-') && locale != 'en-US'
@@ -9,6 +9,8 @@ class ConsoleRedirectController < ApplicationController
params[:redir] = Docuseal.multitenant? ? "#{Docuseal::CONSOLE_URL}/plans" : "#{Docuseal::CONSOLE_URL}/on_premise" params[:redir] = Docuseal.multitenant? ? "#{Docuseal::CONSOLE_URL}/plans" : "#{Docuseal::CONSOLE_URL}/on_premise"
end end
params[:redir] = "#{Docuseal::CONSOLE_URL}/manage" if request.path == '/manage'
return redirect_to(new_user_session_path({ redir: params[:redir] }.compact)) if true_user.blank? return redirect_to(new_user_session_path({ redir: params[:redir] }.compact)) if true_user.blank?
auth = JsonWebToken.encode(uuid: true_user.uuid, auth = JsonWebToken.encode(uuid: true_user.uuid,
+4 -2
View File
@@ -57,8 +57,10 @@ class EsignSettingsController < ApplicationController
save_new_cert!(@encrypted_config, @cert_record) save_new_cert!(@encrypted_config, @cert_record)
redirect_to settings_esign_path, notice: 'Certificate has been successfully added!' redirect_to settings_esign_path, notice: 'Certificate has been successfully added!'
rescue OpenSSL::PKCS12::PKCS12Error rescue OpenSSL::PKCS12::PKCS12Error => e
@cert_record.errors.add(:password, "is invalid. Make sure you're uploading a valid .p12 file") Rollbar.error(e) if defined?(Rollbar)
@cert_record.errors.add(:password, e.message)
render turbo_stream: turbo_stream.replace(:modal, template: 'esign_settings/new'), status: :unprocessable_entity render turbo_stream: turbo_stream.replace(:modal, template: 'esign_settings/new'), status: :unprocessable_entity
end end
+2
View File
@@ -3,6 +3,8 @@
class SessionsController < Devise::SessionsController class SessionsController < Devise::SessionsController
before_action :configure_permitted_parameters before_action :configure_permitted_parameters
around_action :with_browser_locale
def create def create
email = sign_in_params[:email].to_s.downcase email = sign_in_params[:email].to_s.downcase
+6
View File
@@ -33,7 +33,13 @@ class StartFormController < ApplicationController
assign_submission_attributes(@submitter, @template) if @submitter.new_record? assign_submission_attributes(@submitter, @template) if @submitter.new_record?
is_new_record = @submitter.new_record?
if @submitter.save if @submitter.save
if is_new_record
SendSubmissionCreatedWebhookRequestJob.perform_async({ 'submission_id' => @submitter.submission.id })
end
redirect_to submit_form_path(@submitter.slug) redirect_to submit_form_path(@submitter.slug)
else else
render :show render :show
+5 -1
View File
@@ -44,6 +44,10 @@ class SubmissionsController < ApplicationController
params: params.merge('send_completed_email' => true)) params: params.merge('send_completed_email' => true))
end end
submissions.each do |submission|
SendSubmissionCreatedWebhookRequestJob.perform_async({ 'submission_id' => submission.id })
end
Submissions.send_signature_requests(submissions) Submissions.send_signature_requests(submissions)
redirect_to template_path(@template), notice: 'New recipients have been added' redirect_to template_path(@template), notice: 'New recipients have been added'
@@ -52,7 +56,7 @@ class SubmissionsController < ApplicationController
def destroy def destroy
@submission.update!(archived_at: Time.current) @submission.update!(archived_at: Time.current)
SendSubmissionArchivedWebhookRequestJob.perform_later(@submission) SendSubmissionArchivedWebhookRequestJob.perform_async('submission_id' => @submission.id)
redirect_back(fallback_location: template_path(@submission.template), notice: 'Submission has been archived') redirect_back(fallback_location: template_path(@submission.template), notice: 'Submission has been archived')
end end
@@ -16,13 +16,16 @@ class SubmissionsDebugController < ApplicationController
render 'submit_form/show' render 'submit_form/show'
end end
f.pdf do f.pdf do
if params[:audit] result =
Submissions::GenerateAuditTrail.call(@submitter.submission) if params[:audit]
else Submissions::GenerateAuditTrail.call(@submitter.submission)
Submissions::GenerateResultAttachments.call(@submitter) elsif params[:combined]
end Submissions::GenerateCombinedAttachment.call(@submitter)
else
Submissions::GenerateResultAttachments.call(@submitter)
end
send_data ActiveStorage::Attachment.where(name: params[:audit] ? :audit_trail : :documents).last.download, send_data Array.wrap(result).first.download,
filename: 'debug.pdf', filename: 'debug.pdf',
disposition: 'inline', disposition: 'inline',
type: 'application/pdf' type: 'application/pdf'
@@ -33,7 +33,17 @@ class SubmissionsDownloadController < ApplicationController
return head :not_found return head :not_found
end end
render json: build_urls(last_submitter) if params[:combined]
url = build_combined_url(submitter)
if url
render json: [url]
else
head :not_found
end
else
render json: build_urls(last_submitter)
end
end end
private private
@@ -47,4 +57,14 @@ class SubmissionsDownloadController < ApplicationController
ActiveStorage::Blob.proxy_url(attachment.blob, expires_at: FILES_TTL.from_now.to_i) ActiveStorage::Blob.proxy_url(attachment.blob, expires_at: FILES_TTL.from_now.to_i)
end end
end end
def build_combined_url(submitter)
return if submitter.submission.submitters.exists?(completed_at: nil)
return if submitter.submission.submitters.order(:completed_at).last != submitter
attachment = submitter.submission.combined_document_attachment
attachment ||= Submissions::GenerateCombinedAttachment.call(submitter)
ActiveStorage::Blob.proxy_url(attachment.blob, expires_at: FILES_TTL.from_now.to_i)
end
end end
+13 -1
View File
@@ -7,12 +7,14 @@ class SubmitFormController < ApplicationController
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
skip_authorization_check skip_authorization_check
CONFIG_KEYS = [].freeze
PRELOAD_ALL_PAGES_AMOUNT = 200 PRELOAD_ALL_PAGES_AMOUNT = 200
def show def show
@submitter = Submitter.find_by!(slug: params[:slug]) @submitter = Submitter.find_by!(slug: params[:slug])
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at? return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
return render :archived if @submitter.submission.template.archived_at? || @submitter.submission.archived_at?
ActiveRecord::Associations::Preloader.new( ActiveRecord::Associations::Preloader.new(
records: [@submitter], records: [@submitter],
@@ -31,7 +33,17 @@ class SubmitFormController < ApplicationController
Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user) Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user)
render(@submitter.submission.template.archived_at? || @submitter.submission.archived_at? ? :archived : :show) @attachments_index = ActiveStorage::Attachment.where(record: @submitter.submission.submitters, name: :attachments)
.preload(:blob).index_by(&:uuid)
@form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS)
return unless @form_configs[:prefill_signature]
@signature_attachment =
Submitters::MaybeAssignDefaultBrowserSignature.call(@submitter, params, cookies, @attachments_index.values)
@attachments_index[@signature_attachment.uuid] = @signature_attachment if @signature_attachment
end end
def update def update
@@ -0,0 +1,21 @@
# frozen_string_literal: true
class SubmitFormDrawSignatureController < ApplicationController
layout false
around_action :with_browser_locale, only: %i[show]
skip_before_action :authenticate_user!
skip_authorization_check
def show
@submitter = Submitter.find_by!(slug: params[:slug])
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
if @submitter.submission.template.archived_at? || @submitter.submission.archived_at?
return redirect_to submit_form_path(@submitter.slug)
end
render :show
end
end
@@ -0,0 +1,21 @@
# frozen_string_literal: true
class SubmitFormValuesController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
def index
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return render json: {} if submitter.completed_at?
return render json: {} if submitter.submission.template.archived_at? || submitter.submission.archived_at?
value = submitter.values[params['field_uuid']]
attachment = submitter.attachments.where(created_at: params[:after]..).find_by(uuid: value) if value.present?
render json: {
value:,
attachment: attachment&.as_json(only: %i[uuid], methods: %i[url filename content_type])
}, head: :ok
end
end
@@ -25,7 +25,7 @@ class SubmittersAutocompleteController < ApplicationController
term = "#{params[:q].downcase}%" term = "#{params[:q].downcase}%"
submitters.where(column.matches(term, false, true)) submitters.where(column.matches(term))
else else
Submitters.search(submitters, params[:q]) Submitters.search(submitters, params[:q])
end end
@@ -13,7 +13,7 @@ class SubmittersSendEmailController < ApplicationController
alert: 'Email has been sent already.') alert: 'Email has been sent already.')
end end
SendSubmitterInvitationEmailJob.perform_later('submitter_id' => @submitter.id) SendSubmitterInvitationEmailJob.perform_async('submitter_id' => @submitter.id)
@submitter.sent_at ||= Time.current @submitter.sent_at ||= Time.current
@submitter.save! @submitter.save!
@@ -4,7 +4,9 @@ class TemplateDocumentsController < ApplicationController
load_and_authorize_resource :template load_and_authorize_resource :template
def create def create
return head :unprocessable_entity if params[:blobs].blank? && params[:files].blank? if params[:blobs].blank? && params[:files].blank?
return render json: { error: 'File is missing' }, status: :unprocessable_entity
end
old_fields_hash = @template.fields.hash old_fields_hash = @template.fields.hash
+5 -1
View File
@@ -53,7 +53,7 @@ class TemplatesController < ApplicationController
if params[:account_id].present? && authorized_clone_account_id?(params[:account_id]) if params[:account_id].present? && authorized_clone_account_id?(params[:account_id])
@template.account_id = params[:account_id] @template.account_id = params[:account_id]
@template.folder = @template.account.default_template_folder @template.folder = @template.account.default_template_folder if @template.account_id != current_account.id
else else
@template.account = current_account @template.account = current_account
end end
@@ -61,6 +61,8 @@ class TemplatesController < ApplicationController
if @template.save if @template.save
Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template
SendTemplateUpdatedWebhookRequestJob.perform_async('template_id' => @template.id)
maybe_redirect_to_template(@template) maybe_redirect_to_template(@template)
else else
render turbo_stream: turbo_stream.replace(:modal, template: 'templates/new'), status: :unprocessable_entity render turbo_stream: turbo_stream.replace(:modal, template: 'templates/new'), status: :unprocessable_entity
@@ -70,6 +72,8 @@ class TemplatesController < ApplicationController
def update def update
@template.update!(template_params) @template.update!(template_params)
SendTemplateUpdatedWebhookRequestJob.perform_async('template_id' => @template.id)
head :ok head :ok
end end
@@ -9,6 +9,7 @@ class TemplatesPreferencesController < ApplicationController
authorize!(:update, @template) authorize!(:update, @template)
@template.preferences = @template.preferences.merge(template_params[:preferences]) @template.preferences = @template.preferences.merge(template_params[:preferences])
@template.preferences = @template.preferences.reject { |_, v| v.is_a?(String) && v.blank? }
@template.save! @template.save!
head :ok head :ok
@@ -17,6 +18,20 @@ class TemplatesPreferencesController < ApplicationController
private private
def template_params def template_params
params.require(:template).permit(preferences: %i[bcc_completed request_email_subject request_email_body]) params.require(:template).permit(
preferences: %i[bcc_completed request_email_subject request_email_body
documents_copy_email_subject documents_copy_email_body
documents_copy_email_enabled documents_copy_email_attach_audit
completed_notification_email_subject completed_notification_email_body
completed_notification_email_enabled completed_notification_email_attach_audit]
).tap do |attrs|
attrs[:preferences] = attrs[:preferences].transform_values do |value|
if %w[true false].include?(value)
value == 'true'
else
value
end
end
end
end end
end end
@@ -18,12 +18,16 @@ class TemplatesUploadsController < ApplicationController
@template.update!(schema:) @template.update!(schema:)
SendTemplateCreatedWebhookRequestJob.perform_async('template_id' => @template.id)
redirect_to edit_template_path(@template) redirect_to edit_template_path(@template)
rescue Templates::CreateAttachments::PdfEncrypted rescue Templates::CreateAttachments::PdfEncrypted
render turbo_stream: turbo_stream.append(params[:form_id], html: helpers.tag.prompt_password) render turbo_stream: turbo_stream.append(params[:form_id], html: helpers.tag.prompt_password)
rescue StandardError => e rescue StandardError => e
Rollbar.error(e) if defined?(Rollbar) Rollbar.error(e) if defined?(Rollbar)
raise if Rails.env.local?
redirect_to root_path, alert: 'Unable to upload file' redirect_to root_path, alert: 'Unable to upload file'
end end
+19 -11
View File
@@ -1,9 +1,13 @@
# frozen_string_literal: true # frozen_string_literal: true
class TimestampServerController < ApplicationController class TimestampServerController < ApplicationController
HASH_ALGORITHM = 'SHA256'
before_action :build_encrypted_config before_action :build_encrypted_config
authorize_resource :encrypted_config authorize_resource :encrypted_config
TimestampError = Class.new(StandardError)
def create def create
return head :not_found if Docuseal.multitenant? return head :not_found if Docuseal.multitenant?
@@ -14,25 +18,29 @@ class TimestampServerController < ApplicationController
else else
redirect_back fallback_location: settings_notifications_path, alert: 'Unable to save' redirect_back fallback_location: settings_notifications_path, alert: 'Unable to save'
end end
rescue HexaPDF::Error, SocketError, Submissions::TimestampHandler::TimestampError, OpenSSL::Timestamp::TimestampError rescue SocketError, TimestampError, OpenSSL::Timestamp::TimestampError
redirect_back fallback_location: settings_notifications_path, alert: 'Invalid Timeserver' redirect_back fallback_location: settings_notifications_path, alert: 'Invalid Timeserver'
end end
private private
def test_timeserver_url(url) def test_timeserver_url(url)
pdf = HexaPDF::Document.new req = OpenSSL::Timestamp::Request.new
pdf.pages.add req.algorithm = HASH_ALGORITHM
req.message_imprint = OpenSSL::Digest.digest(HASH_ALGORITHM, 'test')
pkcs = Accounts.load_signing_pkcs(current_account) uri = Addressable::URI.parse(url)
pdf.sign(StringIO.new, conn = Faraday.new(uri.origin) do |c|
reason: 'Test', c.basic_auth(uri.user, uri.password) if uri.password.present?
certificate: pkcs.certificate, end
key: pkcs.key,
signature_size: 10_000, response = conn.post(uri.path, req.to_der,
certificate_chain: pkcs.ca_certs || [], 'content-type' => 'application/timestamp-query')
timestamp_handler: Submissions::TimestampHandler.new(tsa_url: url))
raise TimestampError if response.status != 200 || response.body.blank?
response
end end
def load_encrypted_config def load_encrypted_config
@@ -0,0 +1,43 @@
# frozen_string_literal: true
class UserInitialsController < ApplicationController
before_action :load_user_config
authorize_resource :user_config
def edit; end
def update
file = params[:file]
return redirect_to settings_profile_index_path, notice: 'Unable to save initials' if file.blank?
blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename,
content_type: file.content_type)
attachment = ActiveStorage::Attachment.create!(
blob:,
name: 'initials',
record: current_user
)
if @user_config.update(value: attachment.uuid)
redirect_to settings_profile_index_path, notice: 'Initials has been saved'
else
redirect_to settings_profile_index_path, notice: 'Unable to save initials'
end
end
def destroy
@user_config.destroy
redirect_to settings_profile_index_path, notice: 'Initials has been removed'
end
private
def load_user_config
@user_config =
UserConfig.find_or_initialize_by(user: current_user, key: UserConfig::INITIALS_KEY)
end
end
+9
View File
@@ -27,6 +27,7 @@ class UsersController < ApplicationController
if existing_user if existing_user
existing_user.archived_at = nil existing_user.archived_at = nil
existing_user.assign_attributes(user_params) existing_user.assign_attributes(user_params)
existing_user.account = current_account
@user = existing_user @user = existing_user
end end
@@ -46,6 +47,14 @@ class UsersController < ApplicationController
attrs = user_params.compact_blank.merge(user_params.slice(:archived_at)) attrs = user_params.compact_blank.merge(user_params.slice(:archived_at))
attrs.delete(:role) if !role_valid?(attrs[:role]) || current_user == @user attrs.delete(:role) if !role_valid?(attrs[:role]) || current_user == @user
if params.dig(:user, :account_id).present?
account = Account.accessible_by(current_ability).find(params[:user][:account_id])
authorize!(:manage, account)
@user.account = account
end
if @user.update(attrs) if @user.update(attrs)
redirect_back fallback_location: settings_users_path, notice: 'User has been updated' redirect_back fallback_location: settings_users_path, notice: 'User has been updated'
else else
@@ -27,7 +27,8 @@ class VerifyPdfSignatureController < ApplicationController
trusted_certs = [default_pkcs.certificate, trusted_certs = [default_pkcs.certificate,
*default_pkcs.ca_certs, *default_pkcs.ca_certs,
*custom_certs.map(&:certificate), *custom_certs.map(&:certificate),
*custom_certs.flat_map(&:ca_certs).compact] *custom_certs.flat_map(&:ca_certs).compact,
*Docuseal.trusted_certs]
render turbo_stream: turbo_stream.replace('result', partial: 'result', render turbo_stream: turbo_stream.replace('result', partial: 'result',
locals: { pdfs:, files: params[:files], trusted_certs: }) locals: { pdfs:, files: params[:files], trusted_certs: })
@@ -5,6 +5,9 @@ class WebhookPreferencesController < ApplicationController
form.viewed form.viewed
form.started form.started
form.completed form.completed
template.created
template.updated
submission.created
submission.archived submission.archived
].freeze ].freeze
@@ -17,7 +17,7 @@ class WebhookSettingsController < ApplicationController
def update def update
submitter = current_account.submitters.where.not(completed_at: nil).order(:id).last submitter = current_account.submitters.where.not(completed_at: nil).order(:id).last
SendFormCompletedWebhookRequestJob.perform_later(submitter) SendFormCompletedWebhookRequestJob.perform_async({ 'submitter_id' => submitter.id })
redirect_back(fallback_location: settings_webhooks_path, notice: 'Webhook request has been sent.') redirect_back(fallback_location: settings_webhooks_path, notice: 'Webhook request has been sent.')
end end
+24 -22
View File
@@ -41,26 +41,6 @@ document.addEventListener('keyup', (e) => {
} }
}) })
window.customElements.define('toggle-visible', ToggleVisible)
window.customElements.define('disable-hidden', DisableHidden)
window.customElements.define('turbo-modal', TurboModal)
window.customElements.define('file-dropzone', FileDropzone)
window.customElements.define('menu-active', MenuActive)
window.customElements.define('clipboard-copy', ClipboardCopy)
window.customElements.define('dynamic-list', DynamicList)
window.customElements.define('download-button', DownloadButton)
window.customElements.define('set-origin-url', SetOriginUrl)
window.customElements.define('set-timezone', SetTimezone)
window.customElements.define('autoresize-textarea', AutoresizeTextarea)
window.customElements.define('submitters-autocomplete', SubmittersAutocomplete)
window.customElements.define('folder-autocomplete', FolderAutocomplete)
window.customElements.define('signature-form', SignatureForm)
window.customElements.define('submit-form', SubmitForm)
window.customElements.define('prompt-password', PromptPassword)
window.customElements.define('emails-textarea', EmailsTextarea)
window.customElements.define('toggle-cookies', ToggleCookies)
window.customElements.define('toggle-on-submit', ToggleOnSubmit)
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody) document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
document.addEventListener('turbo:submit-end', async (event) => { document.addEventListener('turbo:submit-end', async (event) => {
const resp = event.detail?.formSubmission?.result?.fetchResponse?.response const resp = event.detail?.formSubmission?.result?.fetchResponse?.response
@@ -84,7 +64,29 @@ document.addEventListener('turbo:submit-end', async (event) => {
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
}) })
window.customElements.define('template-builder', class extends HTMLElement { const safeRegisterElement = (name, element, options = {}) => !window.customElements.get(name) && window.customElements.define(name, element, options)
safeRegisterElement('toggle-visible', ToggleVisible)
safeRegisterElement('disable-hidden', DisableHidden)
safeRegisterElement('turbo-modal', TurboModal)
safeRegisterElement('file-dropzone', FileDropzone)
safeRegisterElement('menu-active', MenuActive)
safeRegisterElement('clipboard-copy', ClipboardCopy)
safeRegisterElement('dynamic-list', DynamicList)
safeRegisterElement('download-button', DownloadButton)
safeRegisterElement('set-origin-url', SetOriginUrl)
safeRegisterElement('set-timezone', SetTimezone)
safeRegisterElement('autoresize-textarea', AutoresizeTextarea)
safeRegisterElement('submitters-autocomplete', SubmittersAutocomplete)
safeRegisterElement('folder-autocomplete', FolderAutocomplete)
safeRegisterElement('signature-form', SignatureForm)
safeRegisterElement('submit-form', SubmitForm)
safeRegisterElement('prompt-password', PromptPassword)
safeRegisterElement('emails-textarea', EmailsTextarea)
safeRegisterElement('toggle-cookies', ToggleCookies)
safeRegisterElement('toggle-on-submit', ToggleOnSubmit)
safeRegisterElement('template-builder', class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.appElem = document.createElement('div') this.appElem = document.createElement('div')
@@ -116,7 +118,7 @@ window.customElements.define('template-builder', class extends HTMLElement {
} }
}) })
window.customElements.define('import-list', class extends HTMLElement { safeRegisterElement('import-list', class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.appElem = document.createElement('div') this.appElem = document.createElement('div')
+114
View File
@@ -0,0 +1,114 @@
import SignaturePad from 'signature_pad'
import { cropCanvasAndExportToPNG } from './submission_form/crop_canvas'
window.customElements.define('draw-signature', class extends HTMLElement {
connectedCallback () {
const scale = 3
this.canvas.width = this.canvas.parentNode.clientWidth * scale
this.canvas.height = this.canvas.parentNode.clientHeight * scale
this.canvas.getContext('2d').scale(scale, scale)
this.pad = new SignaturePad(this.canvas)
this.pad.addEventListener('endStroke', () => {
this.updateSubmitButtonVisibility()
})
this.clearButton.addEventListener('click', (e) => {
e.preventDefault()
this.clearSignaturePad()
})
this.form.addEventListener('submit', (e) => {
e.preventDefault()
this.submitButton.disabled = true
this.submitImage().then((data) => {
this.valueInput.value = data.uuid
return fetch(this.form.action, {
method: 'PUT',
body: new FormData(this.form)
}).then((response) => {
this.form.classList.add('hidden')
this.success.classList.remove('hidden')
return response
})
}).finally(() => {
this.submitButton.disabled = false
})
})
}
clearSignaturePad () {
this.pad.clear()
this.updateSubmitButtonVisibility()
}
updateSubmitButtonVisibility () {
if (this.pad.isEmpty()) {
this.submitButton.style.display = 'none'
this.placeholderButton.style.display = 'block'
} else {
this.submitButton.style.display = 'block'
this.placeholderButton.style.display = 'none'
}
}
async submitImage () {
return new Promise((resolve, reject) => {
cropCanvasAndExportToPNG(this.canvas, { errorOnTooSmall: true }).then(async (blob) => {
const file = new File([blob], 'signature.png', { type: 'image/png' })
const formData = new FormData()
formData.append('file', file)
formData.append('submitter_slug', this.dataset.slug)
formData.append('name', 'attachments')
formData.append('remember_signature', 'true')
return fetch('/api/attachments', {
method: 'POST',
body: formData
}).then((resp) => resp.json()).then((attachment) => {
return resolve(attachment)
})
}).catch((error) => {
return reject(error)
})
})
}
get submitButton () {
return this.querySelector('button[type="submit"]')
}
get clearButton () {
return this.querySelector('button[aria-label="Clear"]')
}
get placeholderButton () {
return this.querySelector('button[disabled]')
}
get canvas () {
return this.querySelector('canvas')
}
get valueInput () {
return this.querySelector('input[name^="values"]')
}
get form () {
return this.querySelector('form')
}
get success () {
return this.querySelector('#success')
}
})
+10 -5
View File
@@ -17,13 +17,18 @@ export default targetable(class extends HTMLElement {
this.toggleState() this.toggleState()
fetch(this.dataset.src).then((response) => response.json()).then((urls) => { fetch(this.dataset.src).then(async (response) => {
const isSafariIos = /iPhone|iPad|iPod/i.test(navigator.userAgent) if (response.ok) {
const urls = await response.json()
const isSafariIos = /iPhone|iPad|iPod/i.test(navigator.userAgent)
if (isSafariIos && urls.length > 1) { if (isSafariIos && urls.length > 1) {
this.downloadSafariIos(urls) this.downloadSafariIos(urls)
} else {
this.downloadUrls(urls)
}
} else { } else {
this.downloadUrls(urls) alert('Failed to download files')
} }
}) })
} }
+6 -2
View File
@@ -5,8 +5,12 @@ export default targetable(class extends HTMLElement {
static [target.static] = ['canvas', 'input', 'clear', 'button'] static [target.static] = ['canvas', 'input', 'clear', 'button']
async connectedCallback () { async connectedCallback () {
this.canvas.width = this.canvas.parentNode.parentNode.clientWidth const scale = 3
this.canvas.height = this.canvas.parentNode.parentNode.clientWidth / 3
this.canvas.width = this.canvas.parentNode.clientWidth * scale
this.canvas.height = this.canvas.parentNode.clientHeight * scale
this.canvas.getContext('2d').scale(scale, scale)
const { default: SignaturePad } = await import('signature_pad') const { default: SignaturePad } = await import('signature_pad')
@@ -45,11 +45,7 @@ export default class extends HTMLElement {
this.currentFetch.then(async (resp) => { this.currentFetch.then(async (resp) => {
const items = await resp.json() const items = await resp.json()
if (q.length < 3) { resolve(items)
resolve(items.filter((e) => e[this.dataset.field].startsWith(q)))
} else {
resolve(items)
}
}).catch(() => { }).catch(() => {
resolve([]) resolve([])
}).finally(() => { }).finally(() => {
+21
View File
@@ -0,0 +1,21 @@
export default class extends HTMLElement {
connectedCallback () {
this.button.disabled = false
this.form.addEventListener('submit', () => {
this.button.disabled = true
})
}
disconnectedCallback () {
this.button.disabled = false
}
get button () {
return this.querySelector('[type="submit"]')
}
get form () {
return this.querySelector('form') || this.closest('form')
}
}
+10 -2
View File
@@ -2,23 +2,31 @@ import { createApp, reactive } from 'vue'
import Form from './submission_form/form' import Form from './submission_form/form'
import DownloadButton from './elements/download_button' import DownloadButton from './elements/download_button'
import ToggleSubmit from './elements/toggle_submit'
window.customElements.define('download-button', DownloadButton) const safeRegisterElement = (name, element, options = {}) => !window.customElements.get(name) && window.customElements.define(name, element, options)
window.customElements.define('submission-form', class extends HTMLElement {
safeRegisterElement('download-button', DownloadButton)
safeRegisterElement('toggle-submit', ToggleSubmit)
safeRegisterElement('submission-form', class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.appElem = document.createElement('div') this.appElem = document.createElement('div')
this.app = createApp(Form, { this.app = createApp(Form, {
submitter: JSON.parse(this.dataset.submitter), submitter: JSON.parse(this.dataset.submitter),
canSendEmail: this.dataset.canSendEmail === 'true', canSendEmail: this.dataset.canSendEmail === 'true',
previousSignatureValue: this.dataset.previousSignatureValue,
goToLast: this.dataset.goToLast === 'true', goToLast: this.dataset.goToLast === 'true',
isDemo: this.dataset.isDemo === 'true', isDemo: this.dataset.isDemo === 'true',
attribution: this.dataset.attribution !== 'false', attribution: this.dataset.attribution !== 'false',
withConfetti: this.dataset.withConfetti !== 'false', withConfetti: this.dataset.withConfetti !== 'false',
withDisclosure: this.dataset.withDisclosure === 'true',
withTypedSignature: this.dataset.withTypedSignature !== 'false', withTypedSignature: this.dataset.withTypedSignature !== 'false',
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content, authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
rememberSignature: this.dataset.rememberSignature === 'true',
values: reactive(JSON.parse(this.dataset.values)), values: reactive(JSON.parse(this.dataset.values)),
completedButton: JSON.parse(this.dataset.completedButton || '{}'), completedButton: JSON.parse(this.dataset.completedButton || '{}'),
withQrButton: true,
completedMessage: JSON.parse(this.dataset.completedMessage || '{}'), completedMessage: JSON.parse(this.dataset.completedMessage || '{}'),
completedRedirectUrl: this.dataset.completedRedirectUrl, completedRedirectUrl: this.dataset.completedRedirectUrl,
attachments: reactive(JSON.parse(this.dataset.attachments)), attachments: reactive(JSON.parse(this.dataset.attachments)),
+34
View File
@@ -0,0 +1,34 @@
import Rollbar from 'rollbar/dist/rollbar.umd'
const token = document.querySelector('meta[name="rollbar-token"]')?.getAttribute('content')
if (token) {
window.Rollbar ||= new Rollbar({
accessToken: token,
captureUncaught: true,
captureUnhandledRejections: true,
captureIp: false,
autoInstrument: false,
ignoredMessages: [
/Failed to fetch/i,
/NetworkError/i,
/Load failed/i,
/Clipboard write is not allowed/i
],
transform (payload) {
payload.body.telemetry = []
const string = JSON.stringify(payload)
return JSON.parse(string.replace(/(\/[des]\/)\w{6}/g, (_, m) => m))
},
payload: {
client: {
javascript: {
source_map_enabled: true
}
},
environment: 'production'
}
})
}
+23 -2
View File
@@ -3,7 +3,7 @@
class="field-area flex absolute lg:text-base -outline-offset-1" class="field-area flex absolute lg:text-base -outline-offset-1"
dir="auto" dir="auto"
:style="computedStyle" :style="computedStyle"
:class="{ 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars, 'cursor-default': !submittable, 'border border-red-100 bg-red-100 cursor-pointer': submittable, 'border border-red-100': !isActive && submittable, 'bg-opacity-80': !isActive && !isValueSet && submittable, 'outline-red-500 outline-dashed outline-2 z-10': isActive && submittable, 'bg-opacity-40': (isActive || isValueSet) && submittable }" :class="{ 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars, 'cursor-default': !submittable, 'border border-red-100 bg-red-100 cursor-pointer': submittable, 'border border-red-100': !isActive && submittable, 'bg-opacity-80': !isActive && !isValueSet && submittable, 'field-area-active outline-red-500 outline-dashed outline-2 z-10': isActive && submittable, 'bg-opacity-40': (isActive || isValueSet) && submittable }"
> >
<div <div
v-if="!isActive && !isValueSet && field.type !== 'checkbox' && submittable && !area.option_uuid" v-if="!isActive && !isValueSet && field.type !== 'checkbox' && submittable && !area.option_uuid"
@@ -165,6 +165,9 @@
<span v-else-if="field.type === 'date'"> <span v-else-if="field.type === 'date'">
{{ formattedDate }} {{ formattedDate }}
</span> </span>
<span v-else-if="field.type === 'number'">
{{ formatNumber(modelValue, field.preferences?.format) }}
</span>
<span <span
v-else v-else
class="whitespace-pre-wrap" class="whitespace-pre-wrap"
@@ -337,12 +340,18 @@ export default {
computedStyle () { computedStyle () {
const { x, y, w, h } = this.area const { x, y, w, h } = this.area
return { const style = {
top: y * 100 + '%', top: y * 100 + '%',
left: x * 100 + '%', left: x * 100 + '%',
width: w * 100 + '%', width: w * 100 + '%',
height: h * 100 + '%' height: h * 100 + '%'
} }
if (this.field.preferences?.font_size) {
style.fontSize = this.field.preferences.font_size + 'pt'
}
return style
} }
}, },
watch: { watch: {
@@ -373,6 +382,17 @@ export default {
} }
} }
}, },
formatNumber (number, format) {
if (format === 'comma') {
return new Intl.NumberFormat('en-US').format(number)
} else if (format === 'dot') {
return new Intl.NumberFormat('de-DE').format(number)
} else if (format === 'space') {
return new Intl.NumberFormat('fr-FR').format(number)
} else {
return number
}
},
formatDate (date, format) { formatDate (date, format) {
const monthFormats = { const monthFormats = {
M: 'numeric', M: 'numeric',
@@ -388,6 +408,7 @@ export default {
const yearFormats = { const yearFormats = {
YYYY: 'numeric', YYYY: 'numeric',
YYY: 'numeric',
YY: '2-digit' YY: '2-digit'
} }
+10 -5
View File
@@ -197,13 +197,18 @@ export default {
download () { download () {
this.isDownloading = true this.isDownloading = true
fetch(this.baseUrl + `/submitters/${this.submitterSlug}/download`).then((response) => response.json()).then((urls) => { fetch(this.baseUrl + `/submitters/${this.submitterSlug}/download`).then(async (response) => {
const isSafariIos = /iPhone|iPad|iPod/i.test(navigator.userAgent) if (response.ok) {
const urls = await response.json()
const isSafariIos = /iPhone|iPad|iPod/i.test(navigator.userAgent)
if (isSafariIos && urls.length > 1) { if (isSafariIos && urls.length > 1) {
this.downloadSafariIos(urls) this.downloadSafariIos(urls)
} else {
this.downloadUrls(urls)
}
} else { } else {
this.downloadUrls(urls) alert('Failed to download files')
} }
}) })
}, },
+3 -1
View File
@@ -14,7 +14,9 @@
/> />
<template v-else> <template v-else>
{{ field.name && showFieldNames ? field.name : t('date') }} {{ field.name && showFieldNames ? field.name : t('date') }}
<template v-if="!field.required">({{ t('optional') }})</template> <template v-if="!field.required">
({{ t('optional') }})
</template>
</template> </template>
</label> </label>
<button <button
+31 -1
View File
@@ -104,9 +104,13 @@ export default {
this.isLoading = true this.isLoading = true
return await Promise.all( return await Promise.all(
Array.from(files).map((file) => { Array.from(files).map(async (file) => {
const formData = new FormData() const formData = new FormData()
if (file.type === 'image/bmp') {
file = await this.convertBmpToPng(file)
}
formData.append('file', file) formData.append('file', file)
formData.append('submitter_slug', this.submitterSlug) formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments') formData.append('name', 'attachments')
@@ -122,6 +126,32 @@ export default {
}).finally(() => { }).finally(() => {
this.isLoading = false this.isLoading = false
}) })
},
convertBmpToPng (bmpFile) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = function (event) {
const img = new Image()
img.onload = function () {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
canvas.toBlob(function (blob) {
const newFile = new File([blob], bmpFile.name.replace(/\.\w+$/, '.png'), { type: 'image/png' })
resolve(newFile)
}, 'image/png')
}
img.src = event.target.result
}
reader.onerror = reject
reader.readAsDataURL(bmpFile)
})
} }
} }
} }
+52 -17
View File
@@ -321,10 +321,14 @@
:key="currentField.uuid" :key="currentField.uuid"
v-model="values[currentField.uuid]" v-model="values[currentField.uuid]"
:field="currentField" :field="currentField"
:previous-value="previousSignatureValueFor(currentField)" :previous-value="previousSignatureValueFor(currentField) || previousSignatureValue"
:with-typed-signature="withTypedSignature" :with-typed-signature="withTypedSignature"
:remember-signature="rememberSignature"
:attachments-index="attachmentsIndex" :attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug" :button-text="buttonText"
:with-disclosure="withDisclosure"
:with-qr-button="withQrButton"
:submitter="submitter"
:show-field-names="showFieldNames" :show-field-names="showFieldNames"
@attached="attachments.push($event)" @attached="attachments.push($event)"
@start="scrollIntoField(currentField)" @start="scrollIntoField(currentField)"
@@ -382,7 +386,7 @@
</div> </div>
<div <div
v-if="currentField.type !== 'payment' || submittedValues[currentField.uuid]" v-if="currentField.type !== 'payment' || submittedValues[currentField.uuid]"
class="mt-6 md:mt-8" :class="currentField.type === 'signature' ? 'mt-2' : 'mt-6 md:mt-8'"
> >
<button <button
id="submit_form_button" id="submit_form_button"
@@ -396,11 +400,8 @@
v-if="isSubmitting" v-if="isSubmitting"
class="mr-1 animate-spin" class="mr-1 animate-spin"
/> />
<span v-if="alwaysMinimize || stepFields.length === currentStep + 1"> <span>
{{ t('submit') }} {{ buttonText }}
</span>
<span v-else>
{{ t('next') }}
</span><span </span><span
v-if="isSubmitting" v-if="isSubmitting"
class="w-6 flex justify-start mr-1" class="w-6 flex justify-start mr-1"
@@ -549,11 +550,21 @@ export default {
required: false, required: false,
default: null default: null
}, },
rememberSignature: {
type: Boolean,
required: false,
default: false
},
minimize: { minimize: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false default: false
}, },
withDisclosure: {
type: Boolean,
required: false,
default: false
},
reuseSignature: { reuseSignature: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -574,6 +585,11 @@ export default {
required: false, required: false,
default: true default: true
}, },
withQrButton: {
type: Boolean,
required: false,
default: false
},
withTypedSignature: { withTypedSignature: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -599,6 +615,11 @@ export default {
required: false, required: false,
default: '' default: ''
}, },
previousSignatureValue: {
type: String,
required: false,
default: ''
},
allowToSkip: { allowToSkip: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -614,6 +635,11 @@ export default {
required: false, required: false,
default: false default: false
}, },
dryRun: {
type: Boolean,
required: false,
default: false
},
attribution: { attribution: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -682,11 +708,18 @@ export default {
isMobile () { isMobile () {
return /android|iphone|ipad/i.test(navigator.userAgent) return /android|iphone|ipad/i.test(navigator.userAgent)
}, },
buttonText () {
if (this.alwaysMinimize || this.stepFields.length === this.currentStep + 1) {
return this.t('submit')
} else {
return this.t('next')
}
},
alwaysMinimize () { alwaysMinimize () {
return this.minimize || (this.orientation?.includes('landscape') && this.isMobile && parseInt(window.innerHeight) < 550) return this.minimize || (this.orientation?.includes('landscape') && this.isMobile && parseInt(window.innerHeight) < 550)
}, },
currentStepFields () { currentStepFields () {
return this.stepFields[this.currentStep] return this.stepFields[this.currentStep] || []
}, },
browserLanguage () { browserLanguage () {
return (navigator.language || navigator.userLanguage || 'en').split('-')[0] return (navigator.language || navigator.userLanguage || 'en').split('-')[0]
@@ -763,7 +796,7 @@ export default {
this.isFormVisible = value this.isFormVisible = value
}, },
currentStepFields (value) { currentStepFields (value) {
if (!value) { if (isEmpty(value) && this.currentStep > 0) {
this.currentStep -= 1 this.currentStep -= 1
} }
} }
@@ -956,14 +989,16 @@ export default {
this.$nextTick(() => { this.$nextTick(() => {
this.recalculateButtonDisabledKey = Math.random() this.recalculateButtonDisabledKey = Math.random()
if (scrollToArea) { if (!this.isCompleted) {
this.scrollIntoField(step[0]) if (scrollToArea) {
this.scrollIntoField(step[0])
this.$refs.form.querySelector('input[type="date"], input[type="number"], input[type="text"], select')?.focus() this.$refs.form.querySelector('input[type="date"], input[type="number"], input[type="text"], select')?.focus()
} }
if (clickUpload && !this.values[this.currentField.uuid] && ['file', 'image'].includes(this.currentField.type)) { if (clickUpload && !this.values[this.currentField.uuid] && ['file', 'image'].includes(this.currentField.type)) {
this.$refs.form.querySelector('input[type="file"]')?.click() this.$refs.form.querySelector('input[type="file"]')?.click()
}
} }
}) })
}, },
@@ -1019,7 +1054,7 @@ export default {
const formData = new FormData(this.$refs.form) const formData = new FormData(this.$refs.form)
const isLastStep = this.currentStep === this.stepFields.length - 1 const isLastStep = this.currentStep === this.stepFields.length - 1
if (isLastStep && !emptyRequiredField) { if (isLastStep && !emptyRequiredField && !this.dryRun) {
formData.append('completed', 'true') formData.append('completed', 'true')
} }
+65
View File
@@ -1,7 +1,12 @@
const en = { const en = {
text: 'Text', text: 'Text',
by_clicking_you_agree_to_the: 'By clicking "{button}", you agree to the',
electronic_signature_disclosure: 'Electronic Signature Disclosure',
esignature_disclosure: 'eSignature Disclosure',
signature: 'Signature', signature: 'Signature',
initials: 'Initials', initials: 'Initials',
drawn_signature_on_a_touchscreen_device: 'Drawn signature on a touchscreen device',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Scan the QR code with the camera app to open the form on mobile and draw your signature',
date: 'Date', date: 'Date',
number: 'Number', number: 'Number',
image: 'Image', image: 'Image',
@@ -65,6 +70,11 @@ const en = {
} }
const es = { const es = {
drawn_signature_on_a_touchscreen_device: 'Firma dibujada en un dispositivo con pantalla táctil',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Escanea el código QR con la aplicación de la cámara para abrir el formulario en el móvil y dibujar tu firma',
by_clicking_you_agree_to_the: 'Al hacer clic en "{button}", usted acepta el',
electronic_signature_disclosure: 'Divulgación de Firma Electrónica',
esignature_disclosure: 'Divulgación de eFirma',
minimize: 'Minimizar', minimize: 'Minimizar',
text: 'Texto', text: 'Texto',
signature: 'Firma', signature: 'Firma',
@@ -130,6 +140,11 @@ const es = {
} }
const it = { const it = {
drawn_signature_on_a_touchscreen_device: 'Firma disegnata su un dispositivo con schermo tattile',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Scansiona il codice QR con l\'app della fotocamera per aprire il modulo sul cellulare e disegnare la tua firma',
by_clicking_you_agree_to_the: 'Cliccando su "{button}", accetti il',
electronic_signature_disclosure: 'Divulgazione della Firma Elettronica',
esignature_disclosure: 'Divulgazione della eFirma',
minimize: 'Minimizza', minimize: 'Minimizza',
text: 'Testo', text: 'Testo',
signature: 'Firma', signature: 'Firma',
@@ -195,6 +210,11 @@ const it = {
} }
const de = { const de = {
drawn_signature_on_a_touchscreen_device: 'Gezeichnete Unterschrift auf einem Touchscreen-Gerät',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Scannen Sie den QR-Code mit der Kamera-App, um das Formular auf dem Handy zu öffnen und Ihre Unterschrift zu zeichnen',
by_clicking_you_agree_to_the: 'Durch Klicken auf "{button}" stimmen Sie zu, dass Sie die',
electronic_signature_disclosure: 'Elektronische Unterschriftenoffenlegung',
esignature_disclosure: 'eSignatur Offenlegung',
minimize: 'Minimieren', minimize: 'Minimieren',
text: 'Text', text: 'Text',
signature: 'Unterschrift', signature: 'Unterschrift',
@@ -260,6 +280,11 @@ const de = {
} }
const fr = { const fr = {
drawn_signature_on_a_touchscreen_device: 'Signature dessinée sur un appareil à écran tactile',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Scannez le code QR avec l\'application de l\'appareil photo pour ouvrir le formulaire sur mobile et dessiner votre signature',
by_clicking_you_agree_to_the: 'En cliquant sur "{button}", vous acceptez la',
electronic_signature_disclosure: 'Divulgation de Signature Électronique',
esignature_disclosure: 'Divulgation de la eSignature',
minimize: 'Réduire', minimize: 'Réduire',
text: 'Texte', text: 'Texte',
signature: 'Signature', signature: 'Signature',
@@ -325,6 +350,11 @@ const fr = {
} }
const pl = { const pl = {
drawn_signature_on_a_touchscreen_device: 'Podpis odręczny na urządzeniu z ekranem dotykowym',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Zeskanuj kod QR za pomocą aplikacji aparatu, aby otworzyć formularz na telefonie i narysować swój podpis',
by_clicking_you_agree_to_the: 'Klikając na "{button}", zgadzasz się na',
electronic_signature_disclosure: 'Ujawnienie Elektronicznej Sygnatury',
esignature_disclosure: 'Ujawnienie ePodpisu',
minimize: 'Zminimalizuj', minimize: 'Zminimalizuj',
text: 'Tekst', text: 'Tekst',
signature: 'Podpis', signature: 'Podpis',
@@ -390,6 +420,11 @@ const pl = {
} }
const uk = { const uk = {
drawn_signature_on_a_touchscreen_device: 'Підпис на сенсорному пристрої',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Скануйте QR-код за допомогою програми камери, щоб відкрити форму на мобільному пристрої та намалювати свій підпис',
by_clicking_you_agree_to_the: 'Натиснувши на "{button}", ви погоджуєтеся з',
electronic_signature_disclosure: 'Розголошення Електронного Підпису',
esignature_disclosure: 'Розголошення еПідпису',
minimize: 'Зменшити', minimize: 'Зменшити',
text: 'Текст', text: 'Текст',
signature: 'Підпис', signature: 'Підпис',
@@ -455,6 +490,11 @@ const uk = {
} }
const cs = { const cs = {
drawn_signature_on_a_touchscreen_device: 'Namalovaný podpis na dotykovém zařízení',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Naskenujte QR kód pomocí aplikace fotoaparátu, abyste otevřeli formulář na mobilním zařízení a nakreslili svůj podpis',
by_clicking_you_agree_to_the: 'Kliknutím na "{button}" souhlasíte s',
electronic_signature_disclosure: 'Zveřejněním Elektronického Podpisu',
esignature_disclosure: 'Zveřejnění ePodpisu',
minimize: 'Minimalizovat', minimize: 'Minimalizovat',
text: 'Text', text: 'Text',
signature: 'Podpis', signature: 'Podpis',
@@ -520,6 +560,11 @@ const cs = {
} }
const pt = { const pt = {
drawn_signature_on_a_touchscreen_device: 'Assinatura desenhada em um dispositivo com tela sensível ao toque',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Escaneie o código QR com o aplicativo da câmera para abrir o formulário no celular e desenhar sua assinatura',
by_clicking_you_agree_to_the: 'Ao clicar em "{button}", você concorda com o',
electronic_signature_disclosure: 'Divulgação de Assinatura Eletrônica',
esignature_disclosure: 'Divulgação da eAssinatura',
minimize: 'Minimizar', minimize: 'Minimizar',
text: 'Texto', text: 'Texto',
signature: 'Assinatura', signature: 'Assinatura',
@@ -585,6 +630,11 @@ const pt = {
} }
const he = { const he = {
drawn_signature_on_a_touchscreen_device: 'חתימה שנוצרה במכשיר עם מסך מגע',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'סרוק את קוד ה-QR באמצעות אפליקציית המצלמה כדי לפתוח את הטופס במובייל ולצייר את החתימה שלך',
by_clicking_you_agree_to_the: 'על ידי לחיצה על "{button}", אתה מסכים ל',
electronic_signature_disclosure: 'חשיפת חתימה אלקטרונית',
esignature_disclosure: 'חשיפת ה-eחתימה',
minimize: 'לקטן', minimize: 'לקטן',
text: 'טקסט', text: 'טקסט',
signature: 'חתימה', signature: 'חתימה',
@@ -651,6 +701,11 @@ const he = {
} }
const nl = { const nl = {
drawn_signature_on_a_touchscreen_device: 'Getekende handtekening op een apparaat met een touchscreen',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Scan de QR-code met de camera-app om het formulier op mobiel te openen en uw handtekening te tekenen',
by_clicking_you_agree_to_the: 'Door op "{button}" te klikken, gaat u akkoord met de',
electronic_signature_disclosure: 'Openbaarmaking van Elektronische Handtekening',
esignature_disclosure: 'Openbaarmaking van eHandtekening',
minimize: 'Minimaliseren', minimize: 'Minimaliseren',
text: 'Tekst', text: 'Tekst',
signature: 'Handtekening', signature: 'Handtekening',
@@ -717,6 +772,11 @@ const nl = {
} }
const ar = { const ar = {
drawn_signature_on_a_touchscreen_device: 'توقيع مرسوم على جهاز بشاشة تعمل باللمس',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'امسح رمز الاستجابة السريعة باستخدام تطبيق الكاميرا لفتح النموذج على الهاتف المحمول ورسم توقيعك',
by_clicking_you_agree_to_the: 'بالنقر فوق "{button}"، أنت توافق على',
electronic_signature_disclosure: 'كشف التوقيع الإلكتروني',
esignature_disclosure: 'كشف التوقيع الإلكتروني',
text: 'نص', text: 'نص',
signature: 'توقيع', signature: 'توقيع',
initials: 'الاختصارات', initials: 'الاختصارات',
@@ -783,6 +843,11 @@ const ar = {
} }
const ko = { const ko = {
drawn_signature_on_a_touchscreen_device: '터치스크린 장치에서 그린 서명',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: '카메라 앱으로 QR 코드를 스캔하여 모바일에서 양식을 열고 서명을 그리세요',
by_clicking_you_agree_to_the: '"{button}"를 클릭함으로써, 다음에 동의하게 됩니다',
electronic_signature_disclosure: '전자 서명 공개',
esignature_disclosure: '전자 서명 공개',
text: '텍스트', text: '텍스트',
signature: '서명', signature: '서명',
initials: '이니셜', initials: '이니셜',
@@ -189,7 +189,7 @@ export default {
this.$nextTick(() => { this.$nextTick(() => {
if (this.$refs.canvas) { if (this.$refs.canvas) {
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
this.$refs.canvas.height = (this.$refs.canvas.parentNode.clientWidth / 3) * scale this.$refs.canvas.height = (this.$refs.canvas.parentNode.clientWidth / 4.5) * scale
this.$refs.canvas.getContext('2d').scale(scale, scale) this.$refs.canvas.getContext('2d').scale(scale, scale)
} }
@@ -208,7 +208,7 @@ export default {
entries.forEach(entry => { entries.forEach(entry => {
if (entry.isIntersecting) { if (entry.isIntersecting) {
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
this.$refs.canvas.height = (this.$refs.canvas.parentNode.clientWidth / 3) * scale this.$refs.canvas.height = (this.$refs.canvas.parentNode.clientWidth / 4.5) * scale
this.$refs.canvas.getContext('2d').scale(scale, scale) this.$refs.canvas.getContext('2d').scale(scale, scale)
@@ -10,7 +10,9 @@
:string="field.title" :string="field.title"
/> />
<template v-else>{{ field.name }}</template> <template v-else>{{ field.name }}</template>
<template v-if="!field.required">({{ t('optional') }})</template> <template v-if="!field.required">
({{ t('optional') }})
</template>
</label> </label>
<div <div
v-else v-else
@@ -116,7 +116,7 @@ export default {
methods: { methods: {
async submit () { async submit () {
if (this.sessionId) { if (this.sessionId) {
return fetch((this.baseUrl || '/embed').replace('/embed', '/api/stripe_payments/' + this.sessionId), { return fetch(this.baseUrl + '/api/stripe_payments/' + this.sessionId, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
submitter_slug: this.submitterSlug submitter_slug: this.submitterSlug
@@ -147,11 +147,12 @@ export default {
startCheckout () { startCheckout () {
this.isCreatingCheckout = true this.isCreatingCheckout = true
fetch((this.baseUrl || '/embed').replace('/embed', '/api/stripe_payments'), { fetch(this.baseUrl + '/api/stripe_payments', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
submitter_slug: this.submitterSlug, submitter_slug: this.submitterSlug,
field_uuid: this.field.uuid field_uuid: this.field.uuid,
referer: document.location.href
}), }),
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}).then(async (resp) => { }).then(async (resp) => {
@@ -11,7 +11,9 @@
/> />
<template v-else> <template v-else>
{{ showFieldNames && field.name ? field.name : t('verified_phone_number') }} {{ showFieldNames && field.name ? field.name : t('verified_phone_number') }}
<template v-if="!field.required">({{ t('optional') }})</template> <template v-if="!field.required">
({{ t('optional') }})
</template>
</template> </template>
</label> </label>
<div <div
+212 -23
View File
@@ -1,5 +1,8 @@
<template> <template>
<div dir="auto"> <div
dir="auto"
class="relative"
>
<div <div
class="flex justify-between items-center w-full" class="flex justify-between items-center w-full"
:class="{ 'mb-2': !field.description }" :class="{ 'mb-2': !field.description }"
@@ -25,7 +28,7 @@
id="type_text_button" id="type_text_button"
href="#" href="#"
class="btn btn-outline btn-sm font-medium" class="btn btn-outline btn-sm font-medium"
@click.prevent="toggleTextInput" @click.prevent="[toggleTextInput(), hideQr()]"
> >
<IconSignature :width="16" /> <IconSignature :width="16" />
<span class="hidden sm:inline"> <span class="hidden sm:inline">
@@ -42,7 +45,7 @@
id="type_text_button" id="type_text_button"
href="#" href="#"
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap" class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap"
@click.prevent="toggleTextInput" @click.prevent="[toggleTextInput(), hideQr()]"
> >
<IconTextSize :width="16" /> <IconTextSize :width="16" />
<span class="hidden sm:inline"> <span class="hidden sm:inline">
@@ -80,15 +83,23 @@
<IconReload :width="16" /> <IconReload :width="16" />
{{ t('redraw') }} {{ t('redraw') }}
</a> </a>
<a <span
v-else v-if="withQrButton && !modelValue && !computedPreviousValue && field.preferences?.format !== 'typed'"
href="#" class=" tooltip"
class="btn btn-outline btn-sm font-medium" :data-tip="t('drawn_signature_on_a_touchscreen_device')"
@click.prevent="clear"
> >
<IconReload :width="16" /> <a
{{ t('clear') }} href="#"
</a> class="btn btn-sm btn-neutral font-medium hidden md:flex"
:class="{ 'btn-outline': !isShowQr, 'text-white': isShowQr }"
@click.prevent="isShowQr ? hideQr() : [isTextSignature = false, showQr()]"
>
<IconQrcode
:width="19"
:height="19"
/>
</a>
</span>
<a <a
href="#" href="#"
:title="t('minimize')" :title="t('minimize')"
@@ -118,14 +129,61 @@
<img <img
v-if="modelValue || computedPreviousValue" v-if="modelValue || computedPreviousValue"
:src="attachmentsIndex[modelValue || computedPreviousValue].url" :src="attachmentsIndex[modelValue || computedPreviousValue].url"
class="mx-auto bg-white border border-base-300 rounded max-h-72" class="mx-auto bg-white border border-base-300 rounded max-h-44"
> >
<canvas <div class="relative">
v-show="!modelValue && !computedPreviousValue" <div
ref="canvas" v-if="!modelValue && !computedPreviousValue && !isShowQr && !isTextSignature && isSignatureStarted"
style="padding: 1px; 0" class="absolute top-0.5 right-0.5"
class="bg-white border border-base-300 rounded-2xl w-full" >
/> <a
href="#"
class="btn btn-ghost font-medium btn-xs md:btn-sm"
@click.prevent="[clear(), hideQr()]"
>
<IconReload :width="16" />
{{ t('clear') }}
</a>
</div>
<canvas
v-show="!modelValue && !computedPreviousValue"
ref="canvas"
style="padding: 1px; 0"
class="bg-white border border-base-300 rounded-2xl w-full"
/>
<div
v-if="isShowQr"
class="top-0 bottom-0 right-0 left-0 absolute bg-white rounded-2xl m-0.5"
/>
<div
v-show="isShowQr"
class="top-0 bottom-0 right-0 left-0 absolute bg-base-content/10 rounded-2xl"
>
<div
class="absolute top-1.5 right-1.5 tooltip"
>
<a
href="#"
class="btn btn-sm btn-circle btn-normal btn-outline"
@click.prevent="hideQr"
>
<IconX />
</a>
</div>
<div class="flex items-center justify-center w-full h-full p-4">
<div
v-if="withQrButton"
ref="qr"
class="bg-white p-4 rounded-xl h-full"
>
<canvas
ref="qrCanvas"
class="h-full"
/>
</div>
</div>
</div>
</div>
<input <input
v-if="isTextSignature" v-if="isTextSignature"
id="signature_text_input" id="signature_text_input"
@@ -136,11 +194,39 @@
type="text" type="text"
@input="updateWrittenSignature" @input="updateWrittenSignature"
> >
<div
v-if="isShowQr"
dir="auto"
class="text-base-content/60 text-xs text-center w-full mt-1"
>
{{ t('scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature') }}
</div>
<div
v-else-if="withDisclosure"
dir="auto"
class="text-base-content/60 text-xs text-center w-full mt-1"
>
{{ t('by_clicking_you_agree_to_the').replace('{button}', buttonText.charAt(0).toUpperCase() + buttonText.slice(1)) }} <a
href="https://www.docuseal.co/esign-disclosure"
target="_blank"
>
<span class="inline md:hidden">
{{ t('esignature_disclosure') }}
</span>
<span class="hidden md:inline">
{{ t('electronic_signature_disclosure') }}
</span>
</a>
</div>
<div
v-else
class="mt-5 md:mt-7"
/>
</div> </div>
</template> </template>
<script> <script>
import { IconReload, IconCamera, IconSignature, IconTextSize, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue' import { IconReload, IconCamera, IconSignature, IconTextSize, IconArrowsDiagonalMinimize2, IconQrcode, IconX } from '@tabler/icons-vue'
import { cropCanvasAndExportToPNG } from './crop_canvas' import { cropCanvasAndExportToPNG } from './crop_canvas'
import SignaturePad from 'signature_pad' import SignaturePad from 'signature_pad'
import AppearsOn from './appears_on' import AppearsOn from './appears_on'
@@ -156,7 +242,9 @@ export default {
AppearsOn, AppearsOn,
IconReload, IconReload,
IconCamera, IconCamera,
IconQrcode,
MarkdownContent, MarkdownContent,
IconX,
IconTextSize, IconTextSize,
IconSignature, IconSignature,
IconArrowsDiagonalMinimize2 IconArrowsDiagonalMinimize2
@@ -167,8 +255,8 @@ export default {
type: Object, type: Object,
required: true required: true
}, },
submitterSlug: { submitter: {
type: String, type: Object,
required: true required: true
}, },
showFieldNames: { showFieldNames: {
@@ -176,11 +264,31 @@ export default {
required: false, required: false,
default: true default: true
}, },
withDisclosure: {
type: Boolean,
required: false,
default: false
},
withQrButton: {
type: Boolean,
required: false,
default: false
},
buttonText: {
type: String,
required: false,
default: 'Submit'
},
withTypedSignature: { withTypedSignature: {
type: Boolean, type: Boolean,
required: false, required: false,
default: true default: true
}, },
rememberSignature: {
type: Boolean,
required: false,
default: false
},
attachmentsIndex: { attachmentsIndex: {
type: Object, type: Object,
required: false, required: false,
@@ -201,12 +309,16 @@ export default {
data () { data () {
return { return {
isSignatureStarted: !!this.previousValue, isSignatureStarted: !!this.previousValue,
isShowQr: false,
isUsePreviousValue: true, isUsePreviousValue: true,
isTextSignature: this.field.preferences?.format === 'typed', isTextSignature: this.field.preferences?.format === 'typed',
uploadImageInputKey: Math.random().toString() uploadImageInputKey: Math.random().toString()
} }
}, },
computed: { computed: {
submitterSlug () {
return this.submitter.slug
},
computedPreviousValue () { computedPreviousValue () {
if (this.isUsePreviousValue) { if (this.isUsePreviousValue) {
return this.previousValue return this.previousValue
@@ -223,12 +335,24 @@ export default {
this.$refs.canvas.getContext('2d').scale(scale, scale) this.$refs.canvas.getContext('2d').scale(scale, scale)
} }
if (this.withQrButton) {
import('qr-creator').then(({ default: Qr }) => {
Qr.render({
text: `${document.location.origin}/p/${this.submitterSlug}?f=${this.field.uuid.split('-')[0]}`,
radius: 0.0,
ecLevel: 'H',
background: null,
size: 132
}, this.$refs.qrCanvas)
})
}
}) })
if (this.$refs.canvas) { if (this.$refs.canvas) {
this.pad = new SignaturePad(this.$refs.canvas) this.pad = new SignaturePad(this.$refs.canvas)
this.pad.addEventListener('beginStroke', () => { this.pad.addEventListener('endStroke', () => {
this.isSignatureStarted = true this.isSignatureStarted = true
this.$emit('start') this.$emit('start')
@@ -252,6 +376,7 @@ export default {
}, },
beforeUnmount () { beforeUnmount () {
this.intersectionObserver?.disconnect() this.intersectionObserver?.disconnect()
this.stopCheckSignature()
}, },
methods: { methods: {
remove () { remove () {
@@ -273,6 +398,41 @@ export default {
}) })
} }
}, },
showQr () {
this.isShowQr = true
this.startCheckSignature()
},
hideQr () {
this.isShowQr = false
this.stopCheckSignature()
},
startCheckSignature () {
const after = JSON.stringify(new Date())
this.checkSignatureInterval = setInterval(() => {
this.checkSignature({ after })
}, 2000)
},
stopCheckSignature () {
if (this.checkSignatureInterval) {
clearInterval(this.checkSignatureInterval)
}
},
checkSignature (params = {}) {
return fetch(this.baseUrl + '/s/' + this.submitterSlug + '/values?field_uuid=' + this.field.uuid + '&after=' + params.after, {
method: 'GET'
}).then(async (resp) => {
const { attachment } = await resp.json()
if (attachment?.uuid) {
this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid)
this.hideQr()
}
})
},
clear () { clear () {
this.pad.clear() this.pad.clear()
@@ -280,6 +440,7 @@ export default {
if (this.$refs.textInput) { if (this.$refs.textInput) {
this.$refs.textInput.value = '' this.$refs.textInput.value = ''
this.$refs.textInput.focus()
} }
}, },
updateWrittenSignature (e) { updateWrittenSignature (e) {
@@ -368,6 +529,27 @@ export default {
this.uploadImageInputKey = Math.random().toString() this.uploadImageInputKey = Math.random().toString()
} }
}, },
maybeSetSignedUuid (signedUuid) {
try {
if (window.localStorage && signedUuid && this.rememberSignature) {
const values = window.localStorage.getItem('signed_signature_uuids')
let data
if (values) {
data = JSON.parse(values)
} else {
data = {}
}
data[this.submitter.email] = signedUuid
window.localStorage.setItem('signed_signature_uuids', JSON.stringify(data))
}
} catch (e) {
console.error(e)
}
},
async submit () { async submit () {
if (this.modelValue || this.computedPreviousValue) { if (this.modelValue || this.computedPreviousValue) {
if (this.computedPreviousValue) { if (this.computedPreviousValue) {
@@ -386,6 +568,7 @@ export default {
formData.append('file', file) formData.append('file', file)
formData.append('submitter_slug', this.submitterSlug) formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments') formData.append('name', 'attachments')
formData.append('remember_signature', this.rememberSignature)
return fetch(this.baseUrl + '/api/attachments', { return fetch(this.baseUrl + '/api/attachments', {
method: 'POST', method: 'POST',
@@ -394,10 +577,16 @@ export default {
this.$emit('attached', attachment) this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid) this.$emit('update:model-value', attachment.uuid)
this.maybeSetSignedUuid(attachment.signed_uuid)
return resolve(attachment) return resolve(attachment)
}) })
}).catch((error) => { }).catch((error) => {
return reject(error) if (error.message === 'Image too small' && this.field.required === false) {
return resolve({})
} else {
return reject(error)
}
}) })
}) })
} }
+3 -1
View File
@@ -10,7 +10,9 @@
:string="field.title" :string="field.title"
/> />
<template v-else>{{ field.name }}</template> <template v-else>{{ field.name }}</template>
<template v-if="!field.required">({{ t('optional') }})</template> <template v-if="!field.required">
({{ t('optional') }})
</template>
</label> </label>
<div <div
v-else v-else
+273 -31
View File
@@ -3,7 +3,7 @@
class="absolute overflow-visible group" class="absolute overflow-visible group"
:style="positionStyle" :style="positionStyle"
@pointerdown.stop @pointerdown.stop
@mousedown.stop="startDrag" @mousedown="startMouseMove"
@touchstart="startTouchDrag" @touchstart="startTouchDrag"
> >
<div <div
@@ -65,15 +65,17 @@
dir="auto" dir="auto"
class="pr-1 cursor-text outline-none block" class="pr-1 cursor-text outline-none block"
style="min-width: 2px" style="min-width: 2px"
@paste.prevent="onPaste"
@keydown.enter.prevent="onNameEnter" @keydown.enter.prevent="onNameEnter"
@focus="onNameFocus" @focus="onNameFocus"
@blur="onNameBlur" @blur="onNameBlur"
>{{ optionIndexText }} {{ (defaultField ? (field.title || field.name) : field.name) || defaultName }}</span> >{{ optionIndexText }} {{ (defaultField ? (field.title || field.name) : field.name) || defaultName }}</span>
<div <div
v-if="isNameFocus && !['checkbox', 'phone'].includes(field.type)" v-if="isSettingsFocus || isContenteditable || (isNameFocus && !['checkbox', 'phone'].includes(field.type))"
class="flex items-center ml-1.5" class="flex items-center ml-1.5"
> >
<input <input
v-if="!isContenteditable"
:id="`required-checkbox-${field.uuid}`" :id="`required-checkbox-${field.uuid}`"
v-model="field.required" v-model="field.required"
type="checkbox" type="checkbox"
@@ -81,11 +83,69 @@
@mousedown.prevent @mousedown.prevent
> >
<label <label
v-if="!isContenteditable"
:for="`required-checkbox-${field.uuid}`" :for="`required-checkbox-${field.uuid}`"
class="label text-xs" class="label text-xs"
@click.prevent="field.required = !field.required" @click.prevent="field.required = !field.required"
@mousedown.prevent @mousedown.prevent
>{{ t('required') }}</label> >{{ t('required') }}</label>
<input
v-if="isContenteditable"
:id="`readonly-checkbox-${field.uuid}`"
type="checkbox"
class="checkbox checkbox-xs no-animation rounded"
:checked="!(field.readonly ?? true)"
@change="field.readonly = !(field.readonly ?? true)"
@mousedown.prevent
>
<label
v-if="isContenteditable"
:for="`readonly-checkbox-${field.uuid}`"
class="label text-xs"
@click.prevent="field.readonly = !(field.readonly ?? true)"
@mousedown.prevent
>{{ t('editable') }}</label>
<span
v-if="field.type !== 'payment' && !isContenteditable"
class="dropdown dropdown-end"
@mouseenter="renderDropdown = true"
@touchstart="renderDropdown = true"
>
<label
ref="settingsButton"
tabindex="0"
:title="t('settings')"
class="cursor-pointer flex items-center"
style="height: 25px"
@focus="isSettingsFocus = true"
@blur="maybeBlurSettings"
>
<IconDotsVertical class="w-5 h-5" />
</label>
<ul
v-if="renderDropdown"
ref="settingsDropdown"
tabindex="0"
class="dropdown-content menu menu-xs px-2 pb-2 pt-1 shadow rounded-box w-52 z-10 rounded-t-none"
:style="{ backgroundColor: 'white' }"
@dragstart.prevent.stop
@click="closeDropdown"
@focusout="maybeBlurSettings"
>
<FieldSettings
:field="field"
:default-field="defaultField"
:editable="editable"
:background-color="'white'"
:with-required="false"
:with-areas="false"
@click-formula="isShowFormulaModal = true"
@click-description="isShowDescriptionModal = true"
@click-condition="isShowConditionsModal = true"
@scroll-to="[selectedAreaRef.value = $event, $emit('scroll-to', $event)]"
/>
</ul>
</span>
</div> </div>
<button <button
v-else-if="editable" v-else-if="editable"
@@ -99,7 +159,7 @@
<div <div
class="flex items-center h-full w-full" class="flex items-center h-full w-full"
dir="auto" dir="auto"
:class="[bgColors[submitterIndex], field?.default_value ? (alignClasses[field.preferences?.align] || '') : 'justify-center']" :class="[isContenteditable ? 'bg-opacity-50' : 'bg-opacity-80', bgColors[submitterIndex], isDefaultValuePresent || isContenteditable ? (alignClasses[field.preferences?.align] || '') : 'justify-center']"
> >
<span <span
v-if="field" v-if="field"
@@ -107,7 +167,7 @@
:class="{'w-full h-full': field.type == 'checkbox'}" :class="{'w-full h-full': field.type == 'checkbox'}"
> >
<div <div
v-if="field?.default_value" v-if="isDefaultValuePresent || isContenteditable"
:class="{ 'w-full h-full': field.type == 'checkbox', 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars }" :class="{ 'w-full h-full': field.type == 'checkbox', 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars }"
> >
<div <div
@@ -121,8 +181,19 @@
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }" :class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
/> />
<span <span
v-else v-else-if="field.type === 'number' && !isContenteditable"
class="whitespace-pre-wrap" class="whitespace-pre-wrap"
>{{ formatNumber(field.default_value, field.preferences?.format) }}</span>
<span
v-else
ref="defaultValue"
:contenteditable="isContenteditable"
class="whitespace-pre-wrap outline-none empty:before:content-[attr(placeholder)] before:text-gray-400"
:class="{ 'cursor-text': isContenteditable }"
:placeholder="t('type_value')"
@blur="onDefaultValueBlur"
@paste.prevent="onPaste"
@keydown.enter.prevent="onDefaultValueEnter"
>{{ field.default_value }}</span> >{{ field.default_value }}</span>
</div> </div>
</div> </div>
@@ -136,8 +207,11 @@
</span> </span>
</div> </div>
<div <div
v-if="!isContenteditable"
ref="touchTarget" ref="touchTarget"
class="absolute top-0 bottom-0 right-0 left-0 cursor-pointer" class="absolute top-0 bottom-0 right-0 left-0"
:class="isDragged ? 'cursor-grab' : 'cursor-pointer'"
@dblclick="maybeFocusDefaultValue"
/> />
<span <span
v-if="field?.type && editable" v-if="field?.type && editable"
@@ -145,6 +219,38 @@
@mousedown.stop="startResize" @mousedown.stop="startResize"
@touchstart="startTouchResize" @touchstart="startTouchResize"
/> />
<Teleport
v-if="isShowFormulaModal"
:to="modalContainerEl"
>
<FormulaModal
:field="field"
:editable="editable && !defaultField"
:build-default-name="buildDefaultName"
@close="isShowFormulaModal = false"
/>
</Teleport>
<Teleport
v-if="isShowConditionsModal"
:to="modalContainerEl"
>
<ConditionsModal
:field="field"
:build-default-name="buildDefaultName"
@close="isShowConditionsModal = false"
/>
</Teleport>
<Teleport
v-if="isShowDescriptionModal"
:to="modalContainerEl"
>
<DescriptionModal
:field="field"
:editable="editable && !defaultField"
:build-default-name="buildDefaultName"
@close="isShowDescriptionModal = false"
/>
</Teleport>
</div> </div>
</template> </template>
@@ -152,7 +258,11 @@
import FieldSubmitter from './field_submitter' import FieldSubmitter from './field_submitter'
import FieldType from './field_type' import FieldType from './field_type'
import Field from './field' import Field from './field'
import { IconX, IconCheck } from '@tabler/icons-vue' import FieldSettings from './field_settings'
import FormulaModal from './formula_modal'
import ConditionsModal from './conditions_modal'
import DescriptionModal from './description_modal'
import { IconX, IconCheck, IconDotsVertical } from '@tabler/icons-vue'
import { v4 } from 'uuid' import { v4 } from 'uuid'
export default { export default {
@@ -160,6 +270,11 @@ export default {
components: { components: {
FieldType, FieldType,
IconCheck, IconCheck,
FieldSettings,
FormulaModal,
IconDotsVertical,
DescriptionModal,
ConditionsModal,
FieldSubmitter, FieldSubmitter,
IconX IconX
}, },
@@ -195,11 +310,17 @@ export default {
default: null default: null
} }
}, },
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove'], emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to'],
data () { data () {
return { return {
isShowFormulaModal: false,
isShowConditionsModal: false,
isContenteditable: false,
isSettingsFocus: false,
isShowDescriptionModal: false,
isResize: false, isResize: false,
isDragged: false, isDragged: false,
renderDropdown: false,
isNameFocus: false, isNameFocus: false,
textOverflowChars: 0, textOverflowChars: 0,
dragFrom: { x: 0, y: 0 } dragFrom: { x: 0, y: 0 }
@@ -208,6 +329,12 @@ export default {
computed: { computed: {
fieldNames: FieldType.computed.fieldNames, fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons, fieldIcons: FieldType.computed.fieldIcons,
isDefaultValuePresent () {
return this.field?.default_value || this.field?.default_value === 0
},
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
},
defaultName () { defaultName () {
return this.buildDefaultName(this.field, this.template.fields) return this.buildDefaultName(this.field, this.template.fields)
}, },
@@ -270,26 +397,26 @@ export default {
}, },
bgColors () { bgColors () {
return [ return [
'bg-red-100/80', 'bg-red-100',
'bg-sky-100/80', 'bg-sky-100',
'bg-emerald-100/80', 'bg-emerald-100',
'bg-yellow-100/80', 'bg-yellow-100',
'bg-purple-100/80', 'bg-purple-100',
'bg-pink-100/80', 'bg-pink-100',
'bg-cyan-100/80', 'bg-cyan-100',
'bg-orange-100/80', 'bg-orange-100',
'bg-lime-100/80', 'bg-lime-100',
'bg-indigo-100/80', 'bg-indigo-100',
'bg-red-100/80', 'bg-red-100',
'bg-sky-100/80', 'bg-sky-100',
'bg-emerald-100/80', 'bg-emerald-100',
'bg-yellow-100/80', 'bg-yellow-100',
'bg-purple-100/80', 'bg-purple-100',
'bg-pink-100/80', 'bg-pink-100',
'bg-cyan-100/80', 'bg-cyan-100',
'bg-orange-100/80', 'bg-orange-100',
'bg-lime-100/80', 'bg-lime-100',
'bg-indigo-100/80' 'bg-indigo-100'
] ]
}, },
isSelected () { isSelected () {
@@ -324,6 +451,41 @@ export default {
}, },
methods: { methods: {
buildDefaultName: Field.methods.buildDefaultName, buildDefaultName: Field.methods.buildDefaultName,
closeDropdown () {
document.activeElement.blur()
},
maybeFocusDefaultValue () {
if (['text', 'number'].includes(this.field.type)) {
this.isContenteditable = true
this.$nextTick(() => {
this.$refs.defaultValue.focus()
if (this.$refs.defaultValue.innerText.length) {
window.getSelection().collapse(
this.$refs.defaultValue.firstChild,
this.$refs.defaultValue.innerText.length
)
}
})
}
},
formatNumber (number, format) {
if (format === 'comma') {
return new Intl.NumberFormat('en-US').format(number)
} else if (format === 'dot') {
return new Intl.NumberFormat('de-DE').format(number)
} else if (format === 'space') {
return new Intl.NumberFormat('fr-FR').format(number)
} else {
return number
}
},
maybeBlurSettings (e) {
if (!e.relatedTarget || !this.$refs.settingsDropdown.contains(e.relatedTarget)) {
this.isSettingsFocus = false
}
},
onNameFocus (e) { onNameFocus (e) {
this.selectedAreaRef.value = this.area this.selectedAreaRef.value = this.area
@@ -350,6 +512,9 @@ export default {
this.save() this.save()
}, },
onPaste (e) {
e.target.innerText = (e.clipboardData || window.clipboardData).getData('text/plain')
},
onResizeCell (e) { onResizeCell (e) {
if (e.target.id === 'mask') { if (e.target.id === 'mask') {
const positionX = e.offsetX / (e.target.clientWidth - 1) const positionX = e.offsetX / (e.target.clientWidth - 1)
@@ -379,6 +544,10 @@ export default {
}) })
}, },
onNameBlur (e) { onNameBlur (e) {
if (e.relatedTarget === this.$refs.settingsButton) {
this.isSettingsFocus = true
}
const text = this.$refs.name.innerText.trim() const text = this.$refs.name.innerText.trim()
this.isNameFocus = false this.isNameFocus = false
@@ -393,6 +562,38 @@ export default {
this.save() this.save()
}, },
onDefaultValueBlur (e) {
const text = this.$refs.defaultValue.innerText.trim()
this.isContenteditable = false
if (text) {
if (this.field.type === 'number') {
const number = parseFloat(text)
if (number || number === 0) {
this.field.default_value = parseFloat(text)
}
} else {
this.field.default_value = text
}
if (![true, false].includes(this.field.readonly)) {
this.field.readonly = true
}
this.$refs.defaultValue.innerText = text
} else {
delete this.field.readonly
delete this.field.default_value
this.$refs.defaultValue.innerText = ''
}
this.save()
},
onDefaultValueEnter (e) {
this.$refs.defaultValue.blur()
},
onNameEnter (e) { onNameEnter (e) {
this.$refs.name.blur() this.$refs.name.blur()
}, },
@@ -431,7 +632,7 @@ export default {
return return
} }
this.$refs?.name?.blur() document.activeElement?.blur()
e.preventDefault() e.preventDefault()
@@ -452,8 +653,8 @@ export default {
const page = this.$parent.$refs.mask.previousSibling const page = this.$parent.$refs.mask.previousSibling
const rect = page.getBoundingClientRect() const rect = page.getBoundingClientRect()
this.area.x = (this.dragFrom.x + e.touches[0].clientX - rect.left) / rect.width this.area.x = Math.min(Math.max((this.dragFrom.x + e.touches[0].clientX - rect.left) / rect.width, 0), 1 - this.area.w)
this.area.y = (this.dragFrom.y + e.touches[0].clientY - rect.top) / rect.height this.area.y = Math.min(Math.max((this.dragFrom.y + e.touches[0].clientY - rect.top) / rect.height, 0), 1 - this.area.h)
}, },
stopTouchDrag () { stopTouchDrag () {
this.$el.getRootNode().removeEventListener('touchmove', this.touchDrag) this.$el.getRootNode().removeEventListener('touchmove', this.touchDrag)
@@ -467,6 +668,47 @@ export default {
this.$emit('stop-drag') this.$emit('stop-drag')
}, },
startMouseMove (e) {
if (e.target !== this.$refs.touchTarget) {
return
}
document.activeElement?.blur()
e.preventDefault()
this.isDragged = true
const rect = e.target.getBoundingClientRect()
this.selectedAreaRef.value = this.area
this.dragFrom = { x: rect.left - e.clientX, y: rect.top - e.clientY }
this.$el.getRootNode().addEventListener('mousemove', this.mouseMove)
this.$el.getRootNode().addEventListener('mouseup', this.stopMouseMove)
this.$emit('start-drag')
},
mouseMove (e) {
const page = this.$parent.$refs.mask.previousSibling
const rect = page.getBoundingClientRect()
this.area.x = Math.min(Math.max((this.dragFrom.x + e.clientX - rect.left) / rect.width, 0), 1 - this.area.w)
this.area.y = Math.min(Math.max((this.dragFrom.y + e.clientY - rect.top) / rect.height, 0), 1 - this.area.h)
},
stopMouseMove (e) {
this.$el.getRootNode().removeEventListener('mousemove', this.mouseMove)
this.$el.getRootNode().removeEventListener('mouseup', this.stopMouseMove)
if (this.isDragged) {
this.save()
}
this.isDragged = false
this.$emit('stop-drag')
},
stopDrag () { stopDrag () {
this.$el.getRootNode().removeEventListener('mousemove', this.drag) this.$el.getRootNode().removeEventListener('mousemove', this.drag)
this.$el.getRootNode().removeEventListener('mouseup', this.stopDrag) this.$el.getRootNode().removeEventListener('mouseup', this.stopDrag)
+40 -12
View File
@@ -857,7 +857,7 @@ export default {
type = this.fieldTypes[0] type = this.fieldTypes[0]
} }
if (type === 'checkbox' && !this.drawFieldType) { if (type === 'checkbox' && !this.drawFieldType && (this.template.fields[this.template.fields.length - 1]?.type === 'checkbox' || area.w)) {
const previousField = [...this.template.fields].reverse().find((f) => f.type === type) const previousField = [...this.template.fields].reverse().find((f) => f.type === type)
const previousArea = previousField?.areas?.[previousField.areas.length - 1] const previousArea = previousField?.areas?.[previousField.areas.length - 1]
@@ -1116,30 +1116,58 @@ export default {
if (!this.isAllRequiredFieldsAdded) { if (!this.isAllRequiredFieldsAdded) {
e.preventDefault() e.preventDefault()
return alert(this.t('add_all_required_fields_to_continue')) const fields = this.defaultRequiredFields?.filter((f) => {
return !this.template.fields?.some((field) => field.name === f.name)
})
if (fields?.length) {
return alert(this.t('add_all_required_fields_to_continue') + ': ' + fields.map((f) => f.name).join(', '))
}
} }
if (!this.template.fields.length) { if (!this.template.fields.length) {
e.preventDefault() e.preventDefault()
alert('Please draw fields to prepare the document.') alert('Please draw fields to prepare the document.')
} else {
const submitterWithoutFields =
this.template.submitters.find((submitter) => !this.template.fields.some((f) => f.submitter_uuid === submitter.uuid))
if (submitterWithoutFields) {
e.preventDefault()
alert(`Please add fields for the ${submitterWithoutFields.name}. Or, remove the ${submitterWithoutFields.name} if not needed.`)
}
} }
}, },
onSaveClick () { onSaveClick () {
if (!this.isAllRequiredFieldsAdded) { if (!this.isAllRequiredFieldsAdded) {
return alert(this.t('add_all_required_fields_to_continue')) const fields = this.defaultRequiredFields?.filter((f) => {
return !this.template.fields?.some((field) => field.name === f.name)
})
if (fields?.length) {
return alert(this.t('add_all_required_fields_to_continue') + ': ' + fields.map((f) => f.name).join(', '))
}
} }
if (this.template.fields.length) { if (!this.template.fields.length) {
this.isSaving = true
this.save().then(() => {
window.Turbo.visit(`/templates/${this.template.id}`)
}).finally(() => {
this.isSaving = false
})
} else {
alert('Please draw fields to prepare the document.') alert('Please draw fields to prepare the document.')
} else {
const submitterWithoutFields =
this.template.submitters.find((submitter) => !this.template.fields.some((f) => f.submitter_uuid === submitter.uuid))
if (submitterWithoutFields) {
alert(`Please add fields for the ${submitterWithoutFields.name}. Or, remove the ${submitterWithoutFields.name} if not needed.`)
} else {
this.isSaving = true
this.save().then(() => {
window.Turbo.visit(`/templates/${this.template.id}`)
}).finally(() => {
this.isSaving = false
})
}
} }
}, },
scrollToArea (area) { scrollToArea (area) {
@@ -29,21 +29,38 @@
>Available in Pro</a> >Available in Pro</a>
</div> </div>
<form @submit.prevent="validateSaveAndClose"> <form @submit.prevent="validateSaveAndClose">
<div class="my-4"> <div class="my-4 space-y-5">
<div class="space-y-4"> <div
v-for="(condition, cindex) in conditions"
:key="cindex"
class="space-y-4 relative"
>
<div
v-if="conditions.length > 1"
class="flex justify-between border-b mx-1 -mb-1 pb-1"
>
<span class="text-sm">
{{ t('condition') }} {{ cindex + 1 }}
</span>
<a
href="#"
class="link text-sm"
@click.prevent="conditions.splice(cindex, 1)"
> {{ t('remove') }}</a>
</div>
<select <select
class="select select-bordered select-sm w-full bg-white h-11 pl-4 text-base font-normal" class="select select-bordered select-sm w-full bg-white h-11 pl-4 text-base font-normal"
required required
@change="[ @change="[
newCondition.field_uuid = $event.target.value, condition.field_uuid = $event.target.value,
delete newCondition.value, delete condition.value,
(conditionActions.includes(newCondition.action) ? '' : newCondition.action = conditionActions[0]) (conditionActions(condition).includes(condition.action) ? '' : condition.action = conditionActions(condition)[0])
]" ]"
> >
<option <option
value="" value=""
disabled disabled
:selected="!newCondition.field_uuid" :selected="!condition.field_uuid"
> >
{{ t('select_field_') }} {{ t('select_field_') }}
</option> </option>
@@ -51,19 +68,19 @@
v-for="f in fields" v-for="f in fields"
:key="f.uuid" :key="f.uuid"
:value="f.uuid" :value="f.uuid"
:selected="newCondition.field_uuid === f.uuid" :selected="condition.field_uuid === f.uuid"
> >
{{ f.name || buildDefaultName(f, template.fields) }} {{ f.name || buildDefaultName(f, template.fields) }}
</option> </option>
</select> </select>
<select <select
v-model="newCondition.action" v-model="condition.action"
class="select select-bordered select-sm w-full h-11 pl-4 text-base font-normal" class="select select-bordered select-sm w-full h-11 pl-4 text-base font-normal"
:class="{ 'bg-white': newCondition.field_uuid, 'bg-base-300': !newCondition.field_uuid }" :class="{ 'bg-white': condition.field_uuid, 'bg-base-300': !condition.field_uuid }"
:required="newCondition.field_uuid" :required="condition.field_uuid"
> >
<option <option
v-for="action in conditionActions" v-for="action in conditionActions(condition)"
:key="action" :key="action"
:value="action" :value="action"
> >
@@ -71,10 +88,10 @@
</option> </option>
</select> </select>
<select <select
v-if="conditionField?.options?.length" v-if="conditionField(condition)?.options?.length"
class="select select-bordered select-sm w-full bg-white h-11 pl-4 text-base font-normal" class="select select-bordered select-sm w-full bg-white h-11 pl-4 text-base font-normal"
required required
@change="newCondition.value = $event.target.value" @change="condition.value = $event.target.value"
> >
<option <option
value="" value=""
@@ -84,16 +101,21 @@
{{ t('select_value_') }} {{ t('select_value_') }}
</option> </option>
<option <option
v-for="(option, index) in conditionField.options" v-for="(option, index) in conditionField(condition).options"
:key="option.uuid" :key="option.uuid"
:value="option.uuid" :value="option.uuid"
:selected="newCondition.value === option.uuid" :selected="condition.value === option.uuid"
> >
{{ option.value || `${t('option')} ${index + 1}` }} {{ option.value || `${t('option')} ${index + 1}` }}
</option> </option>
</select> </select>
</div> </div>
</div> </div>
<a
href="#"
class="inline float-right link text-right mb-3 px-2"
@click.prevent="conditions.push({})"
> + {{ t('add_condition') }}</a>
<button <button
class="base-button w-full mt-2" class="base-button w-full mt-2"
> >
@@ -106,7 +128,7 @@
> >
<button <button
class="link" class="link"
@click="[newCondition.field_uuid = null, delete field.conditions, validateSaveAndClose()]" @click="[conditions = [], delete field.conditions, validateSaveAndClose()]"
> >
{{ t('remove_condition') }} {{ t('remove_condition') }}
</button> </button>
@@ -133,16 +155,10 @@ export default {
emits: ['close'], emits: ['close'],
data () { data () {
return { return {
newCondition: this.field.conditions?.[0] || {} conditions: this.field.conditions?.[0] ? JSON.parse(JSON.stringify(this.field.conditions)) : [{}]
} }
}, },
computed: { computed: {
conditionField () {
return this.fields.find((f) => f.uuid === this.newCondition.field_uuid)
},
conditionActions () {
return this.fieldActions(this.conditionField)
},
fields () { fields () {
return this.template.fields.reduce((acc, f) => { return this.template.fields.reduce((acc, f) => {
if (f !== this.field && f.submitter_uuid === this.field.submitter_uuid) { if (f !== this.field && f.submitter_uuid === this.field.submitter_uuid) {
@@ -157,6 +173,12 @@ export default {
this.field.conditions ||= [] this.field.conditions ||= []
}, },
methods: { methods: {
conditionField (condition) {
return this.fields.find((f) => f.uuid === condition.field_uuid)
},
conditionActions (condition) {
return this.fieldActions(this.conditionField(condition))
},
fieldActions (field) { fieldActions (field) {
const actions = [] const actions = []
@@ -181,8 +203,8 @@ export default {
return alert('Available only in Pro') return alert('Available only in Pro')
} }
if (this.newCondition.field_uuid) { if (this.conditions.find((f) => f.field_uuid)) {
this.field.conditions = [this.newCondition] this.field.conditions = this.conditions
} else { } else {
delete this.field.conditions delete this.field.conditions
} }
@@ -10,14 +10,12 @@
style="min-width: 2px" style="min-width: 2px"
:class="iconInline ? 'inline' : 'block'" :class="iconInline ? 'inline' : 'block'"
class="peer outline-none focus:block" class="peer outline-none focus:block"
@paste.prevent="onPaste"
@keydown.enter.prevent="blurContenteditable" @keydown.enter.prevent="blurContenteditable"
@focus="$emit('focus', $event)" @focus="$emit('focus', $event)"
@blur="onBlur" @blur="onBlur"
> >
<template {{ value }}
v-for="(item, index) in (value || '').split(/(_)/)"
:key="index"
>{{ item }}<wbr></template>
</span> </span>
<span <span
v-if="withRequired" v-if="withRequired"
@@ -100,6 +98,9 @@ export default {
} }
}, },
methods: { methods: {
onPaste (e) {
e.target.innerText = (e.clipboardData || window.clipboardData).getData('text/plain')
},
selectContent () { selectContent () {
const el = this.$refs.contenteditable const el = this.$refs.contenteditable
+4 -1
View File
@@ -17,6 +17,7 @@
:image="image" :image="image"
@drop-field="$emit('drop-field', {...$event, attachment_uuid: document.uuid })" @drop-field="$emit('drop-field', {...$event, attachment_uuid: document.uuid })"
@remove-area="$emit('remove-area', $event)" @remove-area="$emit('remove-area', $event)"
@scroll-to="scrollToArea"
@draw="$emit('draw', {...$event, attachment_uuid: document.uuid })" @draw="$emit('draw', {...$event, attachment_uuid: document.uuid })"
/> />
</div> </div>
@@ -125,7 +126,9 @@ export default {
}, },
methods: { methods: {
scrollToArea (area) { scrollToArea (area) {
this.pageRefs[area.page].areaRefs.find((e) => e.area === area).$el.scrollIntoView({ behavior: 'smooth', block: 'center' }) this.$nextTick(() => {
this.pageRefs[area.page].areaRefs.find((e) => e.area === area).$el.scrollIntoView({ behavior: 'smooth', block: 'center' })
})
}, },
setPageRefs (el) { setPageRefs (el) {
if (el) { if (el) {
+16 -355
View File
@@ -57,7 +57,7 @@
class="flex items-center space-x-1" class="flex items-center space-x-1"
> >
<button <button
v-if="field && !field.areas.length" v-if="field && !field.areas?.length"
:title="t('draw')" :title="t('draw')"
class="relative cursor-pointer text-transparent group-hover:text-base-content" class="relative cursor-pointer text-transparent group-hover:text-base-content"
@click="$emit('set-draw', { field })" @click="$emit('set-draw', { field })"
@@ -92,6 +92,7 @@
<PaymentSettings <PaymentSettings
v-if="field.type === 'payment'" v-if="field.type === 'payment'"
:field="field" :field="field"
@click-condition="isShowConditionsModal = true"
/> />
<span <span
v-else v-else
@@ -118,280 +119,17 @@
@dragstart.prevent.stop @dragstart.prevent.stop
@click="closeDropdown" @click="closeDropdown"
> >
<div <FieldSettings
v-if="['number'].includes(field.type)" :field="field"
class="py-1.5 px-1 relative" :default-field="defaultField"
@click.stop :editable="editable"
> :background-color="dropdownBgColor"
<select @click-formula="isShowFormulaModal = true"
class="select select-bordered select-xs w-full max-w-xs h-7 !outline-0 font-normal bg-transparent" @click-description="isShowDescriptionModal = true"
@change="[field.preferences ||= {}, field.preferences.align = $event.target.value, save()]" @click-condition="isShowConditionsModal = true"
> @set-draw="$emit('set-draw', $event)"
<option @scroll-to="$emit('scroll-to', $event)"
v-for="value in ['left', 'right', 'center']" />
:key="value"
:selected="field.preferences?.align ? value === field.preferences.align : value === 'left'"
:value="value"
>
{{ t(value) }}
</option>
</select>
<label
:style="{ backgroundColor: dropdownBgColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('align') }}
</label>
</div>
<div
v-if="['text', 'number'].includes(field.type) && !defaultField"
class="py-1.5 px-1 relative"
@click.stop
>
<input
v-model="field.default_value"
:placeholder="t('default_value')"
dir="auto"
:type="field.type"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0 bg-transparent"
@blur="save"
>
<label
v-if="field.default_value"
:style="{ backgroundColor: dropdownBgColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('default_value') }}
</label>
</div>
<div
v-if="field.type === 'date'"
class="py-1.5 px-1 relative"
@click.stop
>
<select
v-model="field.preferences.format"
:placeholder="t('format')"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="save"
>
<option
v-for="format in dateFormats"
:key="format"
:value="format"
>
{{ formatDate(new Date(), format) }}
</option>
</select>
<label
:style="{ backgroundColor: dropdownBgColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('format') }}
</label>
</div>
<div
v-if="field.type === 'signature'"
class="py-1.5 px-1 relative"
@click.stop
>
<select
:placeholder="t('format')"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="[field.preferences.format = $event.target.value, save()]"
>
<option
value="any"
:selected="!field.preferences?.format || field.preferences.format === 'any'"
>
{{ t('any') }}
</option>
<option
value="drawn"
:selected="field.preferences?.format === 'drawn'"
>
{{ t('drawn') }}
</option>
<option
value="typed"
:selected="field.preferences?.format === 'typed'"
>
{{ t('typed') }}
</option>
</select>
<label
:style="{ backgroundColor: dropdownBgColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('format') }}
</label>
</div>
<li
v-if="field.type != 'phone' && field.type != 'stamp'"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
v-model="field.required"
type="checkbox"
:disabled="!editable || defaultField"
class="toggle toggle-xs"
@update:model-value="save"
>
<span class="label-text">{{ t('required') }}</span>
</label>
</li>
<li
v-if="field.type == 'stamp'"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
:checked="field.preferences?.with_logo != false"
type="checkbox"
class="toggle toggle-xs"
@change="[field.preferences ||= {}, field.preferences.with_logo = field.preferences.with_logo == false, save()]"
>
<span class="label-text">{{ t('with_logo') }}</span>
</label>
</li>
<li
v-if="field.type == 'checkbox'"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
v-model="field.default_value"
type="checkbox"
class="toggle toggle-xs"
@update:model-value="[field.default_value = $event, field.readonly = $event, save()]"
>
<span class="label-text">{{ t('checked') }}</span>
</label>
</li>
<li
v-if="field.type == 'date'"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
v-model="field.readonly"
type="checkbox"
class="toggle toggle-xs"
@update:model-value="[field.default_value = $event ? '{{date}}' : null, field.readonly = $event, save()]"
>
<span class="label-text">{{ t('set_signing_date') }}</span>
</label>
</li>
<li
v-if="['text', 'number'].includes(field.type) && !defaultField"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
v-model="field.readonly"
type="checkbox"
class="toggle toggle-xs"
@update:model-value="save"
>
<span class="label-text">{{ t('read_only') }}</span>
</label>
</li>
<hr
v-if="field.type != 'stamp'"
class="pb-0.5 mt-0.5"
>
<li
v-if="field.type != 'stamp'"
>
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="isShowDescriptionModal = !isShowDescriptionModal"
>
<IconInfoCircle
width="18"
/>
<span class="text-sm">
{{ t('description') }}
</span>
</label>
</li>
<li
v-if="field.type != 'stamp'"
>
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="isShowConditionsModal = !isShowConditionsModal"
>
<IconRouteAltLeft
width="18"
/>
<span class="text-sm">
{{ t('condition') }}
</span>
</label>
</li>
<li v-if="field.type == 'number'">
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="isShowFormulaModal = true"
>
<IconMathFunction
width="18"
/>
<span class="text-sm">
{{ t('formula') }}
</span>
</label>
</li>
<hr class="pb-0.5 mt-0.5">
<li
v-for="(area, index) in sortedAreas"
:key="index"
>
<a
href="#"
class="text-sm py-1 px-2"
@click.prevent="$emit('scroll-to', area)"
>
<IconShape
:width="20"
:stroke-width="1.6"
/>
{{ t('page') }}
<template v-if="template.schema.length > 1">{{ template.schema.findIndex((item) => item.attachment_uuid === area.attachment_uuid) + 1 }}-</template>{{ area.page + 1 }}
</a>
</li>
<li v-if="!field.areas?.length || !['radio', 'multiple'].includes(field.type)">
<a
href="#"
class="text-sm py-1 px-2"
@click.prevent="$emit('set-draw', { field })"
>
<IconNewSection
:width="20"
:stroke-width="1.6"
/>
{{ t('draw_new_area') }}
</a>
</li>
<li v-if="field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells'].includes(field.type)">
<a
href="#"
class="text-sm py-1 px-2"
@click.prevent="copyToAllPages(field)"
>
<IconCopy
:width="20"
:stroke-width="1.6"
/>
{{ t('copy_to_all_pages') }}
</a>
</li>
</ul> </ul>
</span> </span>
<button <button
@@ -518,10 +256,11 @@
import Contenteditable from './contenteditable' import Contenteditable from './contenteditable'
import FieldType from './field_type' import FieldType from './field_type'
import PaymentSettings from './payment_settings' import PaymentSettings from './payment_settings'
import FieldSettings from './field_settings'
import FormulaModal from './formula_modal' import FormulaModal from './formula_modal'
import ConditionsModal from './conditions_modal' import ConditionsModal from './conditions_modal'
import DescriptionModal from './description_modal' import DescriptionModal from './description_modal'
import { IconInfoCircle, IconRouteAltLeft, IconMathFunction, IconShape, IconNewSection, IconTrashX, IconCopy, IconSettings } from '@tabler/icons-vue' import { IconRouteAltLeft, IconMathFunction, IconNewSection, IconTrashX, IconSettings } from '@tabler/icons-vue'
import { v4 } from 'uuid' import { v4 } from 'uuid'
export default { export default {
@@ -529,17 +268,15 @@ export default {
components: { components: {
Contenteditable, Contenteditable,
IconSettings, IconSettings,
IconShape, FieldSettings,
PaymentSettings, PaymentSettings,
IconNewSection, IconNewSection,
IconInfoCircle,
FormulaModal, FormulaModal,
DescriptionModal, DescriptionModal,
ConditionsModal, ConditionsModal,
IconRouteAltLeft, IconRouteAltLeft,
IconTrashX, IconTrashX,
IconMathFunction, IconMathFunction,
IconCopy,
FieldType FieldType
}, },
inject: ['template', 'save', 'backgroundColor', 'selectedAreaRef', 't'], inject: ['template', 'save', 'backgroundColor', 'selectedAreaRef', 't'],
@@ -590,29 +327,6 @@ export default {
modalContainerEl () { modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container') return this.$el.getRootNode().querySelector('#docuseal_modal_container')
}, },
dateFormats () {
const formats = [
'MM/DD/YYYY',
'DD/MM/YYYY',
'YYYY-MM-DD',
'DD-MM-YYYY',
'DD.MM.YYYY',
'MMM D, YYYY',
'MMMM D, YYYY',
'D MMM YYYY',
'D MMMM YYYY'
]
if (Intl.DateTimeFormat().resolvedOptions().timeZone?.includes('Seoul') || navigator.language?.startsWith('ko')) {
formats.push('YYYY년 MM월 DD일')
}
if (this.field.preferences?.format && !formats.includes(this.field.preferences.format)) {
formats.unshift(this.field.preferences.format)
}
return formats
},
defaultName () { defaultName () {
return this.buildDefaultName(this.field, this.template.fields) return this.buildDefaultName(this.field, this.template.fields)
}, },
@@ -647,54 +361,6 @@ export default {
return `${this.fieldNames[field.type]} ${suffix} ${typeIndex + 1}` return `${this.fieldNames[field.type]} ${suffix} ${typeIndex + 1}`
} }
}, },
formatDate (date, format) {
const monthFormats = {
M: 'numeric',
MM: '2-digit',
MMM: 'short',
MMMM: 'long'
}
const dayFormats = {
D: 'numeric',
DD: '2-digit'
}
const yearFormats = {
YYYY: 'numeric',
YY: '2-digit'
}
const parts = new Intl.DateTimeFormat([], {
day: dayFormats[format.match(/D+/)],
month: monthFormats[format.match(/M+/)],
year: yearFormats[format.match(/Y+/)]
}).formatToParts(date)
return format
.replace(/D+/, parts.find((p) => p.type === 'day').value)
.replace(/M+/, parts.find((p) => p.type === 'month').value)
.replace(/Y+/, parts.find((p) => p.type === 'year').value)
},
copyToAllPages (field) {
const areaString = JSON.stringify(field.areas[0])
this.template.documents.forEach((attachment) => {
const numberOfPages = attachment.metadata?.pdf?.number_of_pages || attachment.preview_images.length
for (let page = 0; page <= numberOfPages - 1; page++) {
if (!field.areas.find((area) => area.attachment_uuid === attachment.uuid && area.page === page)) {
field.areas.push({ ...JSON.parse(areaString), attachment_uuid: attachment.uuid, page })
}
}
})
this.$nextTick(() => {
this.$emit('scroll-to', this.field.areas[this.field.areas.length - 1])
})
this.save()
},
onNameFocus (e) { onNameFocus (e) {
this.isNameFocus = true this.isNameFocus = true
@@ -770,11 +436,6 @@ export default {
this.isNameFocus = false this.isNameFocus = false
this.save()
},
removeArea (area) {
this.field.areas.splice(this.field.areas.indexOf(area), 1)
this.save() this.save()
} }
} }
@@ -0,0 +1,547 @@
<template>
<div
v-if="field.type === 'number'"
class="py-1.5 px-1 relative"
@click.stop
>
<select
:placeholder="t('format')"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="[field.preferences ||= {}, field.preferences.format = $event.target.value, save()]"
>
<option
v-for="format in numberFormats"
:key="format"
:value="format"
:selected="format === field.preferences?.format || (format === 'none' && !field.preferences?.format)"
>
{{ formatNumber(123456789.567, format) }}
</option>
</select>
<label
:style="{ backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('format') }}
</label>
</div>
<div
v-if="['number'].includes(field.type)"
class="py-1.5 px-1 relative"
@click.stop
>
<select
class="select select-bordered select-xs w-full max-w-xs h-7 !outline-0 font-normal bg-transparent"
@change="[field.preferences ||= {}, field.preferences.align = $event.target.value, save()]"
>
<option
v-for="value in ['left', 'right', 'center']"
:key="value"
:selected="field.preferences?.align ? value === field.preferences.align : value === 'left'"
:value="value"
>
{{ t(value) }}
</option>
</select>
<label
:style="{ backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('align') }}
</label>
</div>
<div
v-if="['text', 'number'].includes(field.type) && !defaultField"
class="py-1.5 px-1 relative"
@click.stop
>
<input
v-model="field.default_value"
:placeholder="t('default_value')"
dir="auto"
:type="field.type"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0 bg-transparent"
@blur="save"
>
<label
v-if="field.default_value"
:style="{ backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('default_value') }}
</label>
</div>
<div
v-if="['text', 'cells'].includes(field.type)"
class="py-1.5 px-1 relative"
@click.stop
>
<select
class="select select-bordered select-xs w-full max-w-xs h-7 !outline-0 font-normal bg-transparent"
@change="onChangeValidation"
>
<option
:selected="!field.validation"
value=""
>
{{ t('none') }}
</option>
<option
v-for="(key, value) in validations"
:key="key"
:selected="field.validation?.pattern ? value === field.validation.pattern : value === 'none'"
:value="value"
>
{{ t(key) }}
</option>
<option
:selected="field.validation && !validations[field.validation.pattern]"
:value="validations[field.validation?.pattern] || !field.validation?.pattern ? 'custom' : field.validation?.pattern"
>
{{ t('custom') }}
</option>
</select>
<label
:style="{ backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('validation') }}
</label>
</div>
<div
v-if="['text', 'cells'].includes(field.type) && field.validation && !validations[field.validation.pattern]"
class="py-1.5 px-1 relative"
@click.stop
>
<input
ref="validationCustom"
v-model="field.validation.pattern"
:placeholder="t('regexp_validation')"
dir="auto"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0 bg-transparent"
@blur="save"
>
<label
v-if="field.validation.pattern"
:style="{ backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('regexp_validation') }}
</label>
</div>
<div
v-if="field.type === 'date'"
class="py-1.5 px-1 relative"
@click.stop
>
<select
v-model="field.preferences.format"
:placeholder="t('format')"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="save"
>
<option
v-for="format in dateFormats"
:key="format"
:value="format"
>
{{ formatDate(new Date(), format) }}
</option>
</select>
<label
:style="{ backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('format') }}
</label>
</div>
<div
v-if="field.type === 'signature'"
class="py-1.5 px-1 relative"
@click.stop
>
<select
:placeholder="t('format')"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="[field.preferences.format = $event.target.value, save()]"
>
<option
value="any"
:selected="!field.preferences?.format || field.preferences.format === 'any'"
>
{{ t('any') }}
</option>
<option
value="drawn"
:selected="field.preferences?.format === 'drawn'"
>
{{ t('drawn') }}
</option>
<option
value="typed"
:selected="field.preferences?.format === 'typed'"
>
{{ t('typed') }}
</option>
</select>
<label
:style="{ backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('format') }}
</label>
</div>
<li
v-if="withRequired && field.type != 'phone' && field.type != 'stamp'"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
v-model="field.required"
type="checkbox"
:disabled="!editable || defaultField"
class="toggle toggle-xs"
@update:model-value="save"
>
<span class="label-text">{{ t('required') }}</span>
</label>
</li>
<li
v-if="field.type == 'stamp'"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
:checked="field.preferences?.with_logo != false"
type="checkbox"
class="toggle toggle-xs"
@change="[field.preferences ||= {}, field.preferences.with_logo = field.preferences.with_logo == false, save()]"
>
<span class="label-text">{{ t('with_logo') }}</span>
</label>
</li>
<li
v-if="field.type == 'checkbox'"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
v-model="field.default_value"
type="checkbox"
class="toggle toggle-xs"
@update:model-value="[field.default_value = $event, field.readonly = $event, save()]"
>
<span class="label-text">{{ t('checked') }}</span>
</label>
</li>
<li
v-if="field.type == 'date'"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
v-model="field.readonly"
type="checkbox"
class="toggle toggle-xs"
@update:model-value="[field.default_value = $event ? '{{date}}' : null, field.readonly = $event, save()]"
>
<span class="label-text">{{ t('set_signing_date') }}</span>
</label>
</li>
<li
v-if="['text', 'number'].includes(field.type) && !defaultField"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
v-model="field.readonly"
type="checkbox"
class="toggle toggle-xs"
@update:model-value="save"
>
<span class="label-text">{{ t('read_only') }}</span>
</label>
</li>
<hr
v-if="field.type != 'stamp'"
class="pb-0.5 mt-0.5"
>
<li
v-if="field.type != 'stamp'"
>
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="$emit('click-description')"
>
<IconInfoCircle
width="18"
/>
<span class="text-sm">
{{ t('description') }}
</span>
</label>
</li>
<li
v-if="field.type != 'stamp'"
>
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="$emit('click-condition')"
>
<IconRouteAltLeft
width="18"
/>
<span class="text-sm">
{{ t('condition') }}
</span>
</label>
</li>
<li v-if="field.type == 'number'">
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="$emit('click-formula')"
>
<IconMathFunction
width="18"
/>
<span class="text-sm">
{{ t('formula') }}
</span>
</label>
</li>
<hr class="pb-0.5 mt-0.5">
<template v-if="withAreas">
<li
v-for="(area, index) in sortedAreas"
:key="index"
>
<a
href="#"
class="text-sm py-1 px-2"
@click.prevent="$emit('scroll-to', area)"
>
<IconShape
:width="20"
:stroke-width="1.6"
/>
{{ t('page') }}
<template v-if="template.schema.length > 1">{{ template.schema.findIndex((item) => item.attachment_uuid === area.attachment_uuid) + 1 }}-</template>{{ area.page + 1 }}
</a>
</li>
<li v-if="!field.areas?.length || !['radio', 'multiple'].includes(field.type)">
<a
href="#"
class="text-sm py-1 px-2"
@click.prevent="$emit('set-draw', { field })"
>
<IconNewSection
:width="20"
:stroke-width="1.6"
/>
{{ t('draw_new_area') }}
</a>
</li>
</template>
<li v-if="field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells'].includes(field.type)">
<a
href="#"
class="text-sm py-1 px-2"
@click.prevent="copyToAllPages(field)"
>
<IconCopy
:width="20"
:stroke-width="1.6"
/>
{{ t('copy_to_all_pages') }}
</a>
</li>
</template>
<script>
import { IconRouteAltLeft, IconShape, IconMathFunction, IconNewSection, IconInfoCircle, IconCopy } from '@tabler/icons-vue'
export default {
name: 'FieldSettings',
components: {
IconShape,
IconInfoCircle,
IconMathFunction,
IconRouteAltLeft,
IconCopy,
IconNewSection
},
inject: ['template', 'save', 't'],
props: {
field: {
type: Object,
required: true
},
backgroundColor: {
type: String,
required: false,
default: null
},
defaultField: {
type: Object,
required: false,
default: null
},
withRequired: {
type: Boolean,
required: false,
default: true
},
withAreas: {
type: Boolean,
required: false,
default: true
},
editable: {
type: Boolean,
required: false,
default: true
}
},
emits: ['set-draw', 'scroll-to', 'click-formula', 'click-description', 'click-condition'],
data () {
return {
}
},
computed: {
schemaAttachmentsIndexes () {
return (this.template.schema || []).reduce((acc, item, index) => {
acc[item.attachment_uuid] = index
return acc
}, {})
},
numberFormats () {
return [
'none',
'comma',
'dot',
'space'
]
},
dateFormats () {
const formats = [
'MM/DD/YYYY',
'DD/MM/YYYY',
'YYYY-MM-DD',
'DD-MM-YYYY',
'DD.MM.YYYY',
'MMM D, YYYY',
'MMMM D, YYYY',
'D MMM YYYY',
'D MMMM YYYY'
]
if (Intl.DateTimeFormat().resolvedOptions().timeZone?.includes('Seoul') || navigator.language?.startsWith('ko')) {
formats.push('YYYY년 MM월 DD일')
}
if (this.field.preferences?.format && !formats.includes(this.field.preferences.format)) {
formats.unshift(this.field.preferences.format)
}
return formats
},
validations () {
return {
'^[0-9]{3}-[0-9]{2}-[0-9]{4}$': 'ssn',
'^[0-9]{2}-[0-9]{7}$': 'ein',
'^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$': 'email',
'^https?://.*': 'url',
'^[0-9]{5}(?:-[0-9]{4})?$': 'zip',
'^[0-9]+$': 'numbers_only',
'^[a-zA-Z]+$': 'letters_only'
}
},
sortedAreas () {
return (this.field.areas || []).sort((a, b) => {
return this.schemaAttachmentsIndexes[a.attachment_uuid] - this.schemaAttachmentsIndexes[b.attachment_uuid]
})
}
},
methods: {
onChangeValidation (event) {
if (event.target.value === 'custom') {
this.field.validation = { pattern: '' }
this.$nextTick(() => this.$refs.validationCustom.focus())
} else if (event.target.value) {
this.field.validation ||= {}
this.field.validation.pattern = event.target.value
} else {
delete this.field.validation
}
this.save()
},
copyToAllPages (field) {
const areaString = JSON.stringify(field.areas[0])
this.template.documents.forEach((attachment) => {
const numberOfPages = attachment.metadata?.pdf?.number_of_pages || attachment.preview_images.length
for (let page = 0; page <= numberOfPages - 1; page++) {
if (!field.areas.find((area) => area.attachment_uuid === attachment.uuid && area.page === page)) {
field.areas.push({ ...JSON.parse(areaString), attachment_uuid: attachment.uuid, page })
}
}
})
this.$emit('scroll-to', this.field.areas[this.field.areas.length - 1])
this.save()
},
formatNumber (number, format) {
if (format === 'comma') {
return new Intl.NumberFormat('en-US').format(number)
} else if (format === 'dot') {
return new Intl.NumberFormat('de-DE').format(number)
} else if (format === 'space') {
return new Intl.NumberFormat('fr-FR').format(number)
} else {
return number
}
},
formatDate (date, format) {
const monthFormats = {
M: 'numeric',
MM: '2-digit',
MMM: 'short',
MMMM: 'long'
}
const dayFormats = {
D: 'numeric',
DD: '2-digit'
}
const yearFormats = {
YYYY: 'numeric',
YY: '2-digit'
}
const parts = new Intl.DateTimeFormat([], {
day: dayFormats[format.match(/D+/)],
month: monthFormats[format.match(/M+/)],
year: yearFormats[format.match(/Y+/)]
}).formatToParts(date)
return format
.replace(/D+/, parts.find((p) => p.type === 'day').value)
.replace(/M+/, parts.find((p) => p.type === 'month').value)
.replace(/Y+/, parts.find((p) => p.type === 'year').value)
}
}
}
</script>
+1 -1
View File
@@ -35,7 +35,7 @@
@set-draw="$emit('set-draw', $event)" @set-draw="$emit('set-draw', $event)"
/> />
</div> </div>
<div v-if="submitterDefaultFields.length"> <div v-if="submitterDefaultFields.length && editable">
<hr class="mb-2"> <hr class="mb-2">
<template v-if="isShowFieldSearch"> <template v-if="isShowFieldSearch">
<input <input
+15 -1
View File
@@ -1,16 +1,19 @@
const en = { const en = {
editable: 'Editable',
search_field: 'Search field', search_field: 'Search field',
field_not_found: 'Field not found', field_not_found: 'Field not found',
clear: 'Clear', clear: 'Clear',
align: 'Align', align: 'Align',
add_all_required_fields_to_continue: 'Add all required fields to continue', add_all_required_fields_to_continue: 'Add all required fields to continue',
left: 'Left', left: 'Left',
validation: 'Validation',
right: 'Right', right: 'Right',
center: 'Center', center: 'Center',
description: 'Description', description: 'Description',
display_title: 'Display title', display_title: 'Display title',
with_logo: 'With logo', with_logo: 'With logo',
unchecked: 'Unchecked', unchecked: 'Unchecked',
type_value: 'Type value',
equal: 'Equal', equal: 'Equal',
not_equal: 'Not equal', not_equal: 'Not equal',
contains: 'Contains', contains: 'Contains',
@@ -20,6 +23,7 @@ const en = {
select_field_: 'Select field...', select_field_: 'Select field...',
select_value_: 'Select value...', select_value_: 'Select value...',
remove_condition: 'Remove condition', remove_condition: 'Remove condition',
add_condition: 'Add condition',
are_you_sure: 'Are you sure?', are_you_sure: 'Are you sure?',
sign_yourself: 'Sign Yourself', sign_yourself: 'Sign Yourself',
set_signing_date: 'Set signing date', set_signing_date: 'Set signing date',
@@ -97,7 +101,17 @@ const en = {
draw_field: 'Draw {field} Field', draw_field: 'Draw {field} Field',
replace: 'Replace', replace: 'Replace',
uploading_: 'Uploading...', uploading_: 'Uploading...',
add_document: 'Add Document' add_document: 'Add Document',
none: 'None',
ssn: 'SSN',
ein: 'EIN',
email: 'Email',
url: 'URL',
zip: 'ZIP',
custom: 'Custom',
numbers_only: 'Numbers only',
letters_only: 'Letters only',
regexp_validation: 'Regexp validation'
} }
export { en } export { en }
@@ -267,7 +267,7 @@ export default {
if (mapping.field_name && mapping.column_index != null) { if (mapping.field_name && mapping.column_index != null) {
submittersIndex[mapping.submitter_uuid] ||= { uuid: mapping.submitter_uuid, fields: [] } submittersIndex[mapping.submitter_uuid] ||= { uuid: mapping.submitter_uuid, fields: [] }
if (['name', 'email', 'phone'].includes(mapping.field_name.toLowerCase())) { if (['name', 'email', 'phone', 'external_id'].includes(mapping.field_name.toLowerCase())) {
submittersIndex[mapping.submitter_uuid][mapping.field_name.toLowerCase()] = row[mapping.column_index] submittersIndex[mapping.submitter_uuid][mapping.field_name.toLowerCase()] = row[mapping.column_index]
} else { } else {
submittersIndex[mapping.submitter_uuid].fields.push({ submittersIndex[mapping.submitter_uuid].fields.push({
@@ -351,7 +351,13 @@ export default {
this.defaultFields.every((f) => field.name?.toLowerCase() !== f.name?.toLowerCase()) this.defaultFields.every((f) => field.name?.toLowerCase() !== f.name?.toLowerCase())
}) })
return [...this.defaultFields, ...templateFields] const fields = [...this.defaultFields, ...templateFields]
if (this.spreadsheet && this.columns.includes('external_id')) {
fields.push({ name: 'external_id' })
}
return fields
}, },
buildDefaultMappings () { buildDefaultMappings () {
this.submitters.forEach((submitter) => { this.submitters.forEach((submitter) => {
+4 -6
View File
@@ -27,9 +27,8 @@
:default-submitters="defaultSubmitters" :default-submitters="defaultSubmitters"
@start-resize="resizeDirection = $event" @start-resize="resizeDirection = $event"
@stop-resize="resizeDirection = null" @stop-resize="resizeDirection = null"
@start-drag="isMove = true"
@stop-drag="isMove = false"
@remove="$emit('remove-area', item.area)" @remove="$emit('remove-area', item.area)"
@scroll-to="$emit('scroll-to', $event)"
/> />
<FieldArea <FieldArea
v-if="newArea" v-if="newArea"
@@ -39,11 +38,11 @@
/> />
</div> </div>
<div <div
v-show="resizeDirection || isMove || isDrag || showMask || (drawField && isMobile) || fieldsDragFieldRef.value" v-show="resizeDirection || isDrag || showMask || (drawField && isMobile) || fieldsDragFieldRef.value"
id="mask" id="mask"
ref="mask" ref="mask"
class="top-0 bottom-0 left-0 right-0 absolute" class="top-0 bottom-0 left-0 right-0 absolute"
:class="{ 'z-10': !isMobile, 'cursor-grab': isDrag || isMove, 'cursor-nwse-resize': drawField, [resizeDirectionClasses[resizeDirection]]: !!resizeDirectionClasses }" :class="{ 'z-10': !isMobile, 'cursor-grab': isDrag, 'cursor-nwse-resize': drawField, [resizeDirectionClasses[resizeDirection]]: !!resizeDirectionClasses }"
@pointermove="onPointermove" @pointermove="onPointermove"
@pointerdown="onStartDraw" @pointerdown="onStartDraw"
@dragover.prevent @dragover.prevent
@@ -116,12 +115,11 @@ export default {
required: true required: true
} }
}, },
emits: ['draw', 'drop-field', 'remove-area'], emits: ['draw', 'drop-field', 'remove-area', 'scroll-to'],
data () { data () {
return { return {
areaRefs: [], areaRefs: [],
showMask: false, showMask: false,
isMove: false,
resizeDirection: null, resizeDirection: null,
newArea: null newArea: null
} }
@@ -149,12 +149,26 @@
data-turbo="false" data-turbo="false"
>Learn more</a> >Learn more</a>
</div> </div>
<hr>
<li class="mt-1">
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="$emit('click-condition')"
>
<IconRouteAltLeft
width="18"
/>
<span class="text-sm">
{{ t('condition') }}
</span>
</label>
</li>
</ul> </ul>
</span> </span>
</template> </template>
<script> <script>
import { IconSettings, IconCircleCheck, IconBrandStripe, IconInnerShadowTop } from '@tabler/icons-vue' import { IconSettings, IconCircleCheck, IconBrandStripe, IconInnerShadowTop, IconRouteAltLeft } from '@tabler/icons-vue'
import { ref } from 'vue' import { ref } from 'vue'
const isConnected = ref(false) const isConnected = ref(false)
@@ -164,6 +178,7 @@ export default {
components: { components: {
IconSettings, IconSettings,
IconCircleCheck, IconCircleCheck,
IconRouteAltLeft,
IconInnerShadowTop, IconInnerShadowTop,
IconBrandStripe IconBrandStripe
}, },
@@ -174,6 +189,7 @@ export default {
required: true required: true
} }
}, },
emits: ['click-condition'],
data () { data () {
return { return {
isLoading: false isLoading: false
+1
View File
@@ -1,4 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
class ApplicationJob < ActiveJob::Base class ApplicationJob < ActiveJob::Base
retry_on StandardError, wait: 6.seconds, attempts: 5 unless Docuseal.multitenant?
end end
+22 -11
View File
@@ -1,7 +1,11 @@
# frozen_string_literal: true # frozen_string_literal: true
class ProcessSubmitterCompletionJob < ApplicationJob class ProcessSubmitterCompletionJob
def perform(submitter) include Sidekiq::Job
def perform(params = {})
submitter = Submitter.find(params['submitter_id'])
is_all_completed = !submitter.submission.submitters.exists?(completed_at: nil) is_all_completed = !submitter.submission.submitters.exists?(completed_at: nil)
if !is_all_completed && submitter.submission.submitters_order_preserved? if !is_all_completed && submitter.submission.submitters_order_preserved?
@@ -18,7 +22,7 @@ class ProcessSubmitterCompletionJob < ApplicationJob
return if Accounts.load_webhook_url(submitter.account).blank? return if Accounts.load_webhook_url(submitter.account).blank?
SendFormCompletedWebhookRequestJob.perform_later(submitter) SendFormCompletedWebhookRequestJob.perform_async({ 'submitter_id' => submitter.id })
end end
def enqueue_completed_emails(submitter) def enqueue_completed_emails(submitter)
@@ -26,26 +30,33 @@ class ProcessSubmitterCompletionJob < ApplicationJob
user = submission.created_by_user || submitter.template.author user = submission.created_by_user || submitter.template.author
if submitter.account.users.exists?(id: user.id) && submission.preferences['send_email'] != false if submitter.account.users.exists?(id: user.id) && submission.preferences['send_email'] != false &&
submitter.template.preferences['completed_notification_email_enabled'] != false
if submission.submitters.map(&:email).exclude?(user.email) && if submission.submitters.map(&:email).exclude?(user.email) &&
user.user_configs.find_by(key: UserConfig::RECEIVE_COMPLETED_EMAIL)&.value != false && user.user_configs.find_by(key: UserConfig::RECEIVE_COMPLETED_EMAIL)&.value != false &&
user.role != 'integration' user.role != 'integration'
SubmitterMailer.completed_email(submitter, user).deliver_later! SubmitterMailer.completed_email(submitter, user).deliver_later!
end end
bcc = submission.preferences['bcc_completed'].presence || build_bcc_addresses(submission).each do |to|
submission.template.preferences['bcc_completed'].presence ||
submission.account.account_configs
.find_by(key: AccountConfig::BCC_EMAILS)&.value
bcc.to_s.scan(User::EMAIL_REGEXP).each do |to|
SubmitterMailer.completed_email(submitter, user, to:).deliver_later! SubmitterMailer.completed_email(submitter, user, to:).deliver_later!
end end
end end
to = build_to_addresses(submitter) to = build_to_addresses(submitter)
SubmitterMailer.documents_copy_email(submitter, to:).deliver_later! if to.present? return if to.blank? || submitter.template.preferences['documents_copy_email_enabled'] == false
SubmitterMailer.documents_copy_email(submitter, to:).deliver_later!
end
def build_bcc_addresses(submission)
bcc = submission.preferences['bcc_completed'].presence ||
submission.template.preferences['bcc_completed'].presence ||
submission.account.account_configs
.find_by(key: AccountConfig::BCC_EMAILS)&.value
bcc.to_s.scan(User::EMAIL_REGEXP)
end end
def build_to_addresses(submitter) def build_to_addresses(submitter)
@@ -1,12 +1,18 @@
# frozen_string_literal: true # frozen_string_literal: true
class SendFormCompletedWebhookRequestJob < ApplicationJob class SendFormCompletedWebhookRequestJob
include Sidekiq::Job
sidekiq_options queue: :webhooks
USER_AGENT = 'DocuSeal.co Webhook' USER_AGENT = 'DocuSeal.co Webhook'
MAX_ATTEMPTS = 10 MAX_ATTEMPTS = 10
def perform(submitter, params = {}) def perform(params = {})
attempt = params[:attempt].to_i submitter = Submitter.find(params['submitter_id'])
attempt = params['attempt'].to_i
url = Accounts.load_webhook_url(submitter.submission.account) url = Accounts.load_webhook_url(submitter.submission.account)
return if url.blank? return if url.blank?
@@ -34,11 +40,11 @@ class SendFormCompletedWebhookRequestJob < ApplicationJob
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormCompletedWebhookRequestJob.set(wait: (2**attempt).minutes) SendFormCompletedWebhookRequestJob.perform_in((2**attempt).minutes, {
.perform_later(submitter, { 'submitter_id' => submitter.id,
attempt: attempt + 1, 'attempt' => attempt + 1,
last_status: resp&.status.to_i 'last_status' => resp&.status.to_i
}) })
end end
end end
end end
@@ -1,12 +1,18 @@
# frozen_string_literal: true # frozen_string_literal: true
class SendFormStartedWebhookRequestJob < ApplicationJob class SendFormStartedWebhookRequestJob
include Sidekiq::Job
sidekiq_options queue: :webhooks
USER_AGENT = 'DocuSeal.co Webhook' USER_AGENT = 'DocuSeal.co Webhook'
MAX_ATTEMPTS = 10 MAX_ATTEMPTS = 10
def perform(submitter, params = {}) def perform(params = {})
attempt = params[:attempt].to_i submitter = Submitter.find(params['submitter_id'])
attempt = params['attempt'].to_i
url = Accounts.load_webhook_url(submitter.submission.account) url = Accounts.load_webhook_url(submitter.submission.account)
return if url.blank? return if url.blank?
@@ -32,11 +38,11 @@ class SendFormStartedWebhookRequestJob < ApplicationJob
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormStartedWebhookRequestJob.set(wait: (2**attempt).minutes) SendFormStartedWebhookRequestJob.perform_in((2**attempt).minutes, {
.perform_later(submitter, { 'submitter_id' => submitter.id,
attempt: attempt + 1, 'attempt' => attempt + 1,
last_status: resp&.status.to_i 'last_status' => resp&.status.to_i
}) })
end end
end end
end end
@@ -1,12 +1,18 @@
# frozen_string_literal: true # frozen_string_literal: true
class SendFormViewedWebhookRequestJob < ApplicationJob class SendFormViewedWebhookRequestJob
include Sidekiq::Job
sidekiq_options queue: :webhooks
USER_AGENT = 'DocuSeal.co Webhook' USER_AGENT = 'DocuSeal.co Webhook'
MAX_ATTEMPTS = 10 MAX_ATTEMPTS = 10
def perform(submitter, params = {}) def perform(params = {})
attempt = params[:attempt].to_i submitter = Submitter.find(params['submitter_id'])
attempt = params['attempt'].to_i
url = Accounts.load_webhook_url(submitter.submission.account) url = Accounts.load_webhook_url(submitter.submission.account)
return if url.blank? return if url.blank?
@@ -32,11 +38,11 @@ class SendFormViewedWebhookRequestJob < ApplicationJob
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormViewedWebhookRequestJob.set(wait: (2**attempt).minutes) SendFormViewedWebhookRequestJob.perform_in((2**attempt).minutes, {
.perform_later(submitter, { 'submitter_id' => submitter.id,
attempt: attempt + 1, 'attempt' => attempt + 1,
last_status: resp&.status.to_i 'last_status' => resp&.status.to_i
}) })
end end
end end
end end
@@ -1,12 +1,18 @@
# frozen_string_literal: true # frozen_string_literal: true
class SendSubmissionArchivedWebhookRequestJob < ApplicationJob class SendSubmissionArchivedWebhookRequestJob
include Sidekiq::Job
sidekiq_options queue: :webhooks
USER_AGENT = 'DocuSeal.co Webhook' USER_AGENT = 'DocuSeal.co Webhook'
MAX_ATTEMPTS = 10 MAX_ATTEMPTS = 10
def perform(submission, params = {}) def perform(params = {})
attempt = params[:attempt].to_i submission = Submission.find(params['submission_id'])
attempt = params['attempt'].to_i
url = Accounts.load_webhook_url(submission.account) url = Accounts.load_webhook_url(submission.account)
return if url.blank? return if url.blank?
@@ -29,12 +35,12 @@ class SendSubmissionArchivedWebhookRequestJob < ApplicationJob
end end
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan))
SendSubmissionArchivedWebhookRequestJob.set(wait: (2**attempt).minutes) SendSubmissionArchivedWebhookRequestJob.perform_in((2**attempt).minutes, {
.perform_later(submitter, { 'submission_id' => submission.id,
attempt: attempt + 1, 'attempt' => attempt + 1,
last_status: resp&.status.to_i 'last_status' => resp&.status.to_i
}) })
end end
end end
end end
@@ -0,0 +1,46 @@
# frozen_string_literal: true
class SendSubmissionCreatedWebhookRequestJob
include Sidekiq::Job
sidekiq_options queue: :webhooks
USER_AGENT = 'DocuSeal.co Webhook'
MAX_ATTEMPTS = 10
def perform(params = {})
submission = Submission.find(params['submission_id'])
attempt = params['attempt'].to_i
url = Accounts.load_webhook_url(submission.account)
return if url.blank?
preferences = Accounts.load_webhook_preferences(submission.account)
return if preferences['submission.created'].blank?
resp = begin
Faraday.post(url,
{
event_type: 'submission.created',
timestamp: Time.current,
data: Submissions::SerializeForApi.call(submission)
}.to_json,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
rescue Faraday::Error
nil
end
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan))
SendSubmissionCreatedWebhookRequestJob.perform_in((2**attempt).minutes, {
'submission_id' => submission.id,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
end
@@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class SendSubmitterInvitationEmailJob < ApplicationJob class SendSubmitterInvitationEmailJob
include Sidekiq::Job
def perform(params = {}) def perform(params = {})
submitter = Submitter.find(params['submitter_id']) submitter = Submitter.find(params['submitter_id'])
@@ -0,0 +1,46 @@
# frozen_string_literal: true
class SendTemplateCreatedWebhookRequestJob
include Sidekiq::Job
sidekiq_options queue: :webhooks
USER_AGENT = 'DocuSeal.co Webhook'
MAX_ATTEMPTS = 10
def perform(params = {})
template = Template.find(params['template_id'])
attempt = params['attempt'].to_i
url = Accounts.load_webhook_url(template.account)
return if url.blank?
preferences = Accounts.load_webhook_preferences(template.account)
return if preferences['template.created'].blank?
resp = begin
Faraday.post(url,
{
event_type: 'template.created',
timestamp: Time.current,
data: Templates::SerializeForApi.call(template)
}.to_json,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
rescue Faraday::Error
nil
end
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan))
SendTemplateCreatedWebhookRequestJob.perform_in((2**attempt).minutes, {
'template_id' => template.id,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
end
@@ -0,0 +1,46 @@
# frozen_string_literal: true
class SendTemplateUpdatedWebhookRequestJob
include Sidekiq::Job
sidekiq_options queue: :webhooks
USER_AGENT = 'DocuSeal.co Webhook'
MAX_ATTEMPTS = 10
def perform(params = {})
template = Template.find(params['template_id'])
attempt = params['attempt'].to_i
url = Accounts.load_webhook_url(template.account)
return if url.blank?
preferences = Accounts.load_webhook_preferences(template.account)
return if preferences['template.updated'].blank?
resp = begin
Faraday.post(url,
{
event_type: 'template.updated',
timestamp: Time.current,
data: Templates::SerializeForApi.call(template)
}.to_json,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
rescue Faraday::Error
nil
end
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan))
SendTemplateUpdatedWebhookRequestJob.perform_in((2**attempt).minutes, {
'template_id' => template.id,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
end
+21
View File
@@ -6,11 +6,32 @@ class ApplicationMailer < ActionMailer::Base
register_interceptor ActionMailerConfigsInterceptor register_interceptor ActionMailerConfigsInterceptor
register_observer ActionMailerEventsObserver
before_action do before_action do
ActiveStorage::Current.url_options = Docuseal.default_url_options ActiveStorage::Current.url_options = Docuseal.default_url_options
end end
after_action :set_message_metadata
after_action :set_message_uuid
def default_url_options def default_url_options
Docuseal.default_url_options Docuseal.default_url_options
end end
def set_message_metadata
message.instance_variable_set(:@message_metadata, @message_metadata)
end
def set_message_uuid
message['X-Message-Uuid'] = SecureRandom.uuid
end
def assign_message_metadata(tag, record)
@message_metadata = {
'tag' => tag,
'record_id' => record.id,
'record_type' => record.class.name
}
end
end end
+35 -11
View File
@@ -26,6 +26,8 @@ class SubmitterMailer < ApplicationMailer
DEFAULT_INVITATION_SUBJECT DEFAULT_INVITATION_SUBJECT
end end
assign_message_metadata('submitter_invitation', @submitter)
mail( mail(
to: @submitter.friendly_name, to: @submitter.friendly_name,
from: from_address_for_submitter(submitter), from: from_address_for_submitter(submitter),
@@ -46,18 +48,25 @@ class SubmitterMailer < ApplicationMailer
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY) @email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY)
add_completed_email_attachments!( add_completed_email_attachments!(
submitter, with_audit_log: @email_config.nil? || @email_config.value['attach_audit_log'] != false submitter, with_audit_log: (@email_config.nil? || @email_config.value['attach_audit_log'] != false) &&
@submitter.template.preferences['completed_notification_email_attach_audit'] != false
) )
@subject = @submitter.template.preferences['completed_notification_email_subject'].presence
@subject ||= @email_config.value['subject'] if @email_config
@body = @submitter.template.preferences['completed_notification_email_body'].presence
@body ||= @email_config.value['body'] if @email_config
subject = subject =
if @email_config if @subject.present?
ReplaceEmailVariables.call(@email_config.value['subject'], submitter:) ReplaceEmailVariables.call(@subject, submitter:)
else else
submitters = submitter.submission.submitters.order(:completed_at) build_completed_subject(submitter)
.map { |e| e.name || e.email || e.phone }.join(', ')
%(#{submitter.submission.template.name} has been completed by #{submitters})
end end
assign_message_metadata('submitter_completed', @submitter)
mail(from: from_address_for_submitter(submitter), mail(from: from_address_for_submitter(submitter),
to: to || (user.role == 'integration' ? user.friendly_name.sub(/\+\w+@/, '@') : user.friendly_name), to: to || (user.role == 'integration' ? user.friendly_name.sub(/\+\w+@/, '@') : user.friendly_name),
subject:) subject:)
@@ -73,16 +82,25 @@ class SubmitterMailer < ApplicationMailer
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY) @email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY)
@documents = add_completed_email_attachments!( @documents = add_completed_email_attachments!(
submitter, with_audit_log: @email_config.nil? || @email_config.value['attach_audit_log'] != false submitter, with_audit_log: @submitter.template.preferences['documents_copy_email_attach_audit'] != false &&
(@email_config.nil? || @email_config.value['attach_audit_log'] != false)
) )
@subject = @submitter.template.preferences['documents_copy_email_subject'].presence
@subject ||= @email_config.value['subject'] if @email_config
@body = @submitter.template.preferences['documents_copy_email_body'].presence
@body ||= @email_config.value['body'] if @email_config
subject = subject =
if @email_config if @subject.present?
ReplaceEmailVariables.call(@email_config.value['subject'], submitter:) ReplaceEmailVariables.call(@subject, submitter:)
else else
'Your document copy' 'Your document copy'
end end
assign_message_metadata('submitter_documents_copy', @submitter)
mail(from: from_address_for_submitter(submitter), mail(from: from_address_for_submitter(submitter),
to: to || @submitter.friendly_name, to: to || @submitter.friendly_name,
reply_to: @submitter.preferences['reply_to'].presence || reply_to: @submitter.preferences['reply_to'].presence ||
@@ -93,6 +111,12 @@ class SubmitterMailer < ApplicationMailer
private private
def build_completed_subject(submitter)
submitters = submitter.submission.submitters.order(:completed_at)
.map { |e| e.name || e.email || e.phone }.join(', ')
%(#{submitter.submission.template.name} has been completed by #{submitters})
end
def add_completed_email_attachments!(submitter, with_audit_log: true) def add_completed_email_attachments!(submitter, with_audit_log: true)
documents = Submitters.select_attachments_for_download(submitter) documents = Submitters.select_attachments_for_download(submitter)
@@ -107,7 +131,7 @@ class SubmitterMailer < ApplicationMailer
total_size = add_attachments_with_size_limit(documents, total_size) total_size = add_attachments_with_size_limit(documents, total_size)
attachments[submitter.submission.audit_trail.filename.to_s] = audit_trail_data if audit_trail_data attachments[submitter.submission.audit_trail.filename.to_s.tr('"', "'")] = audit_trail_data if audit_trail_data
file_fields = submitter.submission.template_fields.select { |e| e['type'].in?(%w[file payment]) } file_fields = submitter.submission.template_fields.select { |e| e['type'].in?(%w[file payment]) }
@@ -129,7 +153,7 @@ class SubmitterMailer < ApplicationMailer
break if total_size >= MAX_ATTACHMENTS_SIZE break if total_size >= MAX_ATTACHMENTS_SIZE
attachments[attachment.filename.to_s] = attachment.download attachments[attachment.filename.to_s.tr('"', "'")] = attachment.download
end end
total_size total_size
+2
View File
@@ -6,6 +6,8 @@ class UserMailer < ApplicationMailer
@user = user @user = user
@token = @user.send(:set_reset_password_token) @token = @user.send(:set_reset_password_token)
assign_message_metadata('user_invitation', @user)
mail(to: @user.friendly_name, mail(to: @user.friendly_name,
subject: 'You are invited to DocuSeal') subject: 'You are invited to DocuSeal')
end end
+1
View File
@@ -31,6 +31,7 @@ class Account < ApplicationRecord
has_many :submissions, dependent: :destroy has_many :submissions, dependent: :destroy
has_many :submitters, through: :submissions has_many :submitters, through: :submissions
has_many :account_linked_accounts, dependent: :destroy has_many :account_linked_accounts, dependent: :destroy
has_many :email_events, dependent: :destroy
has_many :account_testing_accounts, -> { testing }, dependent: :destroy, has_many :account_testing_accounts, -> { testing }, dependent: :destroy,
class_name: 'AccountLinkedAccount', class_name: 'AccountLinkedAccount',
inverse_of: :account inverse_of: :account
+1
View File
@@ -32,6 +32,7 @@ class AccountConfig < ApplicationRecord
FORM_COMPLETED_BUTTON_KEY = 'form_completed_button' FORM_COMPLETED_BUTTON_KEY = 'form_completed_button'
FORM_COMPLETED_MESSAGE_KEY = 'form_completed_message' FORM_COMPLETED_MESSAGE_KEY = 'form_completed_message'
FORM_WITH_CONFETTI_KEY = 'form_with_confetti' FORM_WITH_CONFETTI_KEY = 'form_with_confetti'
FORM_PREFILL_SIGNATURE_KEY = 'form_prefill_signature'
ESIGNING_PREFERENCE_KEY = 'esigning_preference' ESIGNING_PREFERENCE_KEY = 'esigning_preference'
WEBHOOK_PREFERENCES_KEY = 'webhook_preferences' WEBHOOK_PREFERENCES_KEY = 'webhook_preferences'
DOWNLOAD_LINKS_AUTH_KEY = 'download_links_auth' DOWNLOAD_LINKS_AUTH_KEY = 'download_links_auth'
+42
View File
@@ -0,0 +1,42 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: email_events
#
# id :bigint not null, primary key
# data :text not null
# email :string not null
# emailable_type :string not null
# event_datetime :datetime not null
# event_type :string not null
# tag :string not null
# created_at :datetime not null
# account_id :bigint not null
# emailable_id :bigint not null
# message_id :string not null
#
# Indexes
#
# index_email_events_on_account_id (account_id)
# index_email_events_on_emailable (emailable_type,emailable_id)
# index_email_events_on_message_id (message_id)
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
#
class EmailEvent < ApplicationRecord
belongs_to :emailable, polymorphic: true
belongs_to :account
attribute :data, :string, default: -> { {} }
serialize :data, coder: JSON
before_validation :maybe_set_account, on: :create
def maybe_set_account
self.account ||= emailable.account
end
end
+1
View File
@@ -52,6 +52,7 @@ class Submission < ApplicationRecord
attribute :slug, :string, default: -> { SecureRandom.base58(14) } attribute :slug, :string, default: -> { SecureRandom.base58(14) }
has_one_attached :audit_trail has_one_attached :audit_trail
has_one_attached :combined_document
has_many :template_schema_documents, has_many :template_schema_documents,
->(e) { where(uuid: (e.template_schema.presence || e.template.schema).pluck('attachment_uuid')) }, ->(e) { where(uuid: (e.template_schema.presence || e.template.schema).pluck('attachment_uuid')) },
+1
View File
@@ -26,6 +26,7 @@
# Indexes # Indexes
# #
# index_submitters_on_email (email) # index_submitters_on_email (email)
# index_submitters_on_external_id (external_id)
# index_submitters_on_slug (slug) UNIQUE # index_submitters_on_slug (slug) UNIQUE
# index_submitters_on_submission_id (submission_id) # index_submitters_on_submission_id (submission_id)
# #
+5 -4
View File
@@ -22,10 +22,11 @@
# #
# Indexes # Indexes
# #
# index_templates_on_account_id (account_id) # index_templates_on_account_id (account_id)
# index_templates_on_author_id (author_id) # index_templates_on_author_id (author_id)
# index_templates_on_folder_id (folder_id) # index_templates_on_external_id (external_id)
# index_templates_on_slug (slug) UNIQUE # index_templates_on_folder_id (folder_id)
# index_templates_on_slug (slug) UNIQUE
# #
# Foreign Keys # Foreign Keys
# #
+7
View File
@@ -50,6 +50,7 @@ class User < ApplicationRecord
EMAIL_REGEXP = /[^@;,<>\s]+@[^@;,<>\s]+/ EMAIL_REGEXP = /[^@;,<>\s]+@[^@;,<>\s]+/
has_one_attached :signature has_one_attached :signature
has_one_attached :initials
belongs_to :account belongs_to :account
has_one :access_token, dependent: :destroy has_one :access_token, dependent: :destroy
@@ -80,6 +81,12 @@ class User < ApplicationRecord
true true
end end
def sidekiq?
return true if Rails.env.development?
role == 'admin'
end
def self.sign_in_after_reset_password def self.sign_in_after_reset_password
if PasswordsController::Current.user.present? if PasswordsController::Current.user.present?
!PasswordsController::Current.user.otp_required_for_login !PasswordsController::Current.user.otp_required_for_login
+1
View File
@@ -22,6 +22,7 @@
# #
class UserConfig < ApplicationRecord class UserConfig < ApplicationRecord
SIGNATURE_KEY = 'signature' SIGNATURE_KEY = 'signature'
INITIALS_KEY = 'initials'
RECEIVE_COMPLETED_EMAIL = 'receive_completed_email' RECEIVE_COMPLETED_EMAIL = 'receive_completed_email'
belongs_to :user belongs_to :user
Whitespace-only changes.
+15 -2
View File
@@ -3,7 +3,7 @@
<div class="flex-grow max-w-xl mx-auto"> <div class="flex-grow max-w-xl mx-auto">
<h1 class="text-4xl font-bold mb-4">Account</h1> <h1 class="text-4xl font-bold mb-4">Account</h1>
<%= form_for '', url: settings_account_path, method: :patch, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %> <%= form_for '', url: settings_account_path, method: :patch, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
<%= f.fields_for current_account do |ff| %> <%= fields_for current_account do |ff| %>
<div class="form-control"> <div class="form-control">
<%= ff.label :name, 'Company Name', class: 'label' %> <%= ff.label :name, 'Company Name', class: 'label' %>
<%= ff.text_field :name, required: true, class: 'base-input', dir: 'auto' %> <%= ff.text_field :name, required: true, class: 'base-input', dir: 'auto' %>
@@ -23,7 +23,7 @@
<% end %> <% end %>
<% encrypted_config = @encrypted_config || EncryptedConfig.find_or_initialize_by(account: current_account, key: EncryptedConfig::APP_URL_KEY) %> <% encrypted_config = @encrypted_config || EncryptedConfig.find_or_initialize_by(account: current_account, key: EncryptedConfig::APP_URL_KEY) %>
<% if !Docuseal.multitenant? && can?(:manage, encrypted_config) && !current_account.testing? %> <% if !Docuseal.multitenant? && can?(:manage, encrypted_config) && !current_account.testing? %>
<%= f.fields_for encrypted_config do |ff| %> <%= fields_for encrypted_config do |ff| %>
<div class="form-control"> <div class="form-control">
<%= ff.label :value, 'App URL', class: 'label' %> <%= ff.label :value, 'App URL', class: 'label' %>
<%= ff.text_field :value, autocomplete: 'off', class: 'base-input' %> <%= ff.text_field :value, autocomplete: 'off', class: 'base-input' %>
@@ -75,6 +75,18 @@
</div> </div>
<% end %> <% end %>
<% end %> <% end %>
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::FORM_PREFILL_SIGNATURE_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 py-2.5">
<span>
Remember and pre-fill signatures
</span>
<%= f.check_box :value, class: 'toggle', checked: account_config.value != false, onchange: 'this.form.requestSubmit()' %>
</div>
<% end %>
<% end %>
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::DOWNLOAD_LINKS_AUTH_KEY) %> <% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::DOWNLOAD_LINKS_AUTH_KEY) %>
<% if can?(:manage, account_config) %> <% if can?(:manage, account_config) %>
<%= form_for account_config, url: account_configs_path, method: :post do |f| %> <%= form_for account_config, url: account_configs_path, method: :post do |f| %>
@@ -89,6 +101,7 @@
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
<%= render 'integrations' %>
<% if can?(:manage, current_account) && Docuseal.multitenant? && true_user == current_user %> <% if can?(:manage, current_account) && Docuseal.multitenant? && true_user == current_user %>
<div class="px-1 mt-8"> <div class="px-1 mt-8">
<h2 class="text-2xl font-bold mb-2">Danger Zone</h2> <h2 class="text-2xl font-bold mb-2">Danger Zone</h2>
+7 -7
View File
@@ -1,22 +1,22 @@
<div class="max-w-lg mx-auto px-2"> <div class="max-w-lg mx-auto px-2">
<%= render 'devise/shared/select_server' if Docuseal.multitenant? %> <%= render 'devise/shared/select_server' if Docuseal.multitenant? %>
<h1 class="text-4xl font-bold text-center mt-8">Sign In</h1> <h1 class="text-4xl font-bold text-center mt-8"><%= t('sign_in') %></h1>
<%= form_for(resource, as: resource_name, html: { class: 'space-y-6' }, data: { turbo: params[:redir].blank? }, url: session_path(resource_name)) do |f| %> <%= form_for(resource, as: resource_name, html: { class: 'space-y-6' }, data: { turbo: params[:redir].blank? }, url: session_path(resource_name)) do |f| %>
<% if params[:redir].present? %> <% if params[:redir].present? %>
<%= hidden_field_tag :redir, params[:redir] %> <%= hidden_field_tag :redir, params[:redir] %>
<% end %> <% end %>
<div class="space-y-2"> <div class="space-y-2" dir="auto">
<div class="form-control"> <div class="form-control">
<%= f.label :email, class: 'label' %> <%= f.label :email, t(:email), class: 'label' %>
<%= f.email_field :email, autofocus: true, autocomplete: 'email', class: 'base-input' %> <%= f.email_field :email, autofocus: true, autocomplete: 'email', class: 'base-input' %>
</div> </div>
<div class="form-control"> <div class="form-control">
<%= f.label :password, class: 'label' %> <%= f.label :password, t(:password), class: 'label' %>
<%= f.password_field :password, autocomplete: 'current-password', class: 'base-input' %> <%= f.password_field :password, autocomplete: 'current-password', class: 'base-input' %>
</div> </div>
</div> </div>
<div class="form-control"> <div class="form-control">
<%= f.button button_title(title: 'Sign In', disabled_with: 'Signing In'), class: 'base-button' %> <%= f.button button_title(title: t(:sign_in), disabled_with: t(:signing_in)), class: 'base-button' %>
</div> </div>
<% end %> <% end %>
<% if devise_mapping.omniauthable? %> <% if devise_mapping.omniauthable? %>
@@ -25,14 +25,14 @@
<%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %> <%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state" data-params="true"></set-timezone> <set-timezone data-input-id="state" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %> <%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %>
<%= f.button button_title(title: 'Sign in with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), class: 'white-button w-full mt-4' %> <%= f.button button_title(title: t('sign_in_with_google'), icon: svg_icon('brand_google', class: 'w-6 h-6')), class: 'white-button w-full mt-4' %>
<% end %> <% end %>
<% end %> <% end %>
<% if User.omniauth_providers.include?(:microsoft_office365) %> <% if User.omniauth_providers.include?(:microsoft_office365) %>
<%= form_for '', url: omniauth_authorize_path(resource_name, :microsoft_office365), data: { turbo: false }, method: :post do |f| %> <%= form_for '', url: omniauth_authorize_path(resource_name, :microsoft_office365), data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state_microsoft" data-params="true"></set-timezone> <set-timezone data-input-id="state_microsoft" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query, id: 'state_microsoft' %> <%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query, id: 'state_microsoft' %>
<%= f.button button_title(title: 'Sign in with Microsoft', icon: svg_icon('brand_microsoft', class: 'w-6 h-6')), class: 'white-button w-full' %> <%= f.button button_title(title: t('sign_in_with_microsoft'), icon: svg_icon('brand_microsoft', class: 'w-6 h-6')), class: 'white-button w-full' %>
<% end %> <% end %>
<% end %> <% end %>
</div> </div>
+11 -3
View File
@@ -1,11 +1,19 @@
<div class="flex justify-between mt-4"> <div class="flex justify-between mt-4">
<%- if controller_name != 'sessions' %> <%- if controller_name != 'sessions' %>
<%= link_to 'Already have an account?', new_session_path(resource_name), class: 'link link-hover mx-auto' %> <%= link_to t('already_have_an_account'), new_session_path(resource_name, { lang: params[:lang] }.compact_blank), class: 'link link-hover mx-auto' %>
<% end %> <% end %>
<%- if devise_mapping.registerable? && controller_name != 'registrations' %> <%- if devise_mapping.registerable? && controller_name != 'registrations' %>
<%= link_to 'Create free account', registration_path({ redir: params[:redir] }.compact_blank), class: 'link link-hover' %> <%= link_to t('create_free_account'), registration_path({ redir: params[:redir], lang: params[:lang] }.compact_blank), class: 'link link-hover' %>
<% end %> <% end %>
<%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %> <%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %>
<%= link_to 'Forgot your password?', new_password_path(resource_name), class: 'link link-hover' %> <%= link_to t('forgot_your_password_'), new_password_path(resource_name, { lang: params[:lang] }.compact_blank), class: 'link link-hover' %>
<% end %>
</div>
<div class="text-center mt-10">
<%= form_with url: url_for, method: :get do %>
<% if params[:redir].present? %>
<%= hidden_field_tag :redir, params[:redir] %>
<% end %>
<%= select_tag :lang, options_for_select((I18n.available_locales - %i[en pt-PT de-DE fr-FR es-ES]).map { |code| [t("language_#{code}"), code] }, I18n.locale), onchange: 'this.form.requestSubmit();', class: 'select select-sm border-base-content/30 text-base' %>
<% end %> <% end %>
</div> </div>
@@ -1,10 +1,12 @@
<div class="text-center"> <div class="text-center">
<div class="join"> <div class="join">
<a href="https://docuseal.co<%= request.fullpath.gsub('docuseal.eu', 'docuseal.co') %>" class="btn bg-base-200 join-item w-32 <%= 'bg-base-300' if request.host == 'docuseal.co' %>"> <a href="https://docuseal.co<%= request.fullpath.gsub('docuseal.eu', 'docuseal.co') %>" class="btn bg-base-200 join-item w-32 <%= 'bg-base-300' if request.host == 'docuseal.co' %>">
🌎 Global <%= svg_icon 'world', class: 'w-5 h-5' %>
Global
</a> </a>
<a href="https://docuseal.eu<%= request.fullpath.gsub('docuseal.co', 'docuseal.eu') %>" class="btn bg-base-200 join-item w-32 <%= 'bg-base-300' if request.host == 'docuseal.eu' %>"> <a href="https://docuseal.eu<%= request.fullpath.gsub('docuseal.co', 'docuseal.eu') %>" class="btn bg-base-200 join-item w-32 <%= 'bg-base-300' if request.host == 'docuseal.eu' %>">
🇪🇺 Europe <%= svg_icon 'eu_flag', class: 'w-5 h-5' %>
Europe
</a> </a>
</div> </div>
</div> </div>
+1 -1
View File
@@ -123,7 +123,7 @@
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
<% account_config = AccountConfig.where(account: current_account, key: AccountConfig::ESIGNING_PREFERENCE_KEY).first_or_initialize(value: 'multiple') %> <% account_config = AccountConfig.where(account: current_account, key: AccountConfig::ESIGNING_PREFERENCE_KEY).first_or_initialize(value: 'single') %>
<% if can?(:manage, account_config) %> <% if can?(:manage, account_config) %>
<div class="px-1 mt-8 max-w-xl"> <div class="px-1 mt-8 max-w-xl">
<div class="flex justify-between items-end mb-4 mt-8"> <div class="flex justify-between items-end mb-4 mt-8">
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M6 9l6 6l6 -6" />
</svg>

After

Width:  |  Height:  |  Size: 312 B

+125
View File
@@ -0,0 +1,125 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" version="1.1" viewBox="0 0 1000 1000">
<defs />
<path fill="#039" d="M8,195.25 C178.83,110.03 349.03,140.83 521.26,167.28 C676.47,191.12 833.42,211.85 992,132.75 L992,804.75 C821.17,889.97 650.97,859.17 478.74,832.72 C323.53,808.88 166.58,788.15 8,867.25 L8,195.25" />
<path fill="#fc0" d="M500,238.67 L488.74,272.43 L506.07,280.88 L500,238.67" />
<path fill="#fc0" d="M500,238.67 L511.26,275.92 L493.93,279 L500,238.67" />
<path fill="#fc0" d="M534.66,269.79 L498.22,264.19 L498.22,282.85 L534.66,269.79" />
<path fill="#fc0" d="M534.66,269.79 L505.18,287.21 L494.47,270.45 L534.66,269.79" />
<path fill="#fc0" d="M521.42,309.51 L510.16,272.27 L492.83,275.36 L521.42,309.51" />
<path fill="#fc0" d="M521.42,309.51 L491.94,283.01 L502.65,269.57 L521.42,309.51" />
<path fill="#fc0" d="M465.34,259.13 L501.78,264.74 L501.78,283.41 L465.34,259.13" />
<path fill="#fc0" d="M465.34,259.13 L494.82,285.61 L505.53,272.16 L465.34,259.13" />
<path fill="#fc0" d="M478.58,302.9 L489.84,269.13 L507.17,277.58 L478.58,302.9" />
<path fill="#fc0" d="M478.58,302.9 L508.06,285.51 L497.35,268.75 L478.58,302.9" />
<path fill="#fc0" d="M500,686.67 L488.74,720.43 L506.07,728.88 L500,686.67" />
<path fill="#fc0" d="M500,686.67 L511.26,723.92 L493.93,727 L500,686.67" />
<path fill="#fc0" d="M534.66,717.79 L498.22,712.19 L498.22,730.85 L534.66,717.79" />
<path fill="#fc0" d="M534.66,717.79 L505.18,735.21 L494.47,718.45 L534.66,717.79" />
<path fill="#fc0" d="M521.42,757.51 L510.16,720.27 L492.83,723.36 L521.42,757.51" />
<path fill="#fc0" d="M521.42,757.51 L491.94,731.01 L502.65,717.57 L521.42,757.51" />
<path fill="#fc0" d="M465.34,707.13 L501.78,712.74 L501.78,731.41 L465.34,707.13" />
<path fill="#fc0" d="M465.34,707.13 L494.82,733.61 L505.53,720.16 L465.34,707.13" />
<path fill="#fc0" d="M478.58,750.9 L489.84,717.13 L507.17,725.58 L478.58,750.9" />
<path fill="#fc0" d="M478.58,750.9 L508.06,733.51 L497.35,716.75 L478.58,750.9" />
<path fill="#fc0" d="M281.33,438.24 L270.07,473.54 L287.4,479.69 L281.33,438.24" />
<path fill="#fc0" d="M281.33,438.24 L292.6,474.11 L275.27,479.39 L281.33,438.24" />
<path fill="#fc0" d="M315.99,465.59 C303.97,464.84 291.94,464.29 279.55,464 L279.55,482.66 L315.99,465.59" />
<path fill="#fc0" d="M315.99,465.59 L286.51,486.13 L275.8,470.76 C289.06,468.76 302.33,467.06 315.99,465.59" />
<path fill="#fc0" d="M302.76,506.59 L291.49,470.59 L274.16,475.89 L302.76,506.59" />
<path fill="#fc0" d="M302.76,506.59 L273.27,483.67 L283.98,468.81 L302.76,506.59" />
<path fill="#fc0" d="M246.67,463.91 C258.7,463.69 270.72,463.75 283.12,464.09 L283.12,482.75 C271.09,476.27 259.06,470.03 246.67,463.91" />
<path fill="#fc0" d="M246.67,463.91 L276.16,485.87 L286.87,471.04 C273.6,468.37 260.34,465.98 246.67,463.91" />
<path fill="#fc0" d="M259.91,505.52 L271.17,470.08 L288.5,476.25 L259.91,505.52" />
<path fill="#fc0" d="M259.91,505.52 L289.4,484.08 L278.69,468.67 L259.91,505.52" />
<path fill="#fc0" d="M369.24,317.99 L398.73,299.53 L388.02,283.1 L369.24,317.99" />
<path fill="#fc0" d="M369.24,317.99 L380.51,283.75 L397.84,291.62 L369.24,317.99" />
<path fill="#fc0" d="M356.01,274.87 L385.49,300.04 L396.2,286.25 L356.01,274.87" />
<path fill="#fc0" d="M356.01,274.87 L392.45,278.94 L392.45,297.61 L356.01,274.87" />
<path fill="#fc0" d="M390.67,252.93 L379.4,287.09 L396.73,294.96 L390.67,252.93" />
<path fill="#fc0" d="M390.67,252.93 L401.93,289.85 L384.6,293.47 L390.67,252.93" />
<path fill="#fc0" d="M412.09,323.22 L382.6,297.55 L393.31,283.75 L412.09,323.22" />
<path fill="#fc0" d="M412.09,323.22 L400.83,286.23 L383.5,289.87 L412.09,323.22" />
<path fill="#fc0" d="M425.33,283.29 L395.84,301.31 L385.13,284.9 L425.33,283.29" />
<path fill="#fc0" d="M425.33,283.29 L388.88,278.51 L388.88,297.17 L425.33,283.29" />
<path fill="#fc0" d="M332.05,396.47 L320.79,360.14 L303.46,364.88 L332.05,396.47" />
<path fill="#fc0" d="M332.05,396.47 L302.57,372.63 L313.28,358.12 L332.05,396.47" />
<path fill="#fc0" d="M289.21,394.02 L318.69,373.56 L307.98,357.82 L289.21,394.02" />
<path fill="#fc0" d="M289.21,394.02 L300.47,358.97 L317.8,365.71 L289.21,394.02" />
<path fill="#fc0" d="M275.97,351.92 L305.45,374.93 L316.16,360.45 C302.9,357.36 289.63,354.51 275.97,351.92" />
<path fill="#fc0" d="M275.97,351.92 C288,352.16 300.02,352.64 312.41,353.37 L312.41,372.04 L275.97,351.92" />
<path fill="#fc0" d="M345.29,355.85 L315.81,375.53 L305.1,359.81 C318.36,358.25 331.62,356.95 345.29,355.85" />
<path fill="#fc0" d="M345.29,355.85 L308.85,353.17 L308.85,371.83 L345.29,355.85" />
<path fill="#fc0" d="M310.63,327.47 L321.89,363.69 L304.56,368.42 L310.63,327.47" />
<path fill="#fc0" d="M310.63,327.47 L299.37,362.4 L316.7,369.12 L310.63,327.47" />
<path fill="#fc0" d="M345.29,579.85 L308.85,577.17 L308.85,595.83 L345.29,579.85" />
<path fill="#fc0" d="M345.29,579.85 L315.81,599.53 L305.1,583.81 C318.36,582.25 331.62,580.95 345.29,579.85" />
<path fill="#fc0" d="M332.05,620.47 L320.79,584.14 L303.46,588.88 L332.05,620.47" />
<path fill="#fc0" d="M332.05,620.47 L302.57,596.63 L313.28,582.12 L332.05,620.47" />
<path fill="#fc0" d="M289.21,618.02 L318.69,597.56 L307.98,581.82 L289.21,618.02" />
<path fill="#fc0" d="M289.21,618.02 L300.47,582.97 L317.8,589.71 L289.21,618.02" />
<path fill="#fc0" d="M310.63,551.47 L321.89,587.69 L304.56,592.42 L310.63,551.47" />
<path fill="#fc0" d="M310.63,551.47 L299.37,586.4 L316.7,593.12 L310.63,551.47" />
<path fill="#fc0" d="M275.97,575.92 C288,576.16 300.02,576.64 312.41,577.37 L312.41,596.04 L275.97,575.92" />
<path fill="#fc0" d="M275.97,575.92 L305.45,598.93 L316.16,584.45 C302.9,581.36 289.63,578.51 275.97,575.92" />
<path fill="#fc0" d="M425.33,671.27 L388.88,666.49 L388.88,685.15 L425.33,671.27" />
<path fill="#fc0" d="M425.33,671.27 L395.84,689.29 L385.13,672.88 L425.33,671.27" />
<path fill="#fc0" d="M412.09,711.2 L400.83,674.21 L383.5,677.84 L412.09,711.2" />
<path fill="#fc0" d="M412.09,711.2 L382.6,685.53 L393.31,671.73 L412.09,711.2" />
<path fill="#fc0" d="M369.24,705.97 L398.73,687.51 L388.02,671.08 L369.24,705.97" />
<path fill="#fc0" d="M369.24,705.97 L380.51,671.73 L397.84,679.6 L369.24,705.97" />
<path fill="#fc0" d="M390.67,640.91 L401.93,677.83 L384.6,681.45 L390.67,640.91" />
<path fill="#fc0" d="M390.67,640.91 L379.4,675.07 L396.73,682.94 L390.67,640.91" />
<path fill="#fc0" d="M356.01,662.85 L392.45,666.92 L392.45,685.59 L356.01,662.85" />
<path fill="#fc0" d="M356.01,662.85 L385.49,688.02 L396.2,674.23 L356.01,662.85" />
<path fill="#fc0" d="M718.67,487.09 L729.93,522.81 L712.6,528.19 L718.67,487.09" />
<path fill="#fc0" d="M718.67,487.09 L707.4,522.24 L724.73,528.5 L718.67,487.09" />
<path fill="#fc0" d="M684.01,511.33 C696.03,512.09 708.06,512.63 720.45,512.93 L720.45,531.6 L684.01,511.33" />
<path fill="#fc0" d="M684.01,511.33 L713.49,534.68 L724.2,519.85 C710.94,517.34 697.67,514.52 684.01,511.33" />
<path fill="#fc0" d="M697.24,553.82 L708.51,518.8 L725.84,525.04 L697.24,553.82" />
<path fill="#fc0" d="M697.24,553.82 L726.73,532.85 L716.02,517.51 L697.24,553.82" />
<path fill="#fc0" d="M753.33,513.02 C741.3,513.24 729.28,513.17 716.88,512.84 L716.88,531.51 C728.91,525.67 740.93,519.59 753.33,513.02" />
<path fill="#fc0" d="M753.33,513.02 L723.84,534.95 L713.13,519.57 C726.4,517.73 739.66,515.59 753.33,513.02" />
<path fill="#fc0" d="M740.09,554.89 L728.83,519.32 L711.5,524.68 L740.09,554.89" />
<path fill="#fc0" d="M740.09,554.89 L710.6,532.44 L721.31,517.64 L740.09,554.89" />
<path fill="#fc0" d="M630.76,354.44 L601.27,329.01 L611.98,315.24 L630.76,354.44" />
<path fill="#fc0" d="M630.76,354.44 L619.49,317.67 L602.16,321.33 L630.76,354.44" />
<path fill="#fc0" d="M643.99,314.07 L614.51,332.79 L603.8,316.38 L643.99,314.07" />
<path fill="#fc0" d="M643.99,314.07 L607.55,310 L607.55,328.67 L643.99,314.07" />
<path fill="#fc0" d="M609.33,284.42 L620.6,321.27 L603.27,324.95 L609.33,284.42" />
<path fill="#fc0" d="M609.33,284.42 L598.07,318.52 L615.4,326.43 L609.33,284.42" />
<path fill="#fc0" d="M587.91,349.21 L617.4,330.99 L606.69,314.59 L587.91,349.21" />
<path fill="#fc0" d="M587.91,349.21 L599.17,315.18 L616.5,323.09 L587.91,349.21" />
<path fill="#fc0" d="M574.67,305.66 L604.16,331.52 L614.87,317.73 L574.67,305.66" />
<path fill="#fc0" d="M574.67,305.66 L611.12,310.44 L611.12,329.11 L574.67,305.66" />
<path fill="#fc0" d="M667.95,439.93 L679.21,405.25 L696.54,412.05 L667.95,439.93" />
<path fill="#fc0" d="M667.95,439.93 L697.43,419.88 L686.72,404.19 L667.95,439.93" />
<path fill="#fc0" d="M710.79,442.39 L681.31,418.95 L692.02,404.5 L710.79,442.39" />
<path fill="#fc0" d="M710.79,442.39 L699.53,406.42 L682.2,411.22 L710.79,442.39" />
<path fill="#fc0" d="M724.03,401.01 L694.55,421.89 L683.84,406.16 C697.1,404.74 710.36,403.06 724.03,401.01" />
<path fill="#fc0" d="M724.03,401.01 C712,400.77 699.98,400.28 687.59,399.55 L687.59,418.22 L724.03,401.01" />
<path fill="#fc0" d="M654.71,397.08 L684.19,421.29 L694.9,406.8 C681.64,403.85 668.38,400.63 654.71,397.08" />
<path fill="#fc0" d="M654.71,397.08 L691.15,399.76 L691.15,418.42 L654.71,397.08" />
<path fill="#fc0" d="M689.37,373.86 L678.11,408.65 L695.44,415.47 L689.37,373.86" />
<path fill="#fc0" d="M689.37,373.86 L700.63,409.95 L683.3,414.76 L689.37,373.86" />
<path fill="#fc0" d="M654.71,621.08 L691.15,623.76 L691.15,642.42 L654.71,621.08" />
<path fill="#fc0" d="M654.71,621.08 L684.19,645.29 L694.9,630.8 C681.64,627.85 668.38,624.63 654.71,621.08" />
<path fill="#fc0" d="M667.95,663.93 L679.21,629.25 L696.54,636.05 L667.95,663.93" />
<path fill="#fc0" d="M667.95,663.93 L697.43,643.88 L686.72,628.19 L667.95,663.93" />
<path fill="#fc0" d="M710.79,666.39 L681.31,642.95 L692.02,628.5 L710.79,666.39" />
<path fill="#fc0" d="M710.79,666.39 L699.53,630.42 L682.2,635.22 L710.79,666.39" />
<path fill="#fc0" d="M689.37,597.86 L678.11,632.65 L695.44,639.47 L689.37,597.86" />
<path fill="#fc0" d="M689.37,597.86 L700.63,633.95 L683.3,638.76 L689.37,597.86" />
<path fill="#fc0" d="M724.03,625.01 C712,624.77 699.98,624.28 687.59,623.55 L687.59,642.22 L724.03,625.01" />
<path fill="#fc0" d="M724.03,625.01 L694.55,645.89 L683.84,630.16 C697.1,628.74 710.36,627.06 724.03,625.01" />
<path fill="#fc0" d="M574.67,693.64 L611.12,698.42 L611.12,717.09 L574.67,693.64" />
<path fill="#fc0" d="M574.67,693.64 L604.16,719.5 L614.87,705.71 L574.67,693.64" />
<path fill="#fc0" d="M587.91,737.19 L599.17,703.16 L616.5,711.07 L587.91,737.19" />
<path fill="#fc0" d="M587.91,737.19 L617.4,718.97 L606.69,702.57 L587.91,737.19" />
<path fill="#fc0" d="M630.76,742.42 L601.27,716.99 L611.98,703.22 L630.76,742.42" />
<path fill="#fc0" d="M630.76,742.42 L619.49,705.65 L602.16,709.31 L630.76,742.42" />
<path fill="#fc0" d="M609.33,672.41 L598.07,706.49 L615.4,714.41 L609.33,672.41" />
<path fill="#fc0" d="M609.33,672.41 L620.6,709.25 L603.27,712.93 L609.33,672.41" />
<path fill="#fc0" d="M643.99,702.05 L607.55,697.98 L607.55,716.65 L643.99,702.05" />
<path fill="#fc0" d="M643.99,702.05 L614.51,720.77 L603.8,704.36 L643.99,702.05" />
<path fill="#1A1A1A" opacity="0.2" d="M8,195.25 C178.83,110.03 349.03,140.83 521.26,167.28 C676.47,191.12 833.42,211.85 992,132.75 L992,804.75 C821.17,889.97 650.97,859.17 478.74,832.72 C323.53,808.88 166.58,788.15 8,867.25 L8,195.25 M39.25,214.64 L39.25,819.14 C345.81,690.88 650.43,915.18 960.75,785.36 L960.75,180.86 C654.19,309.12 349.57,84.82 39.25,214.64" />
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

+50
View File
@@ -0,0 +1,50 @@
<svg version="1.1" class="<%= local_assigns[:class] %>" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
y="0px" viewBox="0 0 128 128" style="enable-background:new 0 0 128 128;" xml:space="preserve">
<g>
<g>
<path style="fill:#FFCA28;" d="M17.57,62.68c-2.79-4.01-3.91-7.79-1.18-10.08c2.31-1.94,5.71-2.31,9.91,2.54
c0,0,12.55,14.58,16.87,18.63c0.98,0.91,2.2,1.05,3.31-0.1c0.94-0.98,0.6-1.83-0.2-3c0,0-17.69-26.3-20.01-29.51
c-3.87-5.37-2.38-8.84-0.59-10.49c2.49-2.31,6.87-2.77,10.94,2.81l21.42,28.67c0.65,0.69,1.7,0.79,2.47,0.25
c0.1-0.07,0.2-0.14,0.3-0.21c0.79-0.56,1.02-1.63,0.54-2.47c-3.75-6.53-18.67-32.55-20.86-37.29c-2.52-5.47-1.44-8.25,1.23-9.86
c3.17-1.91,6.15-1.77,9.71,3.52c3.86,5.76,18.85,30.01,22.66,36.53c0.5,0.85,1.57,1.17,2.45,0.74c0.01-0.01,0.03-0.01,0.04-0.02
c0.84-0.41,1.6-1.24,1.25-2.42c-2.08-6.86-12.29-28.22-14.43-33.13c-2.93-6.71-1.5-8.99,1.53-10.53c3.18-1.61,6.49-0.34,8.74,4.14
c1.52,3.04,28.21,51.61,28.21,51.61c-0.39-7.24,1.44-12.37,3-17.52c2.84-9.37,9.71-14.24,14.69-12.05
c2.59,1.14,3.06,3.41,2.78,5.15c-0.56,3.38-2.94,13.85-3.4,22.05c-1.08,19.22,4.73,41.37-16.92,55.29
c-14.49,9.32-30.02,7.68-40.28,0.51C49.31,107.75,19.85,65.96,17.57,62.68z" />
<path style="fill:#EDA600;" d="M117.68,51.77c-1.81,7.61-2.05,16.95-1.99,20.93c0.24,15.51,0.16,28.93-15.39,41.19
c-1.91,1.51-7.9,5.19-14.87,7.11c-2.24,0.61-1.39,1.33-0.01,1.17c7.61-0.88,13.38-4.16,16.59-6.23
c21.64-13.92,16.32-35.92,17.4-55.14c0.46-8.2,2.96-21.1,2.92-22.21C122.28,37.48,119.49,44.16,117.68,51.77z" />
<path style="fill:#EDA600;" d="M77.53,52.57c0,0-1.55,0.01-3.02-2.04C70.1,44.4,56.27,21.25,52.29,15.57
c-4.54-6.48-8.77-4-9.73-3.48c0,0,3.48,0.12,5.4,2.97c3.92,5.81,18.78,31.85,23.47,37.44C74.46,56.11,77.53,52.57,77.53,52.57z" />
<path style="fill:#EDA600;" d="M22.71,54.54c1.36,1.46,13.3,15.63,17.7,19.58c3.78,3.39,6.6-0.93,6.6-0.93s-1.39,0.19-3.29-1.24
c-4.72-3.57-15.9-16.3-18.42-18.98c-3.74-3.98-7.22-1.49-7.96-1.04C17.35,51.93,19.49,51.1,22.71,54.54z" />
<path style="fill:#EDA600;" d="M61.59,61.26c0,0-1.28,0.93-3.63-1.82c-1.71-2.01-21.34-25.96-21.34-25.96
c-4.92-6.01-8.79-4.22-9.59-3.76c0,0,2.47-0.13,5.36,3.2c1.32,1.52,22.27,28.23,23.11,29.14C58.57,65.41,61.41,62.53,61.59,61.26z
" />
<path style="fill:#EDA600;" d="M101.8,57.83c0,0-26.01-43.61-27.67-46.58c-3.69-6.59-7.98-4.57-8.71-4.24c0,0,2.39-0.68,5.2,4.1
c1.5,2.54,20.61,36.9,26.71,47.88c0.48,2.05,0.44,4.72-1.1,6.44c-5.52,6.19-12.56,12.51-10.49,28.18
c0.52,3.97,1.99,7.73,3.08,9.54c1.38,2.27,2.97,1.41,2.27-0.29c-0.47-1.15-1.22-3.86-1.37-5.02c-0.7-5.4-3.06-14.84,7.52-26.16
C99.03,69.79,103.58,64.08,101.8,57.83z" />
</g>
<g>
<path style="fill:#B0BEC5;" d="M103.49,30.96c-1.39-4.93-3.55-9.45-6.35-13.37c-2.52-3.53-5.57-6.57-9.05-9.01
c-0.44-0.31-1-1.27-0.52-2.2s1.52-0.81,1.91-0.65c4.35,1.79,7.91,4.88,10.85,9.12c3.49,5.04,5.22,9.79,6.11,15.1
c0.1,0.58,0.08,1.8-1.16,2.12C104.05,32.39,103.63,31.44,103.49,30.96z" />
</g>
<g>
<path style="fill:#B0BEC5;" d="M96.69,36.25c-1.39-4.93-3.55-9.45-6.35-13.37c-2.52-3.53-5.57-6.57-9.05-9.01
c-0.44-0.31-1-1.27-0.52-2.2c0.48-0.94,1.52-0.81,1.91-0.65c4.35,1.79,7.91,4.88,10.85,9.12c3.49,5.04,5.22,9.79,6.11,15.1
c0.1,0.58,0.08,1.8-1.16,2.12C97.26,37.68,96.83,36.74,96.69,36.25z" />
</g>
<g>
<path style="fill:#B0BEC5;" d="M8.56,77.46c1.72,4.83,4.17,9.19,7.22,12.92c2.75,3.35,5.99,6.19,9.62,8.4
c0.46,0.28,1.08,1.2,0.66,2.16c-0.42,0.97-1.46,0.91-1.87,0.78c-4.46-1.5-8.22-4.35-11.43-8.39c-3.81-4.8-5.86-9.42-7.1-14.66
c-0.14-0.58-0.2-1.79,1.02-2.19C7.9,76.08,8.39,76.99,8.56,77.46z" />
</g>
<g>
<path style="fill:#B0BEC5;" d="M14.99,71.73c1.72,4.83,4.17,9.19,7.22,12.92c2.75,3.35,5.99,6.19,9.62,8.4
c0.46,0.28,1.08,1.2,0.66,2.16c-0.42,0.97-1.46,0.91-1.87,0.78c-4.46-1.5-8.22-4.35-11.43-8.39c-3.81-4.8-5.86-9.42-7.1-14.66
c-0.14-0.58-0.2-1.79,1.02-2.19C14.33,70.34,14.82,71.26,14.99,71.73z" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" />
<path d="M3.6 9h16.8" />
<path d="M3.6 15h16.8" />
<path d="M11.5 3a17 17 0 0 0 0 18" />
<path d="M12.5 3a17 17 0 0 1 0 18" />
</svg>

After

Width:  |  Height:  |  Size: 470 B

+4 -1
View File
@@ -1,5 +1,8 @@
<div class="max-w-xl mx-auto px-2"> <div class="max-w-xl mx-auto px-2">
<h1 class="text-4xl font-bold text-center my-8">👋 Welcome to <%= Docuseal.product_name %></h1> <h1 class="flex text-4xl font-bold items-center justify-center my-8 space-x-2">
<%= svg_icon('waving_hand', class: 'h-10 w-10') %>
<span>Welcome to <%= Docuseal.product_name %></span>
</h1>
<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put, class: 'space-y-6' }) do |f| %> <%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put, class: 'space-y-6' }) do |f| %>
<div class="space-y-2"> <div class="space-y-2">
<%= render 'devise/shared/error_messages', resource: %> <%= render 'devise/shared/error_messages', resource: %>
Loaded 100 of 192 files, more files were not shown because too many files have changed in this diff. Show more