Compare commits

...

68 Commits

Author SHA1 Message Date
Alex Turchyn 7c5c8779ec fix form translations 2023-09-05 21:39:04 +03:00
Alex Turchyn 37ed110ad4 better submitter select icon 2023-09-05 20:50:25 +03:00
Alex Turchyn 2ad5f8a316 handle unsupported file types 2023-09-05 20:40:52 +03:00
Alex Turchyn e7594d81b6 fix signature step next button disabled 2023-09-05 01:35:48 +03:00
Alex Turchyn e7c5f14860 fix webhook serializer 2023-09-05 01:09:46 +03:00
Alex Turchyn c38830dd1c add phone type placeholder 2023-09-05 01:00:21 +03:00
Alex Turchyn 2cbf429293 better if readability 2023-09-04 19:06:16 +03:00
Alex Turchyn 517c292c17 add submitter name email variable 2023-09-04 10:08:32 +03:00
Alex Turchyn 14bd039a88 fix email template variable 2023-09-04 02:12:03 +03:00
Alex Turchyn 8c8c34cb75 add server switch 2023-09-04 01:39:16 +03:00
Alex Turchyn 0cc53066eb refactor 2023-09-03 20:44:54 +03:00
Alex Turchyn 3d13d28e05 add czech form translations 2023-09-03 19:07:18 +03:00
Alex Turchyn a80e20234f add name and phone to submitter details 2023-09-03 19:07:18 +03:00
Alex Turchyn b1b46cf998 use proxy urls for webhook attachments 2023-09-01 19:08:35 +03:00
Alex Turchyn 52ef0ae929 make pdf links clickable 2023-09-01 10:08:27 +03:00
Alex Turchyn c4daf8ed49 fix pdf signature validation and certs 2023-08-31 02:10:10 +03:00
Alex Turchyn c098e61947 fix redirect 2023-08-31 02:10:10 +03:00
Alex Turchyn ceb052b158 start translating the form 2023-08-31 02:10:10 +03:00
Alex Turchyn 61b79d5b24 fix submissions api 2023-08-31 02:10:10 +03:00
Alex Turchyn 5a9cffc6fc fix subdomain urls 2023-08-31 02:10:10 +03:00
Alex Turchyn 08447ec4cf fix builder field button bg color 2023-08-31 02:10:10 +03:00
Alex Turchyn d3a5f36555 add ability to use own signing cert 2023-08-31 02:10:09 +03:00
Alex Turchyn c9f39e7b1a fix sign up redirect 2023-08-27 23:34:00 +03:00
Alex Turchyn 1060aa84ba adjust placeholder 2023-08-27 23:33:55 +03:00
Alex Turchyn b6f68ddde3 remove redundant validation 2023-08-27 16:08:25 +03:00
Alex Turchyn 12dd96e34c bring back sign up path 2023-08-26 01:24:14 +03:00
Alex Turchyn 52ba415429 use active job test queue adapter in rspec 2023-08-26 01:20:43 +03:00
Alex Turchyn 601c984f3f do not display registration routes for signed up users 2023-08-25 23:54:55 +03:00
Alex Turchyn 9d90304d9a fix builder width handler 2023-08-25 23:44:14 +03:00
Alex Turchyn 788493c0a6 support UTF-8 text via ttf 2023-08-25 00:28:02 +03:00
Alex Turchyn e1342860c4 add redirect handler 2023-08-24 00:50:57 +03:00
Alex Turchyn 88968897e9 update gems 2023-08-21 20:09:56 +03:00
Alex Turchyn 966b786a30 store template submitters on submission creation 2023-08-21 16:32:39 +03:00
Alex Turchyn bc3b1b477e add export button 2023-08-20 15:18:15 +03:00
Alex Turchyn cbc6f4497a add base feature specs 2023-08-20 15:15:48 +03:00
Alex Turchyn 6b4e7c2264 allow to edit email templates 2023-08-20 00:11:11 +03:00
Alex Turchyn 8e6639d891 add a link to the console in navbar 2023-08-20 00:05:00 +03:00
Alex Turchyn 8e39f0078d add source to templates 2023-08-19 22:08:17 +03:00
Alex Turchyn 46f350217a refactor and fixes 2023-08-19 21:30:36 +03:00
Alex Turchyn 077c55f7e8 fix rubocop 2023-08-17 21:30:50 +03:00
Alex Turchyn e5c5620b20 collect ua and ip on form completion 2023-08-17 21:30:49 +03:00
Alex Turchyn 187b354605 add personalization settings 2023-08-16 23:51:55 +03:00
Alex Turchyn 545b18086a rescue all email smtp errors 2023-08-15 21:51:46 +03:00
Alex Turchyn 130c91178f fix templates api page when new installation 2023-08-15 21:39:25 +03:00
Alex Turchyn 47c57158f4 refactor and fixes 2023-08-14 00:48:42 +03:00
Alex Turchyn 3b745a650d optimize dockerfile 2023-08-12 15:48:50 +03:00
Alex Turchyn 6871a3f840 send emails when configured smtp configured from env 2023-08-11 18:32:43 +03:00
Alex Turchyn c57148eada allow to set default field values via api 2023-08-11 18:32:33 +03:00
Alex Turchyn d875477ee6 improve SMTP configs form 2023-08-10 23:44:49 +03:00
Alex Turchyn 1daee8214c fix anonymous checkboxes form 2023-08-09 21:10:07 +03:00
Alex Turchyn 9cc85595e3 fix font size on large pdfs 2023-08-09 10:09:36 +03:00
Alex Turchyn f6879d7f94 fix GitHub CI 2023-08-08 23:38:53 +03:00
Alex Turchyn f6247e68b5 fix typo 2023-08-08 23:28:26 +03:00
Alex Turchyn 454acb63db update readme 2023-08-08 23:28:26 +03:00
Alex Turchyn 6d78d5430c add access tokens 2023-08-08 23:28:26 +03:00
Alex Turchyn e75e7b18b4 developer console fixes 2023-08-08 23:28:26 +03:00
Alex Turchyn 644b142798 add source to submissions 2023-08-08 23:28:25 +03:00
Alex Turchyn e4bb5466f5 add webhook settings 2023-08-08 23:28:25 +03:00
Alex Turchyn 8929adbe83 add settings api page 2023-08-08 23:28:25 +03:00
Alex Turchyn 896b149b25 add console redirect 2023-08-08 23:28:25 +03:00
Alex Turchyn 7697b94f73 show alert when upload non pdf or image 2023-08-08 23:28:25 +03:00
Alex Turchyn 7912c9a1ee add submitters api 2023-08-08 23:28:25 +03:00
Alex Turchyn 63e1246a11 adjust readme 2023-08-08 23:27:44 +03:00
Alex Turchyn cbca34a7d7 add reply to into submission invitation email 2023-08-02 23:22:31 +03:00
Alex Turchyn 9471a510c9 adjust email footer 2023-08-02 23:22:31 +03:00
Alex Turchyn e754c5d414 update README 2023-08-02 14:25:23 +03:00
Alex Turchyn 182f593d86 fix form previous day date bug 2023-08-01 00:26:37 +03:00
Alex Turchyn 7f4548e5c7 use company name for email friendly from name 2023-07-31 22:53:49 +03:00
196 changed files with 4778 additions and 865 deletions
+2
View File
@@ -1,5 +1,6 @@
/db/*.sqlite3
**/node_modules
/node_modules
/coverage
/doc
@@ -27,3 +28,4 @@
/attachments
/docuseal
.DS_Store
.env
+1
View File
@@ -127,6 +127,7 @@ jobs:
bundle config path vendor/bundle
bundle install --jobs 4 --retry 4
yarn install
sudo apt-get update
sudo apt-get install libvips
- name: Run
env:
+1
View File
@@ -35,3 +35,4 @@ yarn-debug.log*
/coverage
/attachments
/docuseal
/ee
+6 -3
View File
@@ -27,7 +27,7 @@ Lint/MissingSuper:
Enabled: false
Metrics/MethodLength:
Max: 20
Max: 25
Exclude:
- 'db/migrate/**'
@@ -53,10 +53,13 @@ RSpec/NestedGroups:
Max: 6
RSpec/MultipleExpectations:
Max: 7
Max: 20
RSpec/ExampleLength:
Max: 15
Max: 40
RSpec/MultipleMemoizedHelpers:
Max: 6
Rails/I18nLocaleTexts:
Enabled: false
+10 -2
View File
@@ -31,13 +31,21 @@ ENV BUNDLE_WITHOUT="development:test"
WORKDIR /app
RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler vips-heif libc6-compat ttf-freefont
RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler vips-heif libc6-compat ttf-freefont ttf-liberation && cp /usr/share/fonts/liberation/LiberationSans-Regular.ttf /LiberationSans-Regular.ttf && apk del ttf-liberation
COPY ./Gemfile ./Gemfile.lock ./
RUN bundle update --bundler && bundle install && rm -rf ~/.bundle
COPY . ./
COPY ./bin ./bin
COPY ./app ./app
COPY ./config ./config
COPY ./db ./db
COPY ./log ./log
COPY ./lib ./lib
COPY ./public ./public
COPY ./tmp ./tmp
COPY LICENSE README.md Rakefile config.ru ./
COPY --from=webpack /app/public/packs ./public/packs
+4
View File
@@ -13,19 +13,23 @@ gem 'faraday'
gem 'google-cloud-storage', require: false
gem 'hexapdf'
gem 'image_processing'
gem 'jwt'
gem 'lograge'
gem 'mysql2', require: false
gem 'oj'
gem 'omniauth-google-oauth2'
gem 'omniauth-rails_csrf_protection'
gem 'pagy'
gem 'pdf-reader'
gem 'pg', require: false
gem 'premailer-rails'
gem 'puma'
gem 'rails'
gem 'rails_autolink'
gem 'rails-i18n'
gem 'rollbar', require: ENV.key?('ROLLBAR_ACCESS_TOKEN')
gem 'ruby-vips'
gem 'rubyXL'
gem 'shakapacker'
gem 'sidekiq', require: ENV.key?('REDIS_URL')
gem 'sqlite3', require: false
+133 -102
View File
@@ -1,92 +1,94 @@
GEM
remote: https://rubygems.org/
specs:
actioncable (7.0.5)
actionpack (= 7.0.5)
activesupport (= 7.0.5)
Ascii85 (1.1.0)
actioncable (7.0.7)
actionpack (= 7.0.7)
activesupport (= 7.0.7)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (7.0.5)
actionpack (= 7.0.5)
activejob (= 7.0.5)
activerecord (= 7.0.5)
activestorage (= 7.0.5)
activesupport (= 7.0.5)
actionmailbox (7.0.7)
actionpack (= 7.0.7)
activejob (= 7.0.7)
activerecord (= 7.0.7)
activestorage (= 7.0.7)
activesupport (= 7.0.7)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
actionmailer (7.0.5)
actionpack (= 7.0.5)
actionview (= 7.0.5)
activejob (= 7.0.5)
activesupport (= 7.0.5)
actionmailer (7.0.7)
actionpack (= 7.0.7)
actionview (= 7.0.7)
activejob (= 7.0.7)
activesupport (= 7.0.7)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.0)
actionpack (7.0.5)
actionview (= 7.0.5)
activesupport (= 7.0.5)
actionpack (7.0.7)
actionview (= 7.0.7)
activesupport (= 7.0.7)
rack (~> 2.0, >= 2.2.4)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (7.0.5)
actionpack (= 7.0.5)
activerecord (= 7.0.5)
activestorage (= 7.0.5)
activesupport (= 7.0.5)
actiontext (7.0.7)
actionpack (= 7.0.7)
activerecord (= 7.0.7)
activestorage (= 7.0.7)
activesupport (= 7.0.7)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.0.5)
activesupport (= 7.0.5)
actionview (7.0.7)
activesupport (= 7.0.7)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
activejob (7.0.5)
activesupport (= 7.0.5)
activejob (7.0.7)
activesupport (= 7.0.7)
globalid (>= 0.3.6)
activemodel (7.0.5)
activesupport (= 7.0.5)
activerecord (7.0.5)
activemodel (= 7.0.5)
activesupport (= 7.0.5)
activestorage (7.0.5)
actionpack (= 7.0.5)
activejob (= 7.0.5)
activerecord (= 7.0.5)
activesupport (= 7.0.5)
activemodel (7.0.7)
activesupport (= 7.0.7)
activerecord (7.0.7)
activemodel (= 7.0.7)
activesupport (= 7.0.7)
activestorage (7.0.7)
actionpack (= 7.0.7)
activejob (= 7.0.7)
activerecord (= 7.0.7)
activesupport (= 7.0.7)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
activesupport (7.0.5)
activesupport (7.0.7)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
tzinfo (~> 2.0)
addressable (2.8.4)
addressable (2.8.5)
public_suffix (>= 2.0.2, < 6.0)
afm (0.2.2)
annotate (3.2.0)
activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0)
ast (2.4.2)
aws-eventstream (1.2.0)
aws-partitions (1.781.0)
aws-sdk-core (3.175.0)
aws-partitions (1.807.0)
aws-sdk-core (3.180.3)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.67.0)
aws-sdk-core (~> 3, >= 3.174.0)
aws-sdk-kms (1.71.0)
aws-sdk-core (~> 3, >= 3.177.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.126.0)
aws-sdk-core (~> 3, >= 3.174.0)
aws-sdk-s3 (1.132.1)
aws-sdk-core (~> 3, >= 3.179.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4)
aws-sigv4 (1.5.2)
aws-sigv4 (~> 1.6)
aws-sigv4 (1.6.0)
aws-eventstream (~> 1, >= 1.0.2)
azure-storage-blob (2.0.3)
azure-storage-common (~> 2.0)
@@ -96,8 +98,9 @@ GEM
faraday_middleware (~> 1.0, >= 1.0.0.rc1)
net-http-persistent (~> 4.0)
nokogiri (~> 1, >= 1.10.8)
base64 (0.1.1)
bcrypt (3.1.19)
better_html (2.0.1)
better_html (2.0.2)
actionview (>= 6.0)
activesupport (>= 6.0)
ast (~> 2.0)
@@ -105,7 +108,7 @@ GEM
parser (>= 2.4)
smart_properties
bindex (0.8.1)
bootsnap (1.15.0)
bootsnap (1.16.0)
msgpack (~> 1.2)
builder (3.2.4)
bullet (7.0.7)
@@ -144,7 +147,7 @@ GEM
responders
warden (~> 1.2.3)
diff-lcs (1.5.0)
digest-crc (0.6.4)
digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0)
docile (1.4.0)
dotenv (2.8.1)
@@ -161,7 +164,7 @@ GEM
factory_bot_rails (6.2.0)
factory_bot (~> 6.2.0)
railties (>= 5.0.0)
faker (3.2.0)
faker (3.2.1)
i18n (>= 1.8.11, < 2)
faraday (1.10.3)
faraday-em_http (~> 1.0)
@@ -194,10 +197,10 @@ GEM
webrick (~> 1.7)
websocket-driver (>= 0.6, < 0.8)
ffi (1.15.5)
geom2d (0.3.1)
geom2d (0.4.1)
globalid (1.1.0)
activesupport (>= 5.0)
google-apis-core (0.11.0)
google-apis-core (0.11.1)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
@@ -224,7 +227,7 @@ GEM
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.6.0)
googleauth (1.7.0)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
memoist (~> 0.16)
@@ -232,10 +235,11 @@ GEM
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
hashdiff (1.0.1)
hashery (2.1.2)
hashie (5.0.0)
hexapdf (0.32.2)
hexapdf (0.33.0)
cmdparse (~> 3.0, >= 3.0.3)
geom2d (~> 0.3)
geom2d (~> 0.4, >= 0.4.1)
openssl (>= 2.2.1)
htmlentities (4.3.4)
httpclient (2.8.3)
@@ -245,8 +249,8 @@ GEM
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
io-console (0.6.0)
irb (1.7.0)
reline (>= 0.3.0)
irb (1.7.4)
reline (>= 0.3.6)
jmespath (1.6.2)
json (2.6.3)
jwt (2.7.1)
@@ -260,7 +264,7 @@ GEM
letter_opener (~> 1.7)
railties (>= 5.2)
rexml
lograge (0.12.0)
lograge (0.13.0)
actionpack (>= 4)
activesupport (>= 4)
railties (>= 4)
@@ -278,17 +282,17 @@ GEM
memoist (0.16.2)
method_source (1.0.0)
mini_magick (4.12.0)
mini_mime (1.1.2)
mini_portile2 (2.8.2)
minitest (5.18.1)
msgpack (1.7.1)
mini_mime (1.1.5)
mini_portile2 (2.8.4)
minitest (5.19.0)
msgpack (1.7.2)
multi_json (1.15.0)
multi_xml (0.6.0)
multipart-post (2.3.0)
mysql2 (0.5.5)
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.3.6)
net-imap (0.3.7)
date
net-protocol
net-pop (0.1.2)
@@ -298,7 +302,10 @@ GEM
net-smtp (0.3.3)
net-protocol
nio4r (2.5.9)
nokogiri (1.15.2-arm64-darwin)
nokogiri (1.15.4)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.15.4-arm64-darwin)
racc (~> 1.4)
oauth2 (2.0.9)
faraday (>= 0.17.3, < 3.0)
@@ -307,7 +314,7 @@ GEM
rack (>= 1.2, < 4)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
oj (3.15.0)
oj (3.16.0)
omniauth (2.1.1)
hashie (>= 3.4.6)
rack (>= 2.2.3)
@@ -331,6 +338,12 @@ GEM
parser (3.2.2.3)
ast (~> 2.4.1)
racc
pdf-reader (2.11.0)
Ascii85 (~> 1.0)
afm (~> 0.2.1)
hashery (~> 2.0)
ruby-rc4
ttfunk
pg (1.5.3)
premailer (1.21.0)
addressable
@@ -345,53 +358,58 @@ GEM
method_source (~> 1.0)
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (5.0.1)
puma (6.3.0)
public_suffix (5.0.3)
puma (6.3.1)
nio4r (~> 2.0)
racc (1.7.1)
rack (2.2.7)
rack-protection (3.0.6)
rack
rack (2.2.8)
rack-protection (3.1.0)
rack (~> 2.2, >= 2.2.4)
rack-proxy (0.7.6)
rack
rack-test (2.1.0)
rack (>= 1.3)
rails (7.0.5)
actioncable (= 7.0.5)
actionmailbox (= 7.0.5)
actionmailer (= 7.0.5)
actionpack (= 7.0.5)
actiontext (= 7.0.5)
actionview (= 7.0.5)
activejob (= 7.0.5)
activemodel (= 7.0.5)
activerecord (= 7.0.5)
activestorage (= 7.0.5)
activesupport (= 7.0.5)
rails (7.0.7)
actioncable (= 7.0.7)
actionmailbox (= 7.0.7)
actionmailer (= 7.0.7)
actionpack (= 7.0.7)
actiontext (= 7.0.7)
actionview (= 7.0.7)
activejob (= 7.0.7)
activemodel (= 7.0.7)
activerecord (= 7.0.7)
activestorage (= 7.0.7)
activesupport (= 7.0.7)
bundler (>= 1.15.0)
railties (= 7.0.5)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
railties (= 7.0.7)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
nokogiri (~> 1.14)
rails-i18n (7.0.7)
rails-i18n (7.0.8)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8)
railties (7.0.5)
actionpack (= 7.0.5)
activesupport (= 7.0.5)
rails_autolink (1.1.8)
actionview (> 3.1)
activesupport (> 3.1)
railties (> 3.1)
railties (7.0.7)
actionpack (= 7.0.7)
activesupport (= 7.0.7)
method_source
rake (>= 12.2)
thor (~> 1.0)
zeitwerk (~> 2.5)
rainbow (3.1.1)
rake (13.0.6)
redis-client (0.14.1)
redis-client (0.16.0)
connection_pool
regexp_parser (2.8.1)
reline (0.3.5)
reline (0.3.8)
io-console (~> 0.5)
representable (3.2.0)
declarative (< 0.1.0)
@@ -403,14 +421,14 @@ GEM
actionpack (>= 5.2)
railties (>= 5.2)
retriable (3.1.2)
rexml (3.2.5)
rexml (3.2.6)
rollbar (3.4.0)
rspec-core (3.12.2)
rspec-support (~> 3.12.0)
rspec-expectations (3.12.3)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0)
rspec-mocks (3.12.5)
rspec-mocks (3.12.6)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0)
rspec-rails (6.0.3)
@@ -421,8 +439,9 @@ GEM
rspec-expectations (~> 3.12)
rspec-mocks (~> 3.12)
rspec-support (~> 3.12)
rspec-support (3.12.0)
rubocop (1.53.0)
rspec-support (3.12.1)
rubocop (1.56.1)
base64 (~> 0.1.1)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
@@ -430,7 +449,7 @@ GEM
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.28.0, < 2.0)
rubocop-ast (>= 1.28.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.29.0)
@@ -439,23 +458,28 @@ GEM
rubocop (~> 1.41)
rubocop-factory_bot (2.23.1)
rubocop (~> 1.33)
rubocop-performance (1.18.0)
rubocop-performance (1.19.0)
rubocop (>= 1.7.0, < 2.0)
rubocop-ast (>= 0.4.0)
rubocop-rails (2.20.2)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0)
rubocop-rspec (2.22.0)
rubocop-rspec (2.23.2)
rubocop (~> 1.33)
rubocop-capybara (~> 2.17)
rubocop-factory_bot (~> 2.22)
ruby-progressbar (1.13.0)
ruby-rc4 (0.1.5)
ruby-vips (2.1.4)
ffi (~> 1.12)
ruby2_keywords (0.0.5)
rubyXL (3.4.25)
nokogiri (>= 1.10.8)
rubyzip (>= 1.3.0)
rubyzip (2.3.2)
semantic_range (3.0.0)
shakapacker (7.0.0)
shakapacker (7.0.3)
activesupport (>= 5.2)
rack-proxy (>= 0.6.1)
railties (>= 5.2)
@@ -480,13 +504,15 @@ GEM
snaky_hash (2.0.1)
hashie
version_gem (~> 1.1, >= 1.1.1)
sqlite3 (1.5.4)
sqlite3 (1.6.3)
mini_portile2 (~> 2.8.0)
sqlite3 (1.6.3-arm64-darwin)
strip_attributes (1.13.0)
activemodel (>= 3.0, < 8.0)
thor (1.2.2)
timeout (0.4.0)
trailblazer-option (0.1.2)
ttfunk (1.7.0)
turbo-rails (1.4.0)
actionpack (>= 6.0.0)
activejob (>= 6.0.0)
@@ -511,15 +537,16 @@ GEM
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.8.1)
websocket-driver (0.7.5)
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.6.8)
zeitwerk (2.6.11)
PLATFORMS
arm64-darwin-22
ruby
DEPENDENCIES
annotate
@@ -540,6 +567,7 @@ DEPENDENCIES
google-cloud-storage
hexapdf
image_processing
jwt
letter_opener_web
lograge
mysql2
@@ -547,12 +575,14 @@ DEPENDENCIES
omniauth-google-oauth2
omniauth-rails_csrf_protection
pagy
pdf-reader
pg
premailer-rails
pry-rails
puma
rails
rails-i18n
rails_autolink
rollbar
rspec-rails
rubocop
@@ -560,6 +590,7 @@ DEPENDENCIES
rubocop-rails
rubocop-rspec
ruby-vips
rubyXL
shakapacker
sidekiq
simplecov
+4 -2
View File
@@ -42,6 +42,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
- [x] PDF signature verification
- [x] Users management
- [x] Mobile-optimized
- [x] API and Webhooks for integrations
- [x] Easy to deploy in minutes
## Deploy
@@ -76,10 +77,11 @@ HOST=your-domain-name.com docker-compose up
## For Companies
### Integrate seamless document signing into your web or mobile apps with DocuSeal!
At DocuSeal we have expertise and technologies to make documents creation, filling, signing and processing seamlessly integrated with your product. We specialize in working with various industries, including **Banking, Healthcare, Transport, eCommerce, KYC, CRM, and other software products** that require bulk document signing. By leveraging DocuSeal, we can assist in reducing the overall cost of developing and processing electronic documents while ensuring security and compliance with local electronic document laws.
At DocuSeal we have expertise and technologies to make documents creation, filling, signing and processing seamlessly integrated with your product. We specialize in working with various industries, including **Banking, Healthcare, Transport, Real Estate, eCommerce, KYC, CRM, and other software products** that require bulk document signing. By leveraging DocuSeal, we can assist in reducing the overall cost of developing and processing electronic documents while ensuring security and compliance with local electronic document laws.
[![Book an Integration Demo](https://cal.com/book-with-cal-dark.svg)](https://cal.com/docuseal)
## License
DocuSeal is released under the GNU Affero General Public License v3.0.
Distributed under the AGPLv3 License. See [LICENSE](https://github.com/docusealco/docuseal/blob/master/LICENSE) for more information.
Unless otherwise noted, all files © 2023 Oleksandr Turchyn.
+1 -14
View File
@@ -7,20 +7,7 @@ module Api
def create
submitter = Submitter.find_by!(slug: params[:submitter_slug])
blob =
if (file = params[:file])
ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename,
content_type: file.content_type)
else
ActiveStorage::Blob.find_signed(params[:blob_signed_id])
end
attachment = ActiveStorage::Attachment.create!(
blob:,
name: params[:name],
record: submitter
)
attachment = Submitters.create_attachment!(submitter, params)
render json: attachment.as_json(only: %i[uuid], methods: %i[url filename content_type])
end
@@ -0,0 +1,75 @@
# frozen_string_literal: true
module Api
class SubmissionsController < ApiBaseController
UnknownFieldName = Class.new(StandardError)
UnknownSubmitterName = Class.new(StandardError)
def create
template = current_account.templates.find(params[:template_id])
submissions =
if (params[:emails] || params[:email]).present?
Submissions.create_from_emails(template:,
user: current_user,
source: :api,
mark_as_sent: params[:send_email] != 'false',
emails: params[:emails] || params[:email])
else
submissions_attrs = normalize_submissions_params!(submissions_params[:submission], template)
Submissions.create_from_submitters(template:,
user: current_user,
source: :api,
mark_as_sent: params[:send_email] != 'false',
submissions_attrs:)
end
submitters = submissions.flat_map(&:submitters)
Submitters.send_signature_requests(submitters, send_email: params[:send_email] != 'false')
render json: submitters
rescue UnknownFieldName, UnknownSubmitterName => e
render json: { error: e.message }, status: :unprocessable_entity
end
private
def submissions_params
params.permit(submission: [{ submitters: [[:uuid, :name, :email, :role, :phone, { values: {} }]] }])
end
def normalize_submissions_params!(submissions_params, template)
submissions_params.each do |submission|
submission[:submitters].each_with_index do |submitter, index|
next if submitter[:values].blank?
submitter[:values] =
normalize_submitter_values(template,
submitter[:values],
submitter[:role] || template.submitters[index]['name'])
end
end
submissions_params
end
def normalize_submitter_values(template, values, submitter_name)
submitter =
template.submitters.find { |e| e['name'] == submitter_name } ||
raise(UnknownSubmitterName, "Unknown submitter: #{submitter_name}")
fields = template.fields.select { |e| e['submitter_uuid'] == submitter['uuid'] }
fields_uuid_index = fields.index_by { |e| e['uuid'] }
fields_name_index = fields.index_by { |e| e['name'] }
values.transform_keys do |key|
next key if fields_uuid_index[key].present?
fields_name_index[key]&.dig('uuid') || raise(UnknownFieldName, "Unknown field: #{key}")
end
end
end
end
+15 -2
View File
@@ -2,9 +2,18 @@
module Api
class TemplatesController < ApiBaseController
def update
@template = current_account.templates.find(params[:id])
before_action :load_template, only: %i[show update]
def index
render json: current_account.templates
end
def show
render json: @template.as_json(include: { author: { only: %i[id email first_name last_name] },
documents: { only: %i[id uuid], methods: %i[url filename] } })
end
def update
@template.update!(template_params)
render :ok
@@ -19,5 +28,9 @@ module Api
fields: [[:uuid, :submitter_uuid, :name, :type, :required,
{ options: [], areas: [%i[x y w h cell_w attachment_uuid page]] }]])
end
def load_template
@template = current_account.templates.find(params[:id])
end
end
end
@@ -7,12 +7,7 @@ module Api
@template = current_account.templates.find(params[:template_id])
documents =
find_or_create_blobs.map do |blob|
document = @template.documents.create!(blob:)
Templates::ProcessDocument.call(document)
end
documents = Templates::CreateAttachments.call(@template, params)
schema = documents.map do |doc|
{ attachment_uuid: doc.uuid, name: doc.filename.base }
@@ -27,19 +22,5 @@ module Api
)
}
end
private
def find_or_create_blobs
blobs = params[:blobs]&.map do |attrs|
ActiveStorage::Blob.find_signed(attrs[:signed_id])
end
blobs || params[:files].map do |file|
ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename,
content_type: file.content_type)
end
end
end
end
@@ -0,0 +1,5 @@
# frozen_string_literal: true
class ApiSettingsController < ApplicationController
def index; end
end
+2 -2
View File
@@ -34,8 +34,8 @@ class ApplicationController < ActionController::Base
redirect_to setup_index_path unless User.exists?
end
def button_title(title: 'Submit', disabled_with: 'Submitting', icon: nil)
render_to_string(partial: 'shared/button_title', locals: { title:, disabled_with:, icon: })
def button_title(title: 'Submit', disabled_with: 'Submitting', icon: nil, icon_disabled: nil)
render_to_string(partial: 'shared/button_title', locals: { title:, disabled_with:, icon:, icon_disabled: })
end
def svg_icon(icon_name, class: '')
@@ -0,0 +1,17 @@
# frozen_string_literal: true
class ConsoleRedirectController < ApplicationController
skip_before_action :authenticate_user!
def index
return redirect_to(new_user_session_path({ redir: params[:redir] }.compact)) if current_user.blank?
auth = JsonWebToken.encode(uuid: current_user.uuid,
scope: :console,
exp: 1.minute.from_now.to_i)
path = Addressable::URI.parse(params[:redir]).path if params[:redir].to_s.starts_with?(Docuseal::CONSOLE_URL)
redirect_to("#{Docuseal::CONSOLE_URL}#{path}?#{{ auth: }.to_query}", allow_other_host: true)
end
end
+8 -2
View File
@@ -6,11 +6,17 @@ class EmailSettingsController < ApplicationController
def index; end
def create
if @encrypted_config.update(storage_configs)
if @encrypted_config.update(email_configs)
SettingsMailer.smtp_successful_setup(@encrypted_config.value['from_email']).deliver_now!
redirect_to settings_email_index_path, notice: 'Changes have been saved'
else
render :index, status: :unprocessable_entity
end
rescue StandardError => e
flash[:alert] = e.message
render :index, status: :unprocessable_entity
end
private
@@ -20,7 +26,7 @@ class EmailSettingsController < ApplicationController
EncryptedConfig.find_or_initialize_by(account: current_account, key: EncryptedConfig::EMAIL_SMTP_KEY)
end
def storage_configs
def email_configs
params.require(:encrypted_config).permit(value: {}).tap do |e|
e[:value].compact_blank!
end
+102 -10
View File
@@ -1,19 +1,111 @@
# frozen_string_literal: true
class EsignSettingsController < ApplicationController
DEFAULT_CERT_NAME = 'DocuSeal Self-Host Autogenerated'
CertFormRecord = Struct.new(:name, :file, :password, keyword_init: true) do
include ActiveModel::Validations
def to_key
[]
end
end
def show
cert_data = EncryptedConfig.find_by(account: current_account,
key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || {}
default_pkcs = GenerateCertificate.load_pkcs(cert_data) if cert_data['cert'].present?
custom_pkcs_list = (cert_data['custom'] || []).map do |e|
{ 'pkcs' => OpenSSL::PKCS12.new(Base64.urlsafe_decode64(e['data']), e['password'].to_s),
'name' => e['name'],
'status' => e['status'] }
end
@pkcs_list = [
if default_pkcs
{
'pkcs' => default_pkcs,
'name' => DEFAULT_CERT_NAME,
'status' => custom_pkcs_list.any? { |e| e['status'] == 'default' } ? 'validate' : 'default'
}
end,
*custom_pkcs_list
].compact.reverse
end
def new
@cert_record = CertFormRecord.new
end
def create
pdfs =
params[:files].map do |file|
HexaPDF::Document.new(io: file.open)
end
@cert_record = CertFormRecord.new(**cert_params)
certs = Accounts.load_signing_certs(current_account)
cert_configs = EncryptedConfig.find_or_initialize_by(account: current_account,
key: EncryptedConfig::ESIGN_CERTS_KEY)
trusted_certs = [certs[:cert], certs[:sub_ca], certs[:root_ca]]
if (cert_configs.value && cert_configs.value['custom']&.any? { |e| e['name'] == @cert_record.name }) ||
@cert_record.name == DEFAULT_CERT_NAME
render turbo_stream: turbo_stream.replace('result', partial: 'result',
locals: { pdfs:, files: params[:files], trusted_certs: })
rescue HexaPDF::MalformedPDFError
render turbo_stream: turbo_stream.replace('result', html: helpers.tag.div('Invalid PDF', id: 'result'))
@cert_record.errors.add(:name, 'already exists')
return render turbo_stream: turbo_stream.replace(:modal, template: 'esign_settings/new'),
status: :unprocessable_entity
end
save_new_cert!(cert_configs, @cert_record)
redirect_to settings_esign_path, notice: 'Certificate has been successfully added!'
rescue OpenSSL::PKCS12::PKCS12Error
@cert_record.errors.add(:password, "is invalid. Make sure you're uploading a valid .p12 file")
render turbo_stream: turbo_stream.replace(:modal, template: 'esign_settings/new'), status: :unprocessable_entity
end
def update
cert_configs = EncryptedConfig.find_by(account: current_account, key: EncryptedConfig::ESIGN_CERTS_KEY)
cert_configs.value['custom'].each { |e| e['status'] = 'validate' }
custom_cert_data = cert_configs.value['custom'].find { |e| e['name'] == params[:name] }
custom_cert_data['status'] = 'default' if custom_cert_data
cert_configs.save!
redirect_to settings_esign_path, notice: 'Default certificate has been selected'
end
def destroy
cert_configs = EncryptedConfig.find_by(account: current_account, key: EncryptedConfig::ESIGN_CERTS_KEY)
cert_configs.value['custom'].reject! { |e| e['name'] == params[:name] }
cert_configs.save!
redirect_to settings_esign_path, notice: 'Certificate has been removed'
end
private
def save_new_cert!(cert_configs, cert_record)
pkcs = OpenSSL::PKCS12.new(cert_record.file.read, cert_record.password)
cert_configs.value ||= {}
cert_configs.value['custom'] ||= []
cert_configs.value['custom'].each { |e| e['status'] = 'validate' }
cert_configs.value['custom'] << {
data: Base64.urlsafe_encode64(pkcs.to_der),
password: cert_record.password,
name: cert_record.name,
status: 'default'
}
cert_configs.save!
end
def cert_params
return {} if params[:esign_settings_controller_cert_form_record].blank?
params.require(:esign_settings_controller_cert_form_record).permit(:name, :file, :password)
end
end
@@ -0,0 +1,20 @@
# frozen_string_literal: true
class PersonalizationSettingsController < ApplicationController
def show; end
def create
account_config =
current_account.account_configs.find_or_initialize_by(key: encrypted_config_params[:key])
account_config.update!(encrypted_config_params)
redirect_back(fallback_location: settings_personalization_path, notice: 'Settings have been saved.')
end
private
def encrypted_config_params
params.require(:account_config).permit!
end
end
@@ -2,6 +2,7 @@
class RegistrationsController < Devise::RegistrationsController
prepend_before_action :require_no_authentication, only: [:show]
prepend_before_action :maybe_redirect_if_signed_in, only: [:show]
def show; end
@@ -13,6 +14,23 @@ class RegistrationsController < Devise::RegistrationsController
private
def after_sign_up_path_for(...)
if params[:redir].present?
return console_redirect_index_path(redir: params[:redir]) if params[:redir].starts_with?(Docuseal::CONSOLE_URL)
return params[:redir]
end
super
end
def maybe_redirect_if_signed_in
return unless signed_in?
return if params[:redir].blank?
redirect_to after_sign_up_path_for(current_user), allow_other_host: true
end
def require_no_authentication
super
+10
View File
@@ -12,6 +12,16 @@ class SessionsController < Devise::SessionsController
private
def after_sign_in_path_for(...)
if params[:redir].present?
return console_redirect_index_path(redir: params[:redir]) if params[:redir].starts_with?(Docuseal::CONSOLE_URL)
return params[:redir]
end
super
end
def require_no_authentication
super
+3 -2
View File
@@ -16,16 +16,17 @@ class SetupController < ApplicationController
def create
@account = Account.new(account_params)
@account.timezone = Accounts.normalize_timezone(@account.timezone)
@user = @account.users.new(user_params)
@encrypted_config = EncryptedConfig.new(encrypted_config_params)
unless URI.parse(encrypted_config_params[:value].to_s).class.in?([URI::HTTP, URI::HTTPS])
@encrypted_config = EncryptedConfig.new(encrypted_config_params)
@encrypted_config.errors.add(:value, 'should be a valid URL')
return render :index, status: :unprocessable_entity
end
return render :index, status: :unprocessable_entity unless @account.valid?
if @user.save
encrypted_configs = [
{ key: EncryptedConfig::APP_URL_KEY, value: encrypted_config_params[:value] },
@@ -0,0 +1,5 @@
# frozen_string_literal: true
class SmsSettingsController < ApplicationController
def index; end
end
+4 -2
View File
@@ -25,7 +25,7 @@ class StartFormController < ApplicationController
ua: request.user_agent
)
@submitter.submission ||= Submission.new(template: @template)
@submitter.submission ||= Submission.new(template: @template, source: :link)
if @submitter.save
redirect_to submit_form_path(@submitter.slug)
@@ -36,7 +36,9 @@ class StartFormController < ApplicationController
end
def completed
@submitter = Submitter.where(submission: @template.submissions).find_by!(email: params[:email])
@submitter = Submitter.where(submission: @template.submissions)
.where.not(completed_at: nil)
.find_by!(email: params[:email])
end
private
+13 -33
View File
@@ -6,7 +6,7 @@ class SubmissionsController < ApplicationController
def show
@submission =
Submission.joins(:template).where(template: { account_id: current_account.id })
.preload(template: { documents_attachments: { preview_images_attachments: :blob } })
.preload(:template, template_schema_documents: [:blob, { preview_images_attachments: :blob }])
.find(params[:id])
render :show, layout: 'plain'
@@ -17,18 +17,22 @@ class SubmissionsController < ApplicationController
def create
submissions =
if params[:emails].present?
create_submissions_from_emails
Submissions.create_from_emails(template: @template,
user: current_user,
source: :invite,
mark_as_sent: params[:send_email] == '1',
emails: params[:emails])
else
create_submissions_from_submitters
Submissions.create_from_submitters(template: @template,
user: current_user,
source: :invite,
mark_as_sent: params[:send_email] == '1',
submissions_attrs: submissions_params[:submission].to_h.values)
end
submitters = submissions.flat_map(&:submitters)
if params[:send_email] == '1'
submitters.each do |submitter|
SubmitterMailer.invitation_email(submitter, message: params[:message]).deliver_later!
end
end
Submitters.send_signature_requests(submitters, params)
redirect_to template_path(@template),
notice: "#{submitters.size} #{'recipient'.pluralize(submitters.size)} added"
@@ -45,32 +49,8 @@ class SubmissionsController < ApplicationController
private
def create_submissions_from_emails
emails = params[:emails].to_s.scan(User::EMAIL_REGEXP)
emails.map do |email|
submission = @template.submissions.new(created_by_user: current_user)
submission.submitters.new(email:, uuid: @template.submitters.first['uuid'],
sent_at: params[:send_email] == '1' ? Time.current : nil)
submission.tap(&:save!)
end
end
def create_submissions_from_submitters
submissions_params[:submission].to_h.map do |_, attrs|
submission = @template.submissions.new(created_by_user: current_user)
attrs[:submitters].each do |submitter_attrs|
submission.submitters.new(**submitter_attrs, sent_at: params[:send_email] == '1' ? Time.current : nil)
end
submission.tap(&:save!)
end
end
def submissions_params
params.permit(submission: { submitters: [%i[uuid email]] })
params.permit(submission: { submitters: [%i[uuid email phone name]] })
end
def load_template
@@ -0,0 +1,28 @@
# frozen_string_literal: true
class SubmissionsExportController < ApplicationController
before_action :load_template
def index
submissions = @template.submissions.active
.preload(submitters: { documents_attachments: :blob,
attachments_attachments: :blob })
.order(id: :asc)
if params[:format] == 'csv'
send_data Submissions::GenerateExportFiles.call(submissions, format: params[:format]),
filename: "#{@template.name}.csv"
elsif params[:format] == 'xlsx'
send_data Submissions::GenerateExportFiles.call(submissions, format: params[:format]),
filename: "#{@template.name}.xlsx"
end
end
def new; end
private
def load_template
@template = current_account.templates.find(params[:template_id])
end
end
+6 -29
View File
@@ -7,7 +7,9 @@ class SubmitFormController < ApplicationController
def show
@submitter =
Submitter.preload(submission: { template: { documents_attachments: { preview_images_attachments: :blob } } })
Submitter.preload(submission: [
:template, { template_schema_documents: [:blob, { preview_images_attachments: :blob }] }
])
.find_by!(slug: params[:slug])
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
@@ -17,40 +19,15 @@ class SubmitFormController < ApplicationController
def update
submitter = Submitter.find_by!(slug: params[:slug])
submitter.values.merge!(normalized_values)
submitter.completed_at = Time.current if params[:completed] == 'true'
submitter.opened_at ||= Time.current
submitter.save!
Submissions.update_template_fields!(submitter.submission) if submitter.submission.template_fields.blank?
submitter.submission.save!
if submitter.completed_at?
GenerateSubmitterResultAttachmentsJob.perform_later(submitter)
submitter.submission.template.account.users.active.each do |user|
SubmitterMailer.completed_email(submitter, user).deliver_later!
end
end
Submitters::SubmitValues.call(submitter, params, request)
head :ok
rescue Submitters::SubmitValues::ValidationError => e
render json: { error: e.message }, status: :unprocessable_entity
end
def completed
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
end
private
def normalized_values
params.fetch(:values, {}).to_unsafe_h.transform_values do |v|
if params[:cast_boolean] == 'true'
v == 'true'
else
v.is_a?(Array) ? v.compact_blank : v
end
end
end
end
@@ -0,0 +1,16 @@
# frozen_string_literal: true
class SubmittersSendEmailController < ApplicationController
def create
submitter = Submitter.joins(:template)
.where(template: { account_id: current_account.id })
.find_by!(slug: params[:submitter_slug])
SubmitterMailer.invitation_email(submitter).deliver_later!
submitter.sent_at ||= Time.current
submitter.save!
redirect_back(fallback_location: submission_path(submitter.submission), notice: 'Email has been sent')
end
end
+1 -1
View File
@@ -17,7 +17,7 @@ class TemplatesController < ApplicationController
end
def edit
@template = current_account.templates.preload(documents_attachments: { preview_images_attachments: :blob })
@template = current_account.templates.preload(schema_documents: { preview_images_attachments: :blob })
.find(params[:id])
render :edit, layout: 'plain'
@@ -0,0 +1,32 @@
# frozen_string_literal: true
class VerifyPdfSignatureController < ApplicationController
def create
pdfs =
params[:files].map do |file|
HexaPDF::Document.new(io: file.open)
end
cert_data = if Docuseal.multitenant?
Docuseal::CERTS
else
EncryptedConfig.find_by(account: current_account, key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || {}
end
default_pkcs = GenerateCertificate.load_pkcs(cert_data)
custom_certs = cert_data.fetch('custom', []).map do |e|
OpenSSL::PKCS12.new(Base64.urlsafe_decode64(e['data']), e['password'].to_s)
end
trusted_certs = [default_pkcs.certificate,
*default_pkcs.ca_certs,
*custom_certs.map(&:certificate),
*custom_certs.flat_map(&:ca_certs).compact]
render turbo_stream: turbo_stream.replace('result', partial: 'result',
locals: { pdfs:, files: params[:files], trusted_certs: })
rescue HexaPDF::MalformedPDFError
render turbo_stream: turbo_stream.replace('result', html: helpers.tag.div('Invalid PDF', id: 'result'))
end
end
@@ -0,0 +1,31 @@
# frozen_string_literal: true
class WebhookSettingsController < ApplicationController
def show
@encrypted_config =
current_account.encrypted_configs.find_or_initialize_by(key: EncryptedConfig::WEBHOOK_URL_KEY)
end
def create
@encrypted_config =
current_account.encrypted_configs.find_or_initialize_by(key: EncryptedConfig::WEBHOOK_URL_KEY)
@encrypted_config.update!(encrypted_config_params)
redirect_back(fallback_location: settings_webhooks_path, notice: 'Webhook URL has been saved.')
end
def update
submitter = current_account.submitters.where.not(completed_at: nil).order(:id).last
SendWebhookRequestJob.perform_later(submitter)
redirect_back(fallback_location: settings_webhooks_path, notice: 'Webhook request has been sent.')
end
private
def encrypted_config_params
params.require(:encrypted_config).permit(:value)
end
end
+25
View File
@@ -45,13 +45,38 @@ window.customElements.define('set-timezone', SetTimezone)
window.customElements.define('autoresize-textarea', AutoresizeTextarea)
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
document.addEventListener('turbo:submit-end', async (event) => {
const resp = event.detail?.formSubmission?.result?.fetchResponse?.response
if (!resp?.headers?.get('content-disposition')?.includes('attachment')) {
return
}
const url = URL.createObjectURL(await resp.blob())
const link = document.createElement('a')
link.href = url
link.setAttribute('download', decodeURIComponent(resp.headers.get('content-disposition').split('"')[1]))
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
})
window.customElements.define('template-builder', class extends HTMLElement {
connectedCallback () {
this.appElem = document.createElement('div')
this.appElem.classList.add('max-h-screen')
this.app = createApp(TemplateBuilder, {
template: reactive(JSON.parse(this.dataset.template)),
backgroundColor: '#faf7f5',
withPhone: this.dataset.withPhone === 'true',
acceptFileTypes: this.dataset.acceptFileTypes,
isDirectUpload: this.dataset.isDirectUpload === 'true'
})
+4
View File
@@ -70,3 +70,7 @@ button[disabled] .enabled {
.base-radio {
@apply radio bg-white radio-sm no-animation;
}
.base-select {
@apply select base-input w-full font-normal;
}
+5 -1
View File
@@ -38,7 +38,11 @@ export default actionable(targetable(class extends HTMLElement {
this.uploadFiles(this.input.files)
}
toggleLoading = () => {
toggleLoading = (e) => {
if (e && e.target && !e.target.contains(this)) {
return
}
this.loading.classList.toggle('hidden')
this.icon.classList.toggle('hidden')
this.classList.toggle('opacity-50')
+1 -1
View File
@@ -1,7 +1,7 @@
export default class extends HTMLElement {
connectedCallback () {
this.querySelectorAll('a').forEach((link) => {
if (document.location.pathname.startsWith(link.pathname)) {
if (document.location.pathname.startsWith(link.pathname) && !link.getAttribute('href').startsWith('http')) {
link.classList.add('bg-base-300')
}
})
+5 -1
View File
@@ -5,8 +5,12 @@ export default actionable(class extends HTMLElement {
document.body.classList.add('overflow-hidden')
document.addEventListener('keyup', this.onEscKey)
document.addEventListener('turbo:submit-end', this.onSubmit)
document.addEventListener('turbo:before-cache', this.close)
if (this.dataset.closeAfterSubmit !== 'false') {
document.addEventListener('turbo:submit-end', this.onSubmit)
}
}
disconnectedCallback () {
+3 -2
View File
@@ -9,12 +9,13 @@ window.customElements.define('submission-form', class extends HTMLElement {
this.appElem = document.createElement('div')
this.app = createApp(Form, {
submitterSlug: this.dataset.submitterSlug,
submitterUuid: this.dataset.submitterUuid,
submitter: JSON.parse(this.dataset.submitter),
authenticityToken: this.dataset.authenticityToken,
canSendEmail: this.dataset.canSendEmail === 'true',
isDirectUpload: this.dataset.isDirectUpload === 'true',
isDemo: this.dataset.isDemo === 'true',
attribution: this.dataset.attribution !== 'false',
withConfetti: true,
values: reactive(JSON.parse(this.dataset.values)),
attachments: reactive(JSON.parse(this.dataset.attachments)),
fields: JSON.parse(this.dataset.fields)
+6 -4
View File
@@ -113,7 +113,7 @@
</template>
<script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3 } from '@tabler/icons-vue'
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck } from '@tabler/icons-vue'
export default {
name: 'FieldArea',
@@ -179,7 +179,8 @@ export default {
select: 'Select',
checkbox: 'Checkbox',
radio: 'Radio',
multiple: 'Multiple Select'
multiple: 'Multiple Select',
phone: 'Phone'
}
},
fieldIcons () {
@@ -193,7 +194,8 @@ export default {
checkbox: IconCheckbox,
radio: IconCircleDot,
cells: IconColumns3,
multiple: IconChecks
multiple: IconChecks,
phone: IconPhoneCheck
}
},
image () {
@@ -212,7 +214,7 @@ export default {
},
formattedDate () {
if (this.field.type === 'date' && this.modelValue) {
return new Intl.DateTimeFormat([], { year: 'numeric', month: 'long', day: 'numeric' }).format(new Date(this.modelValue))
return new Intl.DateTimeFormat([], { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }).format(new Date(this.modelValue))
} else {
return ''
}
+16 -4
View File
@@ -12,7 +12,7 @@
:key="areaIndex"
>
<Teleport
:to="`#page-${area.attachment_uuid}-${area.page}`"
:to="findPageElementForArea(area)"
>
<FieldArea
:ref="setAreaRef"
@@ -78,10 +78,16 @@ export default {
this.areaRefs = []
},
methods: {
findPageElementForArea (area) {
return (this.$root.$el?.parentNode?.getRootNode() || document).getElementById(`page-${area.attachment_uuid}-${area.page}`)
},
scrollIntoField (field) {
this.areaRefs.find((area) => {
if (area.field === field) {
if (document.body.style.overflow === 'hidden') {
const root = this.$root.$el.parentNode.getRootNode()
const container = root.body || root.querySelector('div')
if (container.style.overflow === 'hidden') {
this.scrollInContainer(area.$el)
} else {
area.$refs.scrollToElem.scrollIntoView({ behavior: 'smooth', block: 'start' })
@@ -94,13 +100,19 @@ export default {
})
},
scrollInContainer (target) {
const root = this.$root.$el.parentNode.getRootNode()
const scrollbox = root.getElementById('scrollbox')
const formContainer = root.getElementById('form_container')
const container = root.body || root.querySelector('div')
const padding = 64
const boxRect = window.scrollbox.children[0].getBoundingClientRect()
const boxRect = scrollbox.children[0].getBoundingClientRect()
const targetRect = target.getBoundingClientRect()
const targetTopRelativeToBox = targetRect.top - boxRect.top
window.scrollbox.scrollTop = targetTopRelativeToBox - document.body.offsetHeight + window.form_container.offsetHeight + target.offsetHeight + padding
scrollbox.scrollTop = targetTopRelativeToBox - container.offsetHeight + formContainer.offsetHeight + target.offsetHeight + padding
},
setAreaRef (el) {
if (el) {
+32 -16
View File
@@ -7,13 +7,13 @@
:height="30"
/>
<span>
Form has been completed!
{{ t('form_has_been_completed') }}
</span>
</p>
<div class="space-y-3 mt-5">
<button
v-if="canSendEmail && !isDemo"
class="white-button flex items-center space-x-1 w-full"
class="white-button !h-auto flex items-center space-x-1 w-full"
:disabled="isSendingCopy"
@click.prevent="sendCopyToEmail"
>
@@ -23,7 +23,7 @@
/>
<IconMail v-else />
<span>
Send copy via email
{{ t('send_copy_via_email') }}
</span>
</button>
<button
@@ -37,7 +37,7 @@
/>
<IconDownload v-else />
<span>
Download
{{ t('download') }}
</span>
</button>
<a
@@ -58,17 +58,20 @@
>
<IconLogin />
<span>
Create a Free Account
{{ t('create_a_free_account') }}
</span>
</a>
</div>
<div class="text-center mt-4">
Signed with
<div
v-if="attribution"
class="text-center mt-4"
>
{{ t('signed_with') }}
<a
href="https://www.docuseal.co"
target="_blank"
class="underline"
>DocuSeal</a> - open source documents software
>DocuSeal</a> - {{ t('open_source_documents_software') }}
</div>
</div>
</template>
@@ -86,6 +89,7 @@ export default {
IconLogin,
IconDownload
},
inject: ['baseUrl', 't'],
props: {
submitterSlug: {
type: String,
@@ -96,6 +100,16 @@ export default {
required: false,
default: false
},
attribution: {
type: Boolean,
required: false,
default: true
},
withConfetti: {
type: Boolean,
required: false,
default: false
},
canSendEmail: {
type: Boolean,
required: false,
@@ -109,19 +123,21 @@ export default {
}
},
async mounted () {
const { default: confetti } = await import('canvas-confetti')
if (this.withConfetti) {
const { default: confetti } = await import('canvas-confetti')
confetti({
particleCount: 50,
startVelocity: 30,
spread: 140
})
confetti({
particleCount: 50,
startVelocity: 30,
spread: 140
})
}
},
methods: {
sendCopyToEmail () {
this.isSendingCopy = true
fetch(`/send_submission_email.json?submitter_slug=${this.submitterSlug}`, {
fetch(this.baseUrl + `/send_submission_email.json?submitter_slug=${this.submitterSlug}`, {
method: 'POST'
}).then(() => {
alert('Email has been sent')
@@ -132,7 +148,7 @@ export default {
download () {
this.isDownloading = true
fetch(`/submitters/${this.submitterSlug}/download`).then((response) => response.json()).then((urls) => {
fetch(this.baseUrl + `/submitters/${this.submitterSlug}/download`).then((response) => response.json()).then((urls) => {
const fileRequests = urls.map((url) => {
return () => {
return fetch(url).then(async (resp) => {
+4 -3
View File
@@ -29,7 +29,7 @@
{{ message }}
</div>
<div class="text-xs">
<span class="font-medium">Click to upload</span> or drag and drop files
<span class="font-medium">{{ t('click_to_upload') }}</span> {{ t('or_drag_and_drop_files') }}
</div>
</div>
</div>
@@ -55,6 +55,7 @@ export default {
IconCloudUpload,
IconInnerShadowTop
},
inject: ['baseUrl', 't'],
props: {
message: {
type: String,
@@ -141,7 +142,7 @@ export default {
return await Promise.all(
blobs.map((blob) => {
return fetch('/api/attachments', {
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: JSON.stringify({
name: 'attachments',
@@ -166,7 +167,7 @@ export default {
formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments')
return fetch('/api/attachments', {
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: formData
}).then(resp => resp.json()).then((data) => {
+125 -43
View File
@@ -10,10 +10,10 @@
/>
<button
v-if="!isFormVisible"
class="btn btn-neutral text-white absolute rounded-none border-x-0 md:border md:rounded-full bottom-0 w-full md:mb-4 text-base"
class="btn btn-neutral flex text-white absolute rounded-none border-x-0 md:border md:rounded-full bottom-0 w-full md:mb-4 text-base"
@click.prevent="isFormVisible = true"
>
Submit Form
{{ t('submit_form') }}
<IconArrowsDiagonal
class="absolute right-0 mr-4"
:width="20"
@@ -24,6 +24,7 @@
v-show="isFormVisible"
id="form_container"
class="shadow-md bg-base-100 absolute bottom-0 md:bottom-4 w-full border-base-200 border p-4 rounded"
:style="{ backgroundColor: backgroundColor }"
>
<button
v-if="!isCompleted"
@@ -62,7 +63,7 @@
:for="currentField.uuid"
class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">(optional)</template>
<template v-if="!currentField.required">({{ t('optional') }})</template>
</label>
<div
v-else
@@ -74,7 +75,7 @@
v-model="values[currentField.uuid]"
class="base-input !text-2xl w-full"
:required="currentField.required"
:placeholder="`Type here...${currentField.required ? '' : ' (optional)'}`"
:placeholder="`${t('type_here')}...${currentField.required ? '' : ` (${t('optional')})`}`"
type="text"
:name="`values[${currentField.uuid}]`"
@focus="$refs.areas.scrollIntoField(currentField)"
@@ -87,7 +88,7 @@
:for="currentField.uuid"
class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">(optional)</template>
<template v-if="!currentField.required">({{ t('optional') }})</template>
</label>
<div
v-else
@@ -111,7 +112,7 @@
:for="currentField.uuid"
class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">(optional)</template>
<template v-if="!currentField.required">({{ t('optional') }})</template>
</label>
<div
v-else
@@ -129,7 +130,7 @@
value=""
:selected="!values[currentField.uuid]"
>
Select your option
{{ t('select_your_option') }}
</option>
<option
v-for="(option, index) in currentField.options"
@@ -147,7 +148,7 @@
:for="currentField.uuid"
class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">(optional)</template>
<template v-if="!currentField.required">({{ t('optional') }})</template>
</label>
<div class="flex w-full">
<div class="space-y-3.5 mx-auto">
@@ -194,7 +195,16 @@
class="space-y-3.5 mx-auto"
>
<template v-if="isAnonymousChecboxes">
Complete hightlighted checkboxes and click <span class="font-semibold">{{ stepFields.length === currentStep + 1 ? 'submit' : 'next' }}</span>.
<span class="text-xl">
{{ t('complete_hightlighted_checkboxes_and_click') }} <span class="font-semibold">{{ stepFields.length === currentStep + 1 ? t('submit') : t('next') }}</span>.
</span>
<input
v-for="field in currentStepFields"
:key="field.uuid"
type="hidden"
:name="`values[${field.uuid}]`"
:value="!!values[field.uuid]"
>
</template>
<template v-else>
<div
@@ -255,6 +265,16 @@
:submitter-slug="submitterSlug"
@attached="[attachments.push($event), $refs.areas.scrollIntoField(currentField)]"
/>
<PhoneStep
v-else-if="currentField.type === 'phone'"
ref="currentStep"
v-model="values[currentField.uuid]"
:field="currentField"
:default-value="submitter.phone"
:submitter-slug="submitterSlug"
@focus="$refs.areas.scrollIntoField(currentField)"
@submit="submitStep"
/>
</div>
<div class="mt-6 md:mt-8">
<button
@@ -268,10 +288,10 @@
class="mr-1 animate-spin"
/>
<span v-if="stepFields.length === currentStep + 1">
Submit
{{ t('submit') }}
</span>
<span v-else>
Next
{{ t('next') }}
</span><span
v-if="isSubmitting"
class="w-6 flex justify-start mr-1"
@@ -283,7 +303,9 @@
<FormCompleted
v-else
:is-demo="isDemo"
:can-send-email="canSendEmail"
:attribution="attribution"
:with-confetti="withConfetti"
:can-send-email="canSendEmail && !!submitter.email"
:submitter-slug="submitterSlug"
/>
<div class="flex justify-center">
@@ -308,8 +330,10 @@ import ImageStep from './image_step'
import SignatureStep from './signature_step'
import AttachmentStep from './attachment_step'
import MultiSelectStep from './multi_select_step'
import PhoneStep from './phone_step'
import FormCompleted from './completed'
import { IconInnerShadowTop, IconArrowsDiagonal, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import { t } from './i18n'
export default {
name: 'SubmissionForm',
@@ -321,12 +345,19 @@ export default {
MultiSelectStep,
IconInnerShadowTop,
IconArrowsDiagonal,
PhoneStep,
IconArrowsDiagonalMinimize2,
FormCompleted
},
provide () {
return {
baseUrl: this.baseUrl,
t: this.t
}
},
props: {
submitterSlug: {
type: String,
submitter: {
type: Object,
required: true
},
canSendEmail: {
@@ -334,15 +365,21 @@ export default {
required: false,
default: false
},
submitterUuid: {
type: String,
required: true
},
attachments: {
type: Array,
required: false,
default: () => []
},
withConfetti: {
type: Boolean,
required: false,
default: false
},
baseUrl: {
type: String,
required: false,
default: ''
},
fields: {
type: Array,
required: false,
@@ -350,18 +387,29 @@ export default {
},
authenticityToken: {
type: String,
required: true
required: false,
default: ''
},
backgroundColor: {
type: String,
required: false,
default: ''
},
isDirectUpload: {
type: Boolean,
required: true,
required: false,
default: false
},
isDemo: {
type: Boolean,
required: true,
required: false,
default: false
},
attribution: {
type: Boolean,
required: false,
default: true
},
values: {
type: Object,
required: false,
@@ -373,20 +421,28 @@ export default {
isCompleted: false,
isFormVisible: true,
currentStep: 0,
isSubmitting: false
isSubmitting: false,
recalculateButtonDisabledKey: ''
}
},
computed: {
currentStepFields () {
return this.stepFields[this.currentStep]
},
submitterSlug () {
return this.submitter.slug
},
isAnonymousChecboxes () {
return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name) && this.currentStepFields.length > 4
},
isButtonDisabled () {
return this.isSubmitting ||
if (this.recalculateButtonDisabledKey) {
return this.isSubmitting ||
(this.currentField.required && ['image', 'file'].includes(this.currentField.type) && !this.values[this.currentField.uuid]?.length) ||
(this.currentField.required && this.currentField.type === 'signature' && !this.values[this.currentField.uuid]?.length && this.$refs.currentStep && !this.$refs.currentStep.isSignatureStarted)
} else {
return false
}
},
currentField () {
return this.currentStepFields[0]
@@ -422,17 +478,31 @@ export default {
)
if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
document.body.style.overflow = 'hidden'
this.$nextTick(() => {
const root = this.$root.$el.parentNode.getRootNode()
const scrollbox = root.getElementById('scrollbox')
const parent = root.body || root.querySelector('div')
window.scrollbox.classList.add('h-full', 'overflow-y-auto')
window.scrollbox.parentNode.classList.add('h-screen', 'overflow-y-auto')
parent.style.overflow = 'hidden'
scrollbox.classList.add('h-full', 'overflow-y-auto')
scrollbox.parentNode.classList.add('h-screen', 'overflow-y-auto')
scrollbox.parentNode.style.maxHeight = '-webkit-fill-available'
})
}
this.$nextTick(() => {
this.recalculateButtonDisabledKey = Math.random()
})
},
methods: {
t,
goToStep (step, scrollToArea = false, clickUpload = false) {
this.currentStep = this.stepFields.indexOf(step)
this.$nextTick(() => {
this.recalculateButtonDisabledKey = Math.random()
if (scrollToArea) {
this.$refs.areas.scrollIntoField(step[0])
}
@@ -448,7 +518,7 @@ export default {
if (this.isCompleted) {
return Promise.resolve({})
} else {
return fetch(this.submitPath, {
return fetch(this.baseUrl + this.submitPath, {
method: 'POST',
body: formData || new FormData(this.$refs.form)
})
@@ -457,28 +527,40 @@ export default {
async submitStep () {
this.isSubmitting = true
const stepPromise = this.currentField.type === 'signature'
const stepPromise = ['signature', 'phone'].includes(this.currentField.type)
? this.$refs.currentStep.submit
: () => Promise.resolve({})
await stepPromise()
stepPromise().then(() => {
const formData = new FormData(this.$refs.form)
const formData = new FormData(this.$refs.form)
if (this.currentStep === this.stepFields.length - 1) {
formData.append('completed', 'true')
}
this.saveStep(formData).then(response => {
const nextStep = this.stepFields[this.currentStep + 1]
if (nextStep) {
this.goToStep(this.stepFields[this.currentStep + 1], true)
} else {
this.isCompleted = true
if (this.currentStep === this.stepFields.length - 1) {
formData.append('completed', 'true')
}
this.saveStep(formData).then(async (response) => {
if (response.status === 422) {
const data = await response.json()
alert(data.error || 'Value is invalid')
return Promise.reject(new Error(data.error))
}
const nextStep = this.stepFields[this.currentStep + 1]
if (nextStep) {
this.goToStep(this.stepFields[this.currentStep + 1], true)
} else {
this.isCompleted = true
}
}).catch(error => {
console.error('Error submitting form:', error)
}).finally(() => {
this.isSubmitting = false
})
}).catch(error => {
console.error('Error submitting form:', error)
console.log(error)
}).finally(() => {
this.isSubmitting = false
})
+208
View File
@@ -0,0 +1,208 @@
const en = {
submit_form: 'Submit Form',
type_here: 'Type here',
optional: 'optional',
select_your_option: 'Select your option',
complete_hightlighted_checkboxes_and_click: 'Complete hightlighted checkboxes and click',
submit: 'submit',
next: 'next',
click_to_upload: 'Click to upload',
or_drag_and_drop_files: 'or drag and drop files',
send_copy_via_email: 'Send copy via email',
download: 'Download',
form_has_been_completed: 'Form has been completed!',
create_a_free_account: 'Create a Free Account',
signed_with: 'Signed with',
open_source_documents_software: 'open source documents software',
verified_phone_number: 'Verify Phone Number',
use_international_format: 'Use internatioanl format: +1xxx',
six_digits_code: '6-digit code',
change_phone_number: 'Change phone number',
sending: 'Sending...',
resend_code: 'Re-send code',
verification_code_has_been_resent: 'Verification code has been re-sent via SMS'
}
const es = {
submit_form: 'Enviar Formulario',
type_here: 'Escribe aquí',
optional: 'opcional',
select_your_option: 'Selecciona tu opción',
complete_hightlighted_checkboxes_and_click: 'Completa las casillas resaltadas y haz clic',
submit: 'enviar',
next: 'siguiente',
click_to_upload: 'Haz clic para cargar',
or_drag_and_drop_files: 'o arrastra y suelta archivos',
send_copy_via_email: 'Enviar copia por correo electrónico',
download: 'Descargar',
form_has_been_completed: '¡El formulario ha sido completado!',
create_a_free_account: 'Crear una Cuenta Gratuita',
signed_with: 'Firmado con',
open_source_documents_software: 'software de documentos de código abierto',
verified_phone_number: 'Verificar número de teléfono',
use_international_format: 'Usar formato internacional: +1xxx',
six_digits_code: 'Código de 6 dígitos',
change_phone_number: 'Cambiar número de teléfono',
sending: 'Enviando...',
resend_code: 'Reenviar código',
verification_code_has_been_resent: 'El código de verificación ha sido reenviado por SMS'
}
const it = {
submit_form: 'Invia Modulo',
type_here: 'Digita qui',
optional: 'opzionale',
select_your_option: 'Seleziona la tua opzione',
complete_hightlighted_checkboxes_and_click: 'Completa le caselle evidenziate e fai clic',
submit: 'invia',
next: 'avanti',
click_to_upload: 'Clicca per caricare',
or_drag_and_drop_files: 'oppure trascina e rilascia i file',
send_copy_via_email: 'Invia copia via email',
download: 'Scarica',
form_has_been_completed: 'Il modulo è stato completato!',
create_a_free_account: 'Crea un Account Gratuito',
signed_with: 'Firmato con',
open_source_documents_software: 'software di documenti open source',
verified_phone_number: 'Verifica numero di telefono',
use_international_format: 'Usa formato internazionale: +1xxx',
six_digits_code: 'Codice a 6 cifre',
change_phone_number: 'Cambia numero di telefono',
sending: 'Invio in corso...',
resend_code: 'Rinvia codice',
verification_code_has_been_resent: 'Il codice di verifica è stato rinviato tramite SMS'
}
const de = {
submit_form: 'Formular absenden',
type_here: 'Hier eingeben',
optional: 'optional',
select_your_option: 'Wähle deine Option',
complete_hightlighted_checkboxes_and_click: 'Markierte Kontrollkästchen ausfüllen und klicken',
submit: 'absenden',
next: 'weiter',
click_to_upload: 'Klicken zum Hochladen',
or_drag_and_drop_files: 'oder Dateien hierher ziehen und ablegen',
send_copy_via_email: 'Kopie per E-Mail senden',
download: 'Herunterladen',
form_has_been_completed: 'Formular wurde ausgefüllt!',
create_a_free_account: 'Kostenloses Konto erstellen',
signed_with: 'Unterschrieben mit',
open_source_documents_software: 'Open-Source-Dokumentensoftware',
verified_phone_number: 'Telefonnummer überprüfen',
use_international_format: 'Internationales Format verwenden: +1xxx',
six_digits_code: '6-stelliger Code',
change_phone_number: 'Telefonnummer ändern',
sending: 'Senden...',
resend_code: 'Code erneut senden',
verification_code_has_been_resent: 'Die Verifizierungscode wurde erneut per SMS gesendet'
}
const fr = {
submit_form: 'Envoyer le Formulaire',
type_here: 'Tapez ici',
optional: 'facultatif',
select_your_option: 'Sélectionnez votre option',
complete_hightlighted_checkboxes_and_click: 'Complétez les cases à cocher en surbrillance et cliquez',
submit: 'envoyer',
next: 'suivant',
click_to_upload: 'Cliquez pour télécharger',
or_drag_and_drop_files: 'ou faites glisser-déposer les fichiers',
send_copy_via_email: 'Envoyer une copie par e-mail',
download: 'Télécharger',
form_has_been_completed: 'Le formulaire a été complété !',
create_a_free_account: 'Créer un Compte Gratuit',
signed_with: 'Signé avec',
open_source_documents_software: 'logiciel de documents open source',
verified_phone_number: 'Vérifier le numéro de téléphone',
use_international_format: 'Utiliser le format international : +1xxx',
six_digits_code: 'Code à 6 chiffres',
change_phone_number: 'Changer le numéro de téléphone',
sending: 'Envoi en cours...',
resend_code: 'Renvoyer le code',
verification_code_has_been_resent: 'Le code de vérification a été renvoyé par SMS'
}
const pl = {
submit_form: 'Wyślij Formularz',
type_here: 'Wpisz tutaj',
optional: 'opcjonalny',
select_your_option: 'Wybierz swoją opcję',
complete_hightlighted_checkboxes_and_click: 'Wypełnij zaznaczone pola wyboru i kliknij',
submit: 'wyślij',
next: 'dalej',
click_to_upload: 'Kliknij, aby przesłać',
or_drag_and_drop_files: 'lub przeciągnij i upuść pliki',
send_copy_via_email: 'Wyślij kopię drogą mailową',
download: 'Pobierz',
form_has_been_completed: 'Formularz został wypełniony!',
create_a_free_account: 'Utwórz darmowe konto',
signed_with: 'Podpisane za pomocą',
open_source_documents_software: 'oprogramowanie do dokumentów open source',
verified_phone_number: 'Zweryfikuj numer telefonu',
use_international_format: 'Użyj międzynarodowego formatu: +1xxx',
six_digits_code: '6-cyfrowy kod',
change_phone_number: 'Zmień numer telefonu',
sending: 'Wysyłanie...',
resend_code: 'Ponownie wyślij kod',
verification_code_has_been_resent: 'Kod weryfikacyjny został ponownie wysłany'
}
const uk = {
submit_form: 'Надіслати Форму',
type_here: 'Введіть тут',
optional: 'необов’язково',
select_your_option: 'Виберіть варіант',
complete_hightlighted_checkboxes_and_click: 'Заповніть позначені прапорці та натисніть',
submit: 'надіслати',
next: 'далі',
click_to_upload: 'Клацніть, щоб завантажити',
or_drag_and_drop_files: 'або перетягніть файли сюди',
send_copy_via_email: 'Надіслати копію електронною поштою',
download: 'Завантажити',
form_has_been_completed: 'Форму заповнено!',
create_a_free_account: 'Створити безкоштовний обліковий запис',
signed_with: 'Підписано за допомогою',
open_source_documents_software: 'відкритий програмний засіб для документів',
verified_phone_number: 'Підтвердіть номер телефону',
use_international_format: 'Використовуйте міжнародний формат: +1xxx',
six_digits_code: '6-значний код',
change_phone_number: 'Змінити номер телефону',
sending: 'Надсилаю...',
resend_code: 'Повторно відправити код',
verification_code_has_been_resent: 'Код підтвердження був повторно надісланий'
}
const cs = {
submit_form: 'Odeslat formulář',
type_here: 'Zadejte zde',
optional: 'volitelné',
select_your_option: 'Vyberte svou volbu',
complete_hightlighted_checkboxes_and_click: 'Označte zvýrazněné zaškrtávací políčka a klikněte na',
submit: 'odeslat',
next: 'další',
click_to_upload: 'Klikněte pro nahrání',
or_drag_and_drop_files: 'nebo přetáhněte soubory sem',
send_copy_via_email: 'Odeslat kopii e-mailem',
download: 'Stáhnout',
form_has_been_completed: 'Formulář byl dokončen!',
create_a_free_account: 'Vytvořit bezplatný účet',
signed_with: 'Podepsáno pomocí',
open_source_documents_software: 'open source software pro dokumenty',
verified_phone_number: 'Ověřte telefonní číslo',
use_international_format: 'Použijte mezinárodní formát: +1xxx',
six_digits_code: '6-místný kód',
change_phone_number: 'Změnit telefonní číslo',
sending: 'Odesílání...',
resend_code: 'Znovu odeslat kód',
verification_code_has_been_resent: 'Ověřovací kód byl znovu odeslán'
}
const i18n = { en, es, it, de, fr, pl, uk, cs }
const browserLanguage = (navigator.language || navigator.userLanguage || 'en').split('-')[0]
const t = (key) => i18n[browserLanguage]?.[key] || i18n.en[key] || key
export default i18n
export { t }
@@ -0,0 +1,155 @@
<template>
<div>
<label
:for="isCodeSent ? 'one_time_code' : field.uuid"
class="label text-2xl mb-2"
>{{ field.name || t('verified_phone_number') }}
<template v-if="!field.required">({{ t('optional') }})</template>
</label>
<div>
<input
type="hidden"
name="normalize_phone"
value="true"
>
<div v-if="isCodeSent">
<input
id="one_time_code"
class="base-input !text-2xl w-full text-center"
name="one_time_code"
type="text"
autocomplete="one-time-code"
:placeholder="t('six_digits_code')"
required
maxlength="6"
autofocus
inputmode="decimal"
@input="onInputCode"
>
<div class="flex justify-between mt-2 -mb-2 md:-mb-4">
<a
href="#"
class="link"
@click.prevent="isCodeSent = false"
>
{{ t('change_phone_number') }}
</a>
<a
href="#"
class="link"
@click.prevent="resendCode"
>
{{ isResendLoading ? t('sending') : t('resend_code') }}
</a>
</div>
</div>
<input
v-show="!isCodeSent"
:id="field.uuid"
ref="phone"
:value="modelValue || defaultValue"
class="base-input !text-2xl w-full"
autocomplete="tel"
pattern="^\+[0-9\s\-]+$"
:oninvalid="`this.value ? this.setCustomValidity('${t('use_international_format')}...') : ''`"
oninput="this.setCustomValidity('')"
type="tel"
inputmode="tel"
:required="field.required"
placeholder="+1 234 567-8900"
:name="`values[${field.uuid}]`"
@input="$emit('update:model-value', $event.target.value)"
@focus="$emit('focus')"
>
</div>
</div>
</template>
<script>
function throttle (func, delay) {
let lastCallTime = 0
return function (...args) {
const now = Date.now()
if (now - lastCallTime >= delay) {
func.apply(this, args)
lastCallTime = now
}
}
}
export default {
name: 'PhoneStep',
inject: ['t', 'baseUrl'],
props: {
field: {
type: Object,
required: true
},
submitterSlug: {
type: String,
required: true
},
modelValue: {
type: String,
required: false,
default: ''
},
defaultValue: {
type: String,
required: false,
default: ''
}
},
emits: ['update:model-value', 'focus', 'submit'],
data () {
return {
isCodeSent: false,
isResendLoading: false
}
},
methods: {
emitSubmit: throttle(function (e) {
this.$emit('submit')
}, 1000),
onInputCode (e) {
if (e.target.value.length === 6) {
this.emitSubmit()
}
},
resendCode () {
this.isResendLoading = true
this.sendVerificationCode().finally(() => {
alert(this.t('verification_code_has_been_resent'))
this.isResendLoading = false
})
},
sendVerificationCode () {
return fetch(this.baseUrl + '/api/send_phone_verification_code', {
method: 'POST',
body: JSON.stringify({
submitter_slug: this.submitterSlug,
phone: this.$refs.phone.value
}),
headers: { 'Content-Type': 'application/json' }
})
},
async submit () {
if (!this.isCodeSent) {
this.sendVerificationCode()
this.$emit('update:model-value', this.$refs.phone.value)
this.isCodeSent = true
return Promise.reject(new Error('verify with code'))
} else {
return Promise.resolve({})
}
}
}
}
</script>
@@ -10,6 +10,7 @@
data-tip="Type text"
>
<button
id="type_text_button"
class="btn btn-sm btn-circle"
:class="{ 'btn-neutral': isTextSignature, 'btn-outline': !isTextSignature }"
@click.prevent="toggleTextInput"
@@ -78,6 +79,7 @@
/>
<input
v-if="isTextSignature"
id="signature_text_input"
ref="textInput"
class="base-input !text-2xl w-full mt-6"
:required="field.required"
@@ -99,6 +101,7 @@ export default {
IconTextSize,
IconArrowsDiagonalMinimize2
},
inject: ['baseUrl'],
props: {
field: {
type: Object,
@@ -304,7 +307,7 @@ export default {
file,
'/direct_uploads'
).create((_error, data) => {
fetch('/api/attachments', {
fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: JSON.stringify({
submitter_slug: this.submitterSlug,
@@ -326,12 +329,12 @@ export default {
formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments')
return fetch('/api/attachments', {
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: formData
}).then((resp) => resp.json()).then((attachment) => {
this.$emit('update:model-value', attachment.uuid)
this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid)
return resolve(attachment)
})
+16 -14
View File
@@ -226,14 +226,14 @@ export default {
}
},
startResizeCell (e) {
document.addEventListener('mousemove', this.onResizeCell)
document.addEventListener('mouseup', this.stopResizeCell)
this.$el.getRootNode().addEventListener('mousemove', this.onResizeCell)
this.$el.getRootNode().addEventListener('mouseup', this.stopResizeCell)
this.$emit('start-resize', 'ew')
},
stopResizeCell (e) {
document.removeEventListener('mousemove', this.onResizeCell)
document.removeEventListener('mouseup', this.stopResizeCell)
this.$el.getRootNode().removeEventListener('mousemove', this.onResizeCell)
this.$el.getRootNode().removeEventListener('mouseup', this.stopResizeCell)
this.$emit('stop-resize')
@@ -266,11 +266,13 @@ export default {
})
},
onNameBlur (e) {
const text = this.$refs.name.innerText.trim()
this.isNameFocus = false
this.$refs.name.style.minWidth = ''
if (e.target.innerText.trim()) {
this.field.name = e.target.innerText.trim()
if (text) {
this.field.name = text
} else {
this.field.name = ''
this.$refs.name.innerText = this.defaultName
@@ -302,14 +304,14 @@ export default {
this.dragFrom = { x: e.clientX - rect.left, y: e.clientY - rect.top }
document.addEventListener('mousemove', this.drag)
document.addEventListener('mouseup', this.stopDrag)
this.$el.getRootNode().addEventListener('mousemove', this.drag)
this.$el.getRootNode().addEventListener('mouseup', this.stopDrag)
this.$emit('start-drag')
},
stopDrag () {
document.removeEventListener('mousemove', this.drag)
document.removeEventListener('mouseup', this.stopDrag)
this.$el.getRootNode().removeEventListener('mousemove', this.drag)
this.$el.getRootNode().removeEventListener('mouseup', this.stopDrag)
if (this.isDragged) {
this.save()
@@ -322,14 +324,14 @@ export default {
startResize () {
this.selectedAreaRef.value = this.area
document.addEventListener('mousemove', this.resize)
document.addEventListener('mouseup', this.stopResize)
this.$el.getRootNode().addEventListener('mousemove', this.resize)
this.$el.getRootNode().addEventListener('mouseup', this.stopResize)
this.$emit('start-resize', 'nwse')
},
stopResize () {
document.removeEventListener('mousemove', this.resize)
document.removeEventListener('mouseup', this.stopResize)
this.$el.getRootNode().removeEventListener('mousemove', this.resize)
this.$el.getRootNode().removeEventListener('mouseup', this.stopResize)
this.$emit('stop-resize')
+158 -51
View File
@@ -1,13 +1,17 @@
<template>
<div
style="max-width: 1600px"
class="mx-auto pl-4"
class="mx-auto pl-4 h-full"
>
<div class="flex justify-between py-1.5 items-center pr-4">
<div class="flex space-x-3">
<a href="/">
<a
v-if="withLogoLink"
href="/"
>
<Logo />
</a>
<Logo v-else />
<Contenteditable
:model-value="template.name"
class="text-3xl font-semibold focus:text-clip"
@@ -16,46 +20,53 @@
/>
</div>
<div class="space-x-3 flex items-center">
<a
:href="`/templates/${template.id}/submissions/new`"
data-turbo-frame="modal"
class="btn btn-primary"
>
<IconUsersPlus
width="20"
class="inline"
/>
<span class="hidden md:inline">
Recipients
</span>
</a>
<button
class="base-button"
:class="{ disabled: isSaving }"
v-bind="isSaving ? { disabled: true } : {}"
@click.prevent="onSaveClick"
>
<IconInnerShadowTop
v-if="isSaving"
width="20"
class="animate-spin"
/>
<IconDeviceFloppy
v-else
width="20"
/>
<span class="hidden md:inline">
Save
</span>
</button>
<slot
v-if="$slots.buttons"
name="buttons"
/>
<template v-else>
<a
:href="`/templates/${template.id}/submissions/new`"
data-turbo-frame="modal"
class="btn btn-primary"
>
<IconUsersPlus
width="20"
class="inline"
/>
<span class="hidden md:inline">
Recipients
</span>
</a>
<button
class="base-button"
:class="{ disabled: isSaving }"
v-bind="isSaving ? { disabled: true } : {}"
@click.prevent="onSaveClick"
>
<IconInnerShadowTop
v-if="isSaving"
width="20"
class="animate-spin"
/>
<IconDeviceFloppy
v-else
width="20"
/>
<span class="hidden md:inline">
Save
</span>
</button>
</template>
</div>
</div>
<div
class="flex"
style="max-height: calc(100vh - 60px)"
style="max-height: calc(100% - 60px)"
>
<div
ref="previews"
:style="{ 'display': isBreakpointLg ? 'none' : 'initial' }"
class="overflow-y-auto overflow-x-hidden w-52 flex-none pr-3 mt-0.5 pt-0.5 hidden lg:block"
>
<DocumentPreview
@@ -64,6 +75,7 @@
:with-arrows="template.schema.length > 1"
:item="item"
:document="sortedDocuments[index]"
:accept-file-types="acceptFileTypes"
:template="template"
:is-direct-upload="isDirectUpload"
@scroll-to="scrollIntoDocument(item)"
@@ -73,9 +85,13 @@
@down="moveDocument(item, 1)"
@change="save"
/>
<div class="sticky bottom-0 bg-base-100 py-2">
<div
class="sticky bottom-0 py-2"
:class="{ 'bg-base-100': withStickySubmitters }"
>
<Upload
v-if="sortedDocuments.length"
:accept-file-types="acceptFileTypes"
:template-id="template.id"
:is-direct-upload="isDirectUpload"
@success="updateFromUpload"
@@ -90,23 +106,51 @@
<Dropzone
v-if="!sortedDocuments.length"
:template-id="template.id"
:accept-file-types="acceptFileTypes"
:is-direct-upload="isDirectUpload"
@success="updateFromUpload"
/>
<template v-else>
<Document
<template
v-for="document in sortedDocuments"
:key="document.uuid"
:ref="setDocumentRefs"
:areas-index="fieldAreasIndex[document.uuid]"
:selected-submitter="selectedSubmitter"
:document="document"
:is-drag="!!dragFieldType"
:draw-field="drawField"
@draw="onDraw"
@drop-field="onDropfield"
@remove-area="removeArea"
/>
>
<Document
:ref="setDocumentRefs"
:areas-index="fieldAreasIndex[document.uuid]"
:selected-submitter="selectedSubmitter"
:document="document"
:is-drag="!!dragFieldType"
:draw-field="drawField"
@draw="onDraw"
@drop-field="onDropfield"
@remove-area="removeArea"
/>
<DocumentControls
v-if="isBreakpointLg"
:with-arrows="template.schema.length > 1"
:item="template.schema.find((item) => item.attachment_uuid === document.uuid)"
:document="document"
:template="template"
:is-direct-upload="isDirectUpload"
class="pb-2 mb-2 border-b border-base-300 border-dashed"
@remove="onDocumentRemove"
@replace="onDocumentReplace"
@up="moveDocument(template.schema.find((item) => item.attachment_uuid === document.uuid), -1)"
@down="moveDocument(template.schema.find((item) => item.attachment_uuid === document.uuid), 1)"
@change="save"
/>
</template>
<div
v-if="sortedDocuments.length && isBreakpointLg"
class="pb-4"
>
<Upload
:template-id="template.id"
:is-direct-upload="isDirectUpload"
@success="updateFromUpload"
/>
</div>
</template>
</div>
<div
@@ -122,7 +166,7 @@
</div>
</div>
<div
class="relative w-80 flex-none pt-0.5 pr-4 pl-0.5 hidden md:block"
class="relative w-80 flex-none mt-1 pr-4 pl-0.5 hidden md:block"
:class="drawField ? 'overflow-hidden' : 'overflow-auto'"
>
<div
@@ -149,6 +193,7 @@
:fields="template.fields"
:submitters="template.submitters"
:selected-submitter="selectedSubmitter"
:with-sticky-submitters="withStickySubmitters"
@set-draw="drawField = $event"
@set-drag="dragFieldType = $event"
@change-submitter="selectedSubmitter = $event"
@@ -169,6 +214,7 @@ import Document from './document'
import Logo from './logo'
import Contenteditable from './contenteditable'
import DocumentPreview from './preview'
import DocumentControls from './controls'
import { IconUsersPlus, IconDeviceFloppy, IconInnerShadowTop } from '@tabler/icons-vue'
import { v4 } from 'uuid'
import { ref, computed } from 'vue'
@@ -182,6 +228,7 @@ export default {
Logo,
Dropzone,
DocumentPreview,
DocumentControls,
IconInnerShadowTop,
Contenteditable,
IconUsersPlus,
@@ -191,6 +238,9 @@ export default {
return {
template: this.template,
save: this.save,
baseFetch: this.baseFetch,
backgroundColor: this.backgroundColor,
withPhone: this.withPhone,
selectedAreaRef: computed(() => this.selectedAreaRef)
}
},
@@ -201,13 +251,49 @@ export default {
},
isDirectUpload: {
type: Boolean,
required: true,
required: false,
default: false
},
backgroundColor: {
type: String,
required: false,
default: ''
},
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
},
baseUrl: {
type: String,
required: false,
default: ''
},
withLogoLink: {
type: Boolean,
required: false,
default: true
},
withStickySubmitters: {
type: Boolean,
required: false,
default: true
},
withPhone: {
type: Boolean,
required: false,
default: false
},
fetchOptions: {
type: Object,
required: false,
default: () => ({ headers: {} })
}
},
data () {
return {
documentRefs: [],
isBreakpointLg: false,
isSaving: false,
selectedSubmitter: null,
drawField: null,
@@ -244,15 +330,28 @@ export default {
this.selectedSubmitter = this.template.submitters[0]
},
mounted () {
this.$nextTick(() => {
this.onWindowResize()
})
document.addEventListener('keyup', this.onKeyUp)
window.addEventListener('resize', this.onWindowResize)
},
unmounted () {
document.removeEventListener('keyup', this.onKeyUp)
window.removeEventListener('resize', this.onWindowResize)
},
beforeUpdate () {
this.documentRefs = []
},
methods: {
onWindowResize (e) {
const breakpointLg = 1024
this.isBreakpointLg = this.$el.getRootNode().querySelector('div').offsetWidth < breakpointLg
},
setDocumentRefs (el) {
if (el) {
this.documentRefs.push(el)
@@ -474,10 +573,18 @@ export default {
this.selectedAreaRef.value = area
},
baseFetch (path, options = {}) {
return fetch(this.baseUrl + path, {
...options,
headers: { ...this.fetchOptions.headers, ...options.headers }
})
},
save () {
this.$el.closest('template-builder').dataset.template = JSON.stringify(this.template)
if (this.$el.closest('template-builder')) {
this.$el.closest('template-builder').dataset.template = JSON.stringify(this.template)
}
return fetch(`/api/templates/${this.template.id}`, {
return this.baseFetch(`/api/templates/${this.template.id}`, {
method: 'PUT',
body: JSON.stringify({
template: {
@@ -0,0 +1,91 @@
<template>
<div class="flex space-x-2">
<Contenteditable
class="w-full block mr-6"
:model-value="item.name"
:icon-width="16"
@update:model-value="onUpdateName"
/>
<ReplaceButton
:is-direct-upload="isDirectUpload"
:template-id="template.id"
@click.stop
@success="$emit('replace', { replaceSchemaItem: item, ...$event })"
/>
<button
v-if="withArrows"
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors"
style="width: 24px; height: 24px"
@click.stop="$emit('up', item)"
>
&uarr;
</button>
<button
v-if="withArrows"
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors"
style="width: 24px; height: 24px"
@click.stop="$emit('down', item)"
>
&darr;
</button>
<button
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors"
style="width: 24px; height: 24px"
@click.stop="$emit('remove', item)"
>
&times;
</button>
</div>
</template>
<script>
import Contenteditable from './contenteditable'
import Upload from './upload'
import ReplaceButton from './replace'
export default {
name: 'DocumentControls',
components: {
Contenteditable,
ReplaceButton
},
props: {
item: {
type: Object,
required: true
},
template: {
type: Object,
required: true
},
document: {
type: Object,
required: true
},
isDirectUpload: {
type: Boolean,
required: true,
default: false
},
withArrows: {
type: Boolean,
required: false,
default: true
}
},
emits: ['change', 'remove', 'up', 'down', 'replace'],
mounted () {
if (this.isDirectUpload) {
import('@rails/activestorage')
}
},
methods: {
upload: Upload.methods.upload,
onUpdateName (value) {
this.item.name = value
this.$emit('change')
}
}
}
</script>
+16 -4
View File
@@ -42,7 +42,7 @@
ref="input"
type="file"
name="files[]"
accept="image/*, application/pdf"
:accept="acceptFileTypes"
multiple
@change="upload"
>
@@ -61,11 +61,17 @@ export default {
IconCloudUpload,
IconInnerShadowTop
},
inject: ['baseFetch'],
props: {
templateId: {
type: [Number, String],
required: true
},
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
},
isDirectUpload: {
type: Boolean,
required: true,
@@ -88,8 +94,10 @@ export default {
return 'Uploading...'
} else if (this.isProcessing) {
return 'Processing...'
} else {
} else if (this.acceptFileTypes === 'image/*, application/pdf') {
return 'Add PDF documents or images'
} else {
return 'Add documents or images'
}
}
},
@@ -101,9 +109,13 @@ export default {
methods: {
upload: Upload.methods.upload,
onDropFiles (e) {
this.$refs.input.files = e.dataTransfer.files
if (this.acceptFileTypes !== 'image/*, application/pdf' || [...e.dataTransfer.files].every((f) => f.type.match(/(?:image\/)|(?:application\/pdf)/))) {
this.$refs.input.files = e.dataTransfer.files
this.upload()
this.upload()
} else {
alert('Only PDF and images are supported.')
}
}
}
}
+9 -7
View File
@@ -58,7 +58,7 @@
<label
tabindex="0"
title="Areas"
class="cursor-pointer text-base-100 group-hover:text-base-content"
class="cursor-pointer text-transparent group-hover:text-base-content"
>
<IconShape
:width="18"
@@ -104,7 +104,7 @@
<button
v-else
title="Areas"
class="relative cursor-pointer text-base-100 group-hover:text-base-content"
class="relative cursor-pointer text-transparent group-hover:text-base-content"
@click="$emit('set-draw', field)"
>
<IconShape
@@ -113,7 +113,7 @@
/>
</button>
<button
class="relative text-base-100 group-hover:text-base-content"
class="relative text-transparent group-hover:text-base-content"
title="Remove"
@click="$emit('remove', field)"
>
@@ -122,7 +122,7 @@
:stroke-width="1.6"
/>
</button>
<div class="flex flex-col pr-1 text-base-100 group-hover:text-base-content">
<div class="flex flex-col pr-1 text-transparent group-hover:text-base-content">
<button
title="Up"
class="relative"
@@ -156,7 +156,7 @@
</span>
<input
v-model="field.options[index]"
class="w-full input input-primary input-xs text-sm"
class="w-full input input-primary input-xs text-sm bg-transparent"
type="text"
required
@blur="save"
@@ -254,8 +254,10 @@ export default {
})
},
onNameBlur (e) {
if (e.target.innerText.trim()) {
this.field.name = e.target.innerText.trim()
const text = this.$refs.name.$refs.contenteditable.innerText.trim()
if (text) {
this.field.name = text
} else {
this.field.name = ''
this.$refs.name.$refs.contenteditable.innerText = this.defaultName
@@ -29,8 +29,11 @@
@update:model-value="$emit('name-change', selectedSubmitter)"
/>
</div>
<span>
<span class="flex items-center">
<IconPlus
width="18"
height="18"
/>
</span>
</label>
<ul
@@ -86,7 +89,7 @@
</template>
<script>
import { IconUserPlus, IconTrashX } from '@tabler/icons-vue'
import { IconUserPlus, IconTrashX, IconPlus } from '@tabler/icons-vue'
import Contenteditable from './contenteditable'
import { v4 } from 'uuid'
@@ -95,6 +98,7 @@ export default {
components: {
IconUserPlus,
Contenteditable,
IconPlus,
IconTrashX
},
props: {
+23 -18
View File
@@ -18,33 +18,36 @@
:class="menuClasses"
@click="closeDropdown"
>
<li
<template
v-for="(icon, type) in fieldIcons"
:key="type"
>
<a
href="#"
class="text-sm py-1 px-2"
:class="{ 'active': type === modelValue }"
@click.prevent="$emit('update:model-value', type)"
>
<component
:is="icon"
:stroke-width="1.6"
:width="20"
/>
{{ fieldNames[type] }}
</a>
</li>
<li v-if="withPhone || type !== 'phone'">
<a
href="#"
class="text-sm py-1 px-2"
:class="{ 'active': type === modelValue }"
@click.prevent="$emit('update:model-value', type)"
>
<component
:is="icon"
:stroke-width="1.6"
:width="20"
/>
{{ fieldNames[type] }}
</a>
</li>
</template>
</ul>
</span>
</template>
<script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3 } from '@tabler/icons-vue'
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck } from '@tabler/icons-vue'
export default {
name: 'FiledTypeDropdown',
inject: ['withPhone'],
props: {
modelValue: {
type: String,
@@ -79,7 +82,8 @@ export default {
checkbox: 'Checkbox',
multiple: 'Multiple',
radio: 'Radio',
cells: 'Cells'
cells: 'Cells',
phone: 'Phone'
}
},
fieldIcons () {
@@ -93,7 +97,8 @@ export default {
checkbox: IconCheckbox,
cells: IconColumns3,
multiple: IconChecks,
radio: IconCircleDot
radio: IconCircleDot,
phone: IconPhoneCheck
}
}
},
+81 -42
View File
@@ -1,8 +1,9 @@
<template>
<div class="sticky -top-1 bg-base-100 pt-1 -mt-1 z-10">
<div :class="withStickySubmitters ? 'sticky top-0 z-10' : ''">
<FieldSubmitter
:model-value="selectedSubmitter.uuid"
class="w-full bg-base-100"
class="w-full rounded-lg"
:class="{ 'bg-base-100': withStickySubmitters }"
:submitters="submitters"
@new-submitter="save"
@remove="removeSubmitter"
@@ -24,48 +25,79 @@
/>
</div>
<div class="grid grid-cols-3 gap-1 pb-2">
<button
<template
v-for="(icon, type) in fieldIcons"
:key="type"
draggable="true"
class="flex items-center justify-center border border-dashed border-base-300 bg-base-100 w-full rounded relative"
@dragstart="onDragstart(type)"
@dragend="$emit('drag-end')"
@click="addField(type)"
>
<div class="w-0 absolute left-0">
<svg
xmlns="http://www.w3.org/2000/svg"
class="cursor-grab"
width="18"
height="18"
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"
<button
v-if="withPhone || type != 'phone'"
draggable="true"
class="flex items-center justify-center border border-dashed border-base-300 w-full rounded relative"
:style="{ backgroundColor: backgroundColor }"
@dragstart="onDragstart(type)"
@dragend="$emit('drag-end')"
@click="addField(type)"
>
<div class="w-0 absolute left-0">
<svg
xmlns="http://www.w3.org/2000/svg"
class="cursor-grab"
width="18"
height="18"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
/>
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
</svg>
stroke-linecap="round"
stroke-linejoin="round"
>
<path
stroke="none"
d="M0 0h24v24H0z"
fill="none"
/>
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
</svg>
</div>
<div class="flex items-center flex-col px-2 py-2">
<component :is="icon" />
<span class="text-xs mt-1">
{{ fieldNames[type] }}
</span>
</div>
</button>
<div
v-else
class="tooltip flex"
data-tip="Unlock SMS-verified phone number field with paid plan. Use text field for phone numbers without verification."
>
<a
href="https://www.docuseal.co/pricing"
target="_blank"
class="opacity-50 flex items-center justify-center border border-dashed border-base-300 w-full rounded relative"
:style="{ backgroundColor: backgroundColor }"
>
<div class="w-0 absolute left-0">
<IconLock
width="18"
height="18"
stroke-width="1.5"
/>
</div>
<div class="flex items-center flex-col px-2 py-2">
<component :is="icon" />
<span class="text-xs mt-1">
{{ fieldNames[type] }}
</span>
</div>
</a>
</div>
<div class="flex items-center flex-col px-2 py-2">
<component :is="icon" />
<span class="text-xs mt-1">
{{ fieldNames[type] }}
</span>
</div>
</button>
</template>
</div>
<div
v-if="fields.length < 4"
@@ -76,7 +108,7 @@
Draw a text field on the page with a mouse
</li>
<li>
Single click of the page to add a checkbox
Single click on the page to add a checkbox
</li>
<li>
Drag &amp; drop any other field type on the page
@@ -93,19 +125,26 @@ import Field from './field'
import { v4 } from 'uuid'
import FieldType from './field_type'
import FieldSubmitter from './field_submitter'
import { IconLock } from '@tabler/icons-vue'
export default {
name: 'TemplateFields',
components: {
Field,
FieldSubmitter
FieldSubmitter,
IconLock
},
inject: ['save'],
inject: ['save', 'backgroundColor', 'withPhone'],
props: {
fields: {
type: Array,
required: true
},
withStickySubmitters: {
type: Boolean,
required: false,
default: true
},
submitters: {
type: Array,
required: true
+1 -1
View File
@@ -4,7 +4,7 @@
ref="image"
:src="image.url"
:width="width"
class="shadow-md mb-4"
class="border rounded mb-4"
:height="height"
loading="lazy"
>
@@ -18,6 +18,7 @@
<ReplaceButton
:is-direct-upload="isDirectUpload"
:template-id="template.id"
:accept-file-types="acceptFileTypes"
class="opacity-0 group-hover:opacity-100"
@click.stop
@success="$emit('replace', { replaceSchemaItem: item, ...$event })"
@@ -94,6 +95,11 @@ export default {
type: Object,
required: true
},
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
},
isDirectUpload: {
type: Boolean,
required: true,
+7 -1
View File
@@ -14,7 +14,7 @@
ref="input"
name="files[]"
type="file"
accept="image/*, application/pdf"
:accept="acceptFileTypes"
@change="upload"
>
</form>
@@ -26,11 +26,17 @@ import Upload from './upload'
export default {
name: 'ReplaceDocument',
inject: ['baseFetch'],
props: {
templateId: {
type: [Number, String],
required: true
},
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
},
isDirectUpload: {
type: Boolean,
required: true,
+9 -3
View File
@@ -33,7 +33,7 @@
ref="input"
name="files[]"
type="file"
accept="image/*, application/pdf"
:accept="acceptFileTypes"
multiple
@change="upload"
>
@@ -50,11 +50,17 @@ export default {
IconUpload,
IconInnerShadowTop
},
inject: ['baseFetch'],
props: {
templateId: {
type: [Number, String],
required: true
},
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
},
isDirectUpload: {
type: Boolean,
required: true,
@@ -113,7 +119,7 @@ export default {
this.isProcessing = true
fetch(`/api/templates/${this.templateId}/documents`, {
this.baseFetch(`/api/templates/${this.templateId}/documents`, {
method: 'POST',
body: JSON.stringify({ blobs }),
headers: { 'Content-Type': 'application/json' }
@@ -124,7 +130,7 @@ export default {
this.isProcessing = false
})
} else {
fetch(`/api/templates/${this.templateId}/documents`, {
this.baseFetch(`/api/templates/${this.templateId}/documents`, {
method: 'POST',
body: new FormData(this.$refs.form)
}).then(resp => resp.json()).then((data) => {
+24
View File
@@ -0,0 +1,24 @@
# frozen_string_literal: true
class SendWebhookRequestJob < ApplicationJob
USER_AGENT = 'DocuSeal.co Webhook'
def perform(submitter)
config = submitter.submission.account.encrypted_configs.find_by(key: EncryptedConfig::WEBHOOK_URL_KEY)
return if config.blank? || config.value.blank?
Submissions::EnsureResultGenerated.call(submitter)
ActiveStorage::Current.url_options = Docuseal.default_url_options
Faraday.post(config.value,
{
event_type: 'form.submitted',
timestamp: Time.current.iso8601,
data: Submitters::SerializeForWebhook.call(submitter)
}.to_json,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
end
end
+4
View File
@@ -6,6 +6,10 @@ class ApplicationMailer < ActionMailer::Base
register_interceptor ActionMailerConfigsInterceptor
before_action do
ActiveStorage::Current.url_options = Docuseal.default_url_options
end
def default_url_options
Docuseal.default_url_options
end
+7
View File
@@ -0,0 +1,7 @@
# frozen_string_literal: true
class SettingsMailer < ApplicationMailer
def smtp_successful_setup(email)
mail(to: email, subject: 'SMTP has been configured')
end
end
+47 -6
View File
@@ -3,23 +3,47 @@
class SubmitterMailer < ApplicationMailer
DEFAULT_MESSAGE = %(You have been invited to submit the "%<name>s" form:)
def invitation_email(submitter, message: format(DEFAULT_MESSAGE, name: submitter.submission.template.name))
def invitation_email(submitter, message: '')
@current_account = submitter.submission.template.account
@submitter = submitter
@message = message
@message = message.presence || format(DEFAULT_MESSAGE, name: submitter.submission.template.name)
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY)
subject =
if @email_config
ReplaceEmailVariables.call(@email_config.value['subject'], submitter:)
else
'You have been invited to submit a form'
end
mail(to: @submitter.email,
subject: 'You have been invited to submit a form')
from: from_address_for_submitter(submitter),
subject:,
reply_to: submitter.submission.created_by_user&.friendly_name)
end
def completed_email(submitter, user)
@current_account = submitter.submission.template.account
@submitter = submitter
@user = user
mail(to: user.email,
subject: %(#{submitter.email} has completed the "#{submitter.submission.template.name}" form))
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY)
subject =
if @email_config
ReplaceEmailVariables.call(@email_config.value['subject'], submitter:)
else
%(#{submitter.email} has completed the "#{submitter.submission.template.name}" form)
end
mail(from: from_address_for_submitter(submitter),
to: user.email,
subject:)
end
def documents_copy_email(submitter)
@current_account = submitter.submission.template.account
@submitter = submitter
Submissions::EnsureResultGenerated.call(@submitter)
@@ -30,6 +54,23 @@ class SubmitterMailer < ApplicationMailer
attachments[attachment.filename.to_s] = attachment.download
end
mail(to: submitter.email, subject: 'Your copy of documents')
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY)
subject =
if @email_config
ReplaceEmailVariables.call(@email_config.value['subject'], submitter:)
else
'Your copy of documents'
end
mail(from: from_address_for_submitter(submitter),
to: submitter.email,
subject:)
end
private
def from_address_for_submitter(submitter)
submitter.submission.created_by_user&.friendly_name || submitter.submission.template.author.friendly_name
end
end
+1
View File
@@ -2,6 +2,7 @@
class UserMailer < ApplicationMailer
def invitation_email(user)
@current_account = user.account
@user = user
@token = @user.send(:set_reset_password_token)
+39
View File
@@ -0,0 +1,39 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: access_tokens
#
# id :bigint not null, primary key
# sha256 :text not null
# token :text not null
# created_at :datetime not null
# updated_at :datetime not null
# user_id :bigint not null
#
# Indexes
#
# index_access_tokens_on_sha256 (sha256) UNIQUE
# index_access_tokens_on_user_id (user_id)
#
# Foreign Keys
#
# fk_rails_... (user_id => users.id)
#
class AccessToken < ApplicationRecord
TOKEN_LENGTH = 22
belongs_to :user
before_validation :set_sha256, on: :create
attribute :token, :string, default: -> { SecureRandom.base58(TOKEN_LENGTH) }
encrypts :token
private
def set_sha256
self.sha256 = Digest::SHA256.hexdigest(token)
end
end
+3
View File
@@ -14,7 +14,10 @@
class Account < ApplicationRecord
has_many :users, dependent: :destroy
has_many :encrypted_configs, dependent: :destroy
has_many :account_configs, dependent: :destroy
has_many :templates, dependent: :destroy
has_many :submissions, through: :templates
has_many :submitters, through: :submissions
has_many :active_users, -> { active }, dependent: :destroy,
inverse_of: :account, class_name: 'User'
+60
View File
@@ -0,0 +1,60 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: account_configs
#
# id :bigint not null, primary key
# key :string not null
# value :text not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
# index_account_configs_on_account_id (account_id)
# index_account_configs_on_account_id_and_key (account_id,key) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
#
class AccountConfig < ApplicationRecord
SUBMITTER_INVITATION_EMAIL_KEY = 'submitter_invitation_email'
SUBMITTER_COMPLETED_EMAIL_KEY = 'submitter_completed_email'
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY = 'submitter_documents_copy_email'
DEFAULT_VALUES = {
SUBMITTER_INVITATION_EMAIL_KEY => {
'subject' => 'You have been invited to submit a form',
'body' => "Hi there,\n\n" \
"You have been invited to submit the \"{{template.name}}\" form:\n\n" \
"{{submitter.link}}\n\n" \
"Please contact us by replying to this email if you didn't request this.\n\n" \
"Thanks,\n" \
'{{account.name}}'
},
SUBMITTER_COMPLETED_EMAIL_KEY => {
'subject' => '{{submitter.name}} has completed the "{{template.name}}" form',
'body' => "Hi,\n\n" \
"{{submitter.name}} has completed the \"{{template.name}}\" form:\n\n" \
"{{submission.link}}\n\n" \
"Thanks,\n" \
'{{account.name}}'
},
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => {
'subject' => 'Your copy of documents',
'body' => "Hi there,\n\n" \
"Please check the copy of your \"{{template.name}}\" submission in the email attachments.\n" \
"Alternatively, you can download the copy using:\n\n" \
"{{documents.links}}\n\n" \
"Thanks,\n" \
'{{account.name}}'
}
}.freeze
belongs_to :account
serialize :value, JSON
end
+7 -4
View File
@@ -21,10 +21,13 @@
# fk_rails_... (account_id => accounts.id)
#
class EncryptedConfig < ApplicationRecord
FILES_STORAGE_KEY = 'active_storage'
EMAIL_SMTP_KEY = 'action_mailer_smtp'
ESIGN_CERTS_KEY = 'esign_certs'
APP_URL_KEY = 'app_url'
CONFIG_KEYS = [
FILES_STORAGE_KEY = 'active_storage',
EMAIL_SMTP_KEY = 'action_mailer_smtp',
ESIGN_CERTS_KEY = 'esign_certs',
APP_URL_KEY = 'app_url',
WEBHOOK_URL_KEY = 'webhook_url'
].freeze
belongs_to :account
+15
View File
@@ -6,6 +6,7 @@
#
# id :bigint not null, primary key
# deleted_at :datetime
# source :text not null
# template_fields :text
# template_schema :text
# template_submitters :text
@@ -26,6 +27,7 @@
#
class Submission < ApplicationRecord
belongs_to :template
has_one :account, through: :template
belongs_to :created_by_user, class_name: 'User', optional: true
has_many :submitters, dependent: :destroy
@@ -34,5 +36,18 @@ class Submission < ApplicationRecord
serialize :template_schema, JSON
serialize :template_submitters, JSON
attribute :source, :string, default: 'link'
has_many :template_schema_documents,
->(e) { where(uuid: (e.template_schema.presence || e.template.schema).pluck('attachment_uuid')) },
through: :template, source: :documents_attachments
scope :active, -> { where(deleted_at: nil) }
enum :source, {
invite: 'invite',
api: 'api',
embed: 'embed',
link: 'link'
}, scope: false, prefix: true
end
+5 -1
View File
@@ -6,9 +6,11 @@
#
# id :bigint not null, primary key
# completed_at :datetime
# email :string not null
# email :string
# ip :string
# name :string
# opened_at :datetime
# phone :string
# sent_at :datetime
# slug :string not null
# ua :string
@@ -30,6 +32,8 @@
#
class Submitter < ApplicationRecord
belongs_to :submission
has_one :template, through: :submission
has_one :account, through: :template
attribute :values, :string, default: -> { {} }
attribute :slug, :string, default: -> { SecureRandom.base58(14) }
+2
View File
@@ -10,6 +10,7 @@
# name :string not null
# schema :text not null
# slug :string not null
# source :text not null
# submitters :text not null
# created_at :datetime not null
# updated_at :datetime not null
@@ -37,6 +38,7 @@ class Template < ApplicationRecord
attribute :schema, :string, default: -> { [] }
attribute :submitters, :string, default: -> { [{ name: DEFAULT_SUBMITTER_NAME, uuid: SecureRandom.uuid }] }
attribute :slug, :string, default: -> { SecureRandom.base58(14) }
attribute :source, :string, default: 'native'
serialize :fields, JSON
serialize :schema, JSON
+24 -9
View File
@@ -11,8 +11,8 @@
# email :string not null
# encrypted_password :string not null
# failed_attempts :integer default(0), not null
# first_name :string not null
# last_name :string not null
# first_name :string
# last_name :string
# last_sign_in_at :datetime
# last_sign_in_ip :string
# locked_at :datetime
@@ -22,6 +22,7 @@
# role :string not null
# sign_in_count :integer default(0), not null
# unlock_token :string
# uuid :text not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
@@ -32,25 +33,35 @@
# index_users_on_email (email) UNIQUE
# index_users_on_reset_password_token (reset_password_token) UNIQUE
# index_users_on_unlock_token (unlock_token) UNIQUE
# index_users_on_uuid (uuid) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
#
class User < ApplicationRecord
ROLES = %w[admin].freeze
ROLES = [
ADMIN_ROLE = 'admin'
].freeze
EMAIL_REGEXP =
/[a-z0-9][.']?(?:(?:[a-z0-9_-]++[.'])*[a-z0-9_-]++)*@(?:[a-z0-9]++[.-])*[a-z0-9]++\.[a-z]{2,}/i
EMAIL_REGEXP = /[^@,\s]+@[^@,\s]+/
belongs_to :account
has_one :access_token, dependent: :destroy
has_many :templates, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
devise :database_authenticatable, :recoverable, :rememberable, :validatable, :trackable
devise :registerable, :omniauthable, omniauth_providers: [:google_oauth2] if Docuseal.multitenant?
attribute :role, :string, default: 'admin'
attribute :role, :string, default: ADMIN_ROLE
attribute :uuid, :string, default: -> { SecureRandom.uuid }
scope :active, -> { where(deleted_at: nil) }
scope :admins, -> { where(role: ADMIN_ROLE) }
def access_token
super || build_access_token.tap(&:save!)
end
def active_for_authentication?
!deleted_at?
@@ -61,14 +72,18 @@ class User < ApplicationRecord
end
def initials
[first_name.first, last_name.first].join.upcase
[first_name&.first, last_name&.first].compact_blank.join.upcase
end
def full_name
[first_name, last_name].join(' ')
[first_name, last_name].compact_blank.join(' ')
end
def friendly_name
"#{full_name} <#{email}>"
if full_name.present?
"#{full_name} <#{email}>"
else
email
end
end
end
+2 -2
View File
@@ -11,13 +11,13 @@
<div class="grid md:grid-cols-2 gap-4">
<div class="form-control">
<%= ff.label :timezone, class: 'label' %>
<%= ff.select :timezone, nil, {}, class: 'select base-input w-full font-normal' do %>
<%= ff.select :timezone, nil, {}, class: 'base-select' do %>
<%= time_zone_options_for_select(current_account.timezone) %>
<% end %>
</div>
<div class="form-control">
<%= ff.label :locale, 'Time format', class: 'label' %>
<%= ff.select :locale, options_for_select(controller.class::LOCALE_OPTIONS.invert, current_account.locale), {}, class: 'select base-input w-full font-normal' %>
<%= ff.select :locale, options_for_select(controller.class::LOCALE_OPTIONS.invert, current_account.locale), {}, class: 'base-select' %>
</div>
</div>
<% end %>
+105
View File
@@ -0,0 +1,105 @@
<div class="flex flex-wrap space-y-4 md:flex-nowrap md:space-y-0 md:space-x-10">
<%= render 'shared/settings_nav' %>
<div class="flex-grow">
<h1 class="text-4xl font-bold mb-4">API</h1>
<div class="card bg-base-200">
<div class="card-body p-6">
<label for="api_key" class="text-sm font-semibold">X-Auth-Token</label>
<div class="flex flex-row space-x-4">
<input id="api_key" type="text" value="<%= current_user.access_token.token %>" class="input font-mono input-bordered w-full" autocomplete="off" readonly>
<%= render 'shared/clipboard_copy', icon: 'copy', text: current_user.access_token.token, class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: 'Copy', copied_title: 'Copied' %>
</div>
</div>
</div>
<div class="space-y-4 mt-4">
<div class="collapse collapse-plus bg-base-200 px-1">
<input type="checkbox">
<div class="collapse-title text-xl font-medium">
<div>
Request signature, single submitter
</div>
<div class="mt-1">
<div class="badge badge-warning badge-lg">POST</div>
<div class="badge badge-primary badge-lg"><%= api_submissions_path %></div>
</div>
</div>
<div class="collapse-content" style="display: inherit">
<div class="mockup-code overflow-hidden">
<% text = capture do %>curl --location '<%= api_submissions_url %>' \
--header 'X-Auth-Token: <%= current_user.access_token.token %>' \
--data-raw '{
"template_id": <%= current_account.templates.last&.id || 1 %>,
"emails": "<%= current_user.email.sub('@', '+test@') %>, <%= current_user.email.sub('@', '+test2@') %>"
}'<% end.to_str %>
<span class="top-0 right-0 absolute">
<%= render 'shared/clipboard_copy', icon: 'copy', text:, class: 'btn btn-ghost text-white', icon_class: 'w-6 h-6 text-white', copy_title: 'Copy', copied_title: 'Copied' %>
</span>
<pre data-prefix="$"><code class="overflow-hidden w-full"><%= text %></code></pre>
</div>
</div>
</div>
<div class="collapse collapse-plus bg-base-200 px-1">
<input type="checkbox">
<div class="collapse-title text-xl font-medium">
<div>
Request signature, multiple submitters with default values
</div>
<div class="mt-1">
<div class="badge badge-warning badge-lg">POST</div>
<div class="badge badge-primary badge-lg"><%= api_submissions_path %></div>
</div>
</div>
<div class="collapse-content" style="display: inherit">
<div class="mockup-code overflow-hidden">
<% text = capture do %>curl --location '<%= api_submissions_url %>' \
--header 'X-Auth-Token: <%= current_user.access_token.token %>' \
--data-raw '{
"template_id": <%= current_account.templates.last&.id || 1 %>,
"submission": [
{
"submitters": [
{
"name": "John Doe",
"role": "<%= current_account.templates.last ? current_account.templates.last.submitters.first['name'] : 'First Submitter' %>",
"email": "<%= current_user.email.sub('@', '+test@') %>",
"values": {
"Form Text Field Name": "Default Value"
}
},
{ "name": "Second Submitter", "email": "<%= current_user.email.sub('@', '+test2@') %>" }
]
}
]
}'<% end.to_str %>
<span class="top-0 right-0 absolute">
<%= render 'shared/clipboard_copy', icon: 'copy', text:, class: 'btn btn-ghost text-white', icon_class: 'w-6 h-6 text-white', copy_title: 'Copy', copied_title: 'Copied' %>
</span>
<pre data-prefix="$"><code class="overflow-hidden w-full"><%= text %></code></pre>
</div>
</div>
</div>
<div class="collapse collapse-plus bg-base-200 px-1">
<input type="checkbox">
<div class="collapse-title text-xl font-medium">
<div>
Template details
</div>
<div class="mt-1">
<div class="badge badge-info badge-lg">GET</div>
<div class="badge badge-primary badge-lg"><%= api_template_path(':id') %></div>
</div>
</div>
<div class="collapse-content" style="display: inherit">
<div class="mockup-code overflow-hidden">
<% text = capture do %>curl '<%= api_template_url(current_account.templates&.last || 1) %>' \
--header 'X-Auth-Token: <%= current_user.access_token.token %>'<% end.to_str %>
<span class="top-0 right-0 absolute">
<%= render 'shared/clipboard_copy', icon: 'copy', text:, class: 'btn btn-ghost text-white', icon_class: 'w-6 h-6 text-white', copy_title: 'Copy', copied_title: 'Copied' %>
</span>
<pre data-prefix="$"><code class="overflow-hidden w-full"><%= text %></code></pre>
</div>
</div>
</div>
</div>
</div>
</div>
+1 -1
View File
@@ -17,7 +17,7 @@
<div class="pb-6 pt-1 space-y-1">
<p class="flex items-center space-x-1 text-xs text-base-content/60">
<%= svg_icon('user', class: 'w-4 h-4') %>
<span><%= template.author.full_name %></span>
<span><%= template.author.full_name.presence || template.author.email %></span>
</p>
<p class="flex items-center space-x-1 text-xs text-base-content/60">
<%= svg_icon('calendar', class: 'w-4 h-4') %>
+4 -1
View File
@@ -1,6 +1,9 @@
<div class="max-w-xl mx-auto px-2">
<h1 class="text-3xl font-bold text-center my-8">Profile Details</h1>
<%= form_for('', as: resource_name, html: { class: 'space-y-6' }, url: new_registration_path) do |f| %>
<%= form_for('', as: resource_name, html: { class: 'space-y-6' }, data: { turbo: params[:redir].blank? }, url: new_registration_path) do |f| %>
<% if params[:redir].present? %>
<%= hidden_field_tag :redir, params[:redir] %>
<% end %>
<div class="space-y-2">
<%= render 'devise/shared/error_messages', resource: %>
<%= f.fields_for resource do |ff| %>
+5 -1
View File
@@ -1,6 +1,10 @@
<div class="max-w-xl mx-auto px-2">
<%= render 'devise/shared/select_server' if Docuseal.multitenant? %>
<h1 class="text-3xl font-bold text-center my-8">Create Free Account</h1>
<%= form_for(User.new, html: { class: 'space-y-6' }, url: new_registration_path, method: :get) do |f| %>
<%= form_for(User.new, html: { class: 'space-y-6' }, url: new_registration_path, data: { turbo: params[:redir].blank? }, method: :get) do |f| %>
<% if params[:redir].present? %>
<%= hidden_field_tag :redir, params[:redir] %>
<% end %>
<div class="space-y-2">
<div class="form-control">
<%= f.label :email, class: 'label' %>
+5 -1
View File
@@ -1,6 +1,10 @@
<div class="max-w-xl mx-auto px-2">
<%= render 'devise/shared/select_server' if Docuseal.multitenant? %>
<h1 class="text-4xl font-bold text-center my-8">Log In</h1>
<%= form_for(resource, as: resource_name, html: { class: 'space-y-6' }, 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? %>
<%= hidden_field_tag :redir, params[:redir] %>
<% end %>
<div class="space-y-2">
<div class="form-control">
<%= f.label :email, class: 'label' %>
+1 -1
View File
@@ -3,7 +3,7 @@
<%= link_to 'Log in', new_session_path(resource_name), class: 'link link-hover' %>
<% end %>
<%- if devise_mapping.registerable? && controller_name != 'registrations' %>
<%= link_to 'Create free account', registration_path, class: 'link link-hover' %>
<%= link_to 'Create free account', registration_path({ redir: params[:redir] }.compact_blank), class: 'link link-hover' %>
<% end %>
<%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %>
<%= link_to 'Forgot your password?', new_password_path(resource_name), class: 'link link-hover' %>
@@ -0,0 +1,10 @@
<div class="text-center">
<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' %>">
🌎 Global
</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' %>">
🇪🇺 Europe
</a>
</div>
</div>
+21
View File
@@ -25,6 +25,27 @@
<%= ff.password_field :password, value: value['password'], required: true, class: 'base-input' %>
</div>
</div>
<div class="grid md:grid-cols-2 gap-4">
<div class="form-control">
<%= ff.label :domain, 'Domain (optional)', class: 'label' %>
<%= ff.text_field :domain, value: value['domain'], class: 'base-input' %>
</div>
<div class="form-control">
<%= ff.label :authentication, class: 'label' %>
<%= ff.select :authentication, options_for_select([%w[Plain plain], %w[Login login], %w[CRAM-MD5 cram_md5]], value.fetch('authentication', 'plain')), { prompt: true }, required: true, class: 'base-select' %>
</div>
</div>
<div class="form-control">
<%= ff.label :security_label, 'SMTP Security', class: 'label' %>
<div class="flex items-center space-x-6">
<% [%w[None none], %w[SSL ssl], %w[TLS tls]].each do |(label, val)| %>
<%= ff.label :security, value: val, for: "#{val}_radio", class: 'label' do %>
<%= ff.radio_button :security, val, checked: (value['security'].blank? && val == 'none') || value['security'] == val, id: "#{val}_radio", class: 'base-radio mr-2' %>
<%= label %>
<% end %>
<% end %>
</div>
</div>
<div class="form-control">
<%= ff.label :from_email, 'Send from Email', class: 'label' %>
<%= ff.email_field :from_email, value: value['from_email'], required: true, class: 'base-input' %>
-41
View File
@@ -1,41 +0,0 @@
<div class="flex flex-wrap space-y-4 md:flex-nowrap md:space-y-0">
<%= render 'shared/settings_nav' %>
<div class="flex-grow max-w-xl mx-auto">
<h1 class="text-4xl font-bold mb-4">PDF Signature</h1>
<div id="result">
<p class="mb-2">
Upload signed PDF file to validate its signature:
</p>
</div>
<%= form_for '', url: settings_esign_index_path, method: :post, html: { enctype: 'multipart/form-data' } do |f| %>
<%= f.button type: 'submit', class: 'flex' do %>
<div class="disabled mb-3">
<%= svg_icon('loader', class: 'w-5 h-5 animate-spin inline') %>
Analyzing...
</div>
<% end %>
<file-dropzone data-is-direct-upload="false" data-name="verify_attachments" data-submit-on-upload="true" class="w-full">
<label for="file" class="w-full block h-32 relative bg-base-200 hover:bg-base-200/70 rounded-md border border-base-content border-dashed">
<div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center">
<div class="flex flex-col items-center">
<span data-target="file-dropzone.icon">
<%= svg_icon('cloud_upload', class: 'w-10 h-10') %>
</span>
<span data-target="file-dropzone.loading" class="hidden">
<%= svg_icon('loader', class: 'w-10 h-10 animate-spin') %>
</span>
<div class="font-medium mb-1">
Verify Signed PDF
</div>
<div class="text-xs">
<span class="font-medium">Click to upload</span> or drag and drop files
</div>
</div>
<input id="file" name="files[]" class="hidden" data-action="change:file-dropzone#onSelectFiles" data-target="file-dropzone.input" type="file" accept="application/pdf" multiple>
</div>
</label>
</file-dropzone>
<% end %>
</div>
<div class="w-0 md:w-52"></div>
</div>
+24
View File
@@ -0,0 +1,24 @@
<%= render 'shared/turbo_modal', title: 'Upload Certificate' do %>
<%= form_for @cert_record, url: settings_esign_path, html: { class: 'space-y-4', enctype: 'multipart/form-data' }, data: { turbo_frame: :_top } do |f| %>
<div class="space-y-2">
<div class="form-control">
<%= f.label :name, class: 'label' %>
<%= f.text_field :name, required: true, class: 'base-input' %>
</div>
<div class="form-control">
<%= f.label :file, class: 'label' %>
<%= f.file_field :file, required: true %>
<label class="label">
<span class="label-text-alt">Use a valid .der, .p12 or .pfx file.</span>
</label>
</div>
<div class="form-control">
<%= f.label :password, 'Password (optional)', class: 'label' %>
<%= f.text_field :password, class: 'base-input' %>
</div>
</div>
<div class="form-control pt-2">
<%= f.button button_title, class: 'base-button' %>
</div>
<% end %>
<% end %>
+99
View File
@@ -0,0 +1,99 @@
<div class="flex flex-wrap space-y-4 md:flex-nowrap md:space-y-0 md:space-x-10">
<%= render 'shared/settings_nav' %>
<div class="flex-grow">
<div class="max-w-xl">
<h1 class="text-4xl font-bold mb-4">PDF Signature</h1>
<div id="result">
<p class="mb-2">
Upload signed PDF file to validate its signature:
</p>
</div>
<%= form_for '', url: verify_pdf_signature_index_path, method: :post, html: { enctype: 'multipart/form-data' } do |f| %>
<%= f.button type: 'submit', class: 'flex' do %>
<div class="disabled mb-3">
<%= svg_icon('loader', class: 'w-5 h-5 animate-spin inline') %>
Analyzing...
</div>
<% end %>
<file-dropzone data-is-direct-upload="false" data-name="verify_attachments" data-submit-on-upload="true" class="w-full">
<label for="file" class="w-full block h-32 relative bg-base-200 hover:bg-base-200/70 rounded-md border border-base-content border-dashed">
<div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center">
<div class="flex flex-col items-center">
<span data-target="file-dropzone.icon">
<%= svg_icon('cloud_upload', class: 'w-10 h-10') %>
</span>
<span data-target="file-dropzone.loading" class="hidden">
<%= svg_icon('loader', class: 'w-10 h-10 animate-spin') %>
</span>
<div class="font-medium mb-1">
Verify Signed PDF
</div>
<div class="text-xs">
<span class="font-medium">Click to upload</span> or drag and drop files
</div>
</div>
<input id="file" name="files[]" class="hidden" data-action="change:file-dropzone#onSelectFiles" data-target="file-dropzone.input" type="file" accept="application/pdf" multiple>
</div>
</label>
</file-dropzone>
<% end %>
</div>
<div class="flex justify-between items-end mb-4 mt-8">
<h2 class="text-3xl font-bold">Signing Certificates</h2>
<%= link_to new_settings_esign_path, class: 'btn btn-primary btn-md', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('plus', class: 'w-6 h-6') %>
<span>Upload Cert</span>
<% end %>
</div>
<%= render 'alert' %>
<div class="overflow-x-auto">
<table class="table w-full table-lg rounded-b-none overflow-hidden">
<thead class="bg-base-200">
<tr class="text-neutral uppercase">
<th>
Name
</th>
<th>
Valid To
</th>
<th>
Status
</th>
<th class="text-right" width="1px">
</th>
</tr>
</thead>
<tbody>
<% @pkcs_list.each do |item| %>
<tr scope="row" class="group">
<td>
<%= item['name'] %>
</td>
<td>
<%= l(item['pkcs'].certificate.not_after.to_date, format: :long, locale: current_account.locale) %>
</td>
<td>
<% if item['status'] == 'default' %>
<span class="badge badge-lg badge-info badge-outline">
<%= item['status'] %>
</span>
<% else %>
<%= button_to settings_esign_path, method: :put, params: { name: item['name'] }, class: 'btn btn-outline btn-neutral btn-xs whitespace-nowrap', title: 'Delete', data: { turbo_confirm: 'Are you sure?' } do %>
Make Default
<% end %>
<% end %>
</td>
<td>
<% if item['name'] != EsignSettingsController::DEFAULT_CERT_NAME && item['status'] != 'default' %>
<%= button_to settings_esign_path, params: { name: item['name'] }, method: :delete, class: 'btn btn-outline btn-error btn-xs', title: 'Delete', data: { turbo_confirm: 'Are you sure?' } do %>
Remove
<% end %>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
</div>
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"></path>
<path d="M12 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"></path>
<path d="M12 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"></path>
</svg>

After

Width:  |  Height:  |  Size: 470 B

+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>
<path d="M5 4h4l2 5l-2.5 1.5a11 11 0 0 0 5 5l1.5 -2.5l5 2v4a2 2 0 0 1 -2 2a16 16 0 0 1 -15 -15a2 2 0 0 1 2 -2"></path>
</svg>

After

Width:  |  Height:  |  Size: 409 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="#000000" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M8 9l3 3l-3 3" />
<path d="M13 15l3 0" />
<path d="M3 4m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z" />
</svg>

After

Width:  |  Height:  |  Size: 428 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="#000000" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0" />
<path d="M21 21l-6 -6" />
<path d="M7 10l2 2l4 -4" />
</svg>

After

Width:  |  Height:  |  Size: 394 B

+1 -6
View File
@@ -8,11 +8,6 @@
</head>
<body>
<%= yield %>
<p>
---
</p>
<p>
Sent using <a href="<%= Docuseal::PRODUCT_URL %>"><%= Docuseal::PRODUCT_NAME %></a> documents software.
</p>
<%= render partial: 'shared/mailer_attribution' %>
</body>
</html>
@@ -0,0 +1,28 @@
<div class="collapse collapse-plus bg-base-200">
<input type="checkbox">
<div class="collapse-title text-xl font-medium">
<div>
Documents Copy Email
</div>
</div>
<div class="collapse-content">
<%= form_for AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY), url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
<%= f.hidden_field :key %>
<%= f.fields_for :value, Struct.new(:subject, :body).new(*f.object.value.values_at('subject', 'body')) do |ff| %>
<div class="form-control">
<%= ff.label :subject, class: 'label' %>
<%= ff.text_field :subject, required: true, class: 'base-input' %>
</div>
<div class="form-control">
<%= ff.label :body, class: 'label' %>
<autoresize-textarea>
<%= ff.text_area :body, required: true, class: 'base-input w-full py-2' %>
</autoresize-textarea>
</div>
<% end %>
<div class="form-control pt-2">
<%= f.button button_title(title: 'Save', disabled_with: 'Saving'), class: 'base-button' %>
</div>
<% end %>
</div>
</div>
@@ -0,0 +1 @@
<%= render 'logo_placeholder' %>
@@ -0,0 +1,11 @@
<div class="alert my-4">
<%= svg_icon('info_circle', class: 'w-6 h-6') %>
<div>
<p class="font-bold">Unlock with DocuSeal Pro</p>
<p>
Display your company name and logo when signing documents.
<br>
<a class="link font-medium" target="_blank" href="<%= "#{Docuseal::CONSOLE_URL}/#{Docuseal.multitenant? ? 'plans' : 'on_premise'}" %>">Learn More</a>
</p>
</div>
</div>
@@ -0,0 +1,28 @@
<div class="collapse collapse-plus bg-base-200">
<input type="checkbox">
<div class="collapse-title text-xl font-medium">
<div>
Signature Request Email
</div>
</div>
<div class="collapse-content">
<%= form_for AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY), url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
<%= f.hidden_field :key %>
<%= f.fields_for :value, Struct.new(:subject, :body).new(*f.object.value.values_at('subject', 'body')) do |ff| %>
<div class="form-control">
<%= ff.label :subject, class: 'label' %>
<%= ff.text_field :subject, required: true, class: 'base-input' %>
</div>
<div class="form-control">
<%= ff.label :body, class: 'label' %>
<autoresize-textarea>
<%= ff.text_area :body, required: true, class: 'base-input w-full py-2' %>
</autoresize-textarea>
</div>
<% end %>
<div class="form-control pt-2">
<%= f.button button_title(title: 'Save', disabled_with: 'Saving'), class: 'base-button' %>
</div>
<% end %>
</div>
</div>
@@ -0,0 +1,28 @@
<div class="collapse collapse-plus bg-base-200">
<input type="checkbox">
<div class="collapse-title text-xl font-medium">
<div>
Form Completed Email
</div>
</div>
<div class="collapse-content">
<%= form_for AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY), url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
<%= f.hidden_field :key %>
<%= f.fields_for :value, Struct.new(:subject, :body).new(*f.object.value.values_at('subject', 'body')) do |ff| %>
<div class="form-control">
<%= ff.label :subject, class: 'label' %>
<%= ff.text_field :subject, required: true, class: 'base-input' %>
</div>
<div class="form-control">
<%= ff.label :body, class: 'label' %>
<autoresize-textarea>
<%= ff.text_area :body, required: true, class: 'base-input w-full py-2' %>
</autoresize-textarea>
</div>
<% end %>
<div class="form-control pt-2">
<%= f.button button_title(title: 'Save', disabled_with: 'Saving'), class: 'base-button' %>
</div>
<% end %>
</div>
</div>
@@ -0,0 +1,14 @@
<div class="flex flex-wrap space-y-4 md:flex-nowrap md:space-y-0">
<%= render 'shared/settings_nav' %>
<div class="flex-grow max-w-xl mx-auto">
<p class="text-4xl font-bold mb-4">Email Templates</p>
<div class="space-y-4">
<%= render 'signature_request_email_form' %>
<%= render 'submitter_completed_email_form' %>
<%= render 'documents_copy_email_form' %>
</div>
<p class="text-4xl font-bold mb-4 mt-8">Company Logo</p>
<%= render 'logo_form' %>
</div>
<div class="w-0 md:w-52"></div>
</div>
@@ -2,12 +2,7 @@
<div class="space-y-6 mx-auto">
<div class="space-y-6">
<div class="flex items-center justify-center">
<a href="/" class="flex items-center">
<div class="mr-3">
<%= render 'shared/logo', width: '50px', height: '50px' %>
</div>
<h1 class="text-5xl font-bold text-center">DocuSeal</h1>
</a>
<%= render 'start_form/docuseal_logo' %>
</div>
<div class="text-center text-4xl font-bold">
Email has been sent
@@ -0,0 +1 @@
<p>SMTP has been successfully configured!</p>
+1 -4
View File
@@ -1,4 +1 @@
<div class="text-center px-2">
Powered by
<a href="<%= Docuseal::PRODUCT_URL %>" class="underline"><%= Docuseal::PRODUCT_NAME %></a> - open source documents software
</div>
<%= render 'shared/powered_by' %>
+1 -1
View File
@@ -8,7 +8,7 @@
</span>
<span class="disabled">
<span class="flex items-center justify-center space-x-2">
<%= svg_icon('loader', class: 'w-6 h-6 animate-spin') %>
<%= local_assigns[:icon_disabled] || svg_icon('loader', class: 'w-5 h-5 animate-spin') %>
<span><%= disabled_with %>...</span>
</span>
</span>
+12 -2
View File
@@ -2,16 +2,26 @@
<label class="<%= local_assigns[:class] %>">
<input type="radio" class="peer hidden">
<span class="peer-checked:hidden flex items-center space-x-2">
<%= svg_icon('link', class: local_assigns[:icon_class] || 'w-6 h-6 text-white') %>
<%= svg_icon(local_assigns[:icon] || 'link', class: local_assigns[:icon_class] || 'w-6 h-6 text-white') %>
<span class="hidden md:inline">
<%= local_assigns[:copy_title] || 'Copy' %>
</span>
<% if local_assigns[:copy_title_md] %>
<span class="inline md:hidden">
<%= local_assigns[:copy_title_md] %>
</span>
<% end %>
</span>
<span class="hidden peer-checked:flex items-center space-x-2">
<%= svg_icon('clipboard_copy', class: local_assigns[:icon_class] || 'w-6 h-6 text-white') %>
<%= svg_icon(local_assigns[:copied_icon] || 'clipboard_copy', class: local_assigns[:icon_class] || 'w-6 h-6 text-white') %>
<span class="hidden md:inline">
<%= local_assigns[:copied_title] || 'Copied' %>
</span>
<% if local_assigns[:copied_title_md] %>
<span class="inline md:hidden">
<%= local_assigns[:copied_title_md] %>
</span>
<% end %>
</span>
</label>
</clipboard-copy>
@@ -0,0 +1,6 @@
<p>
---
</p>
<p>
Sent using <a href="<%= Docuseal::PRODUCT_URL %>"><%= Docuseal::PRODUCT_NAME %></a> free document signing.
</p>
+1 -1
View File
@@ -1,4 +1,4 @@
<div id="flash" class="absolute top-0 w-full h-0">
<div id="flash" class="absolute top-0 w-full h-0 z-20">
<div class="max-w-xl mx-auto mt-1.5">
<div class="px-4 py-3 rounded-2xl bg-base-200 flex items-center justify-between mx-4 md:mx-0">
<div class="flex items-center space-x-3">

Some files were not shown because too many files have changed in this diff Show More