Compare commits

..
1 Commits
Author SHA1 Message Date
Alex Turchyn 6fbc81fae2 increase tokens length 2023-07-08 21:05:29 +03:00
215 changed files with 1189 additions and 5481 deletions

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
spec:
name: docuseal
services:
- name: docuseal
dockerfile_path: Dockerfile
git:
repo_clone_url: https://github.com/docusealco/docuseal.git
branch: master
envs:
- key: SECRET_KEY_BASE
value: "- 64 characters string 64 characters string 64 characters string -"
type: SECRET
scope: RUN_TIME
- key: DATABASE_URL
scope: RUN_TIME
value: ${docuseal-db.DATABASE_URL}
databases:
- name: docuseal-db
-2
View File
@@ -1,6 +1,5 @@
/db/*.sqlite3 /db/*.sqlite3
**/node_modules
/node_modules /node_modules
/coverage /coverage
/doc /doc
@@ -28,4 +27,3 @@
/attachments /attachments
/docuseal /docuseal
.DS_Store .DS_Store
.env
-2
View File
@@ -30,5 +30,3 @@ linters:
Enabled: false Enabled: false
Style/ZeroLengthPredicate: Style/ZeroLengthPredicate:
Enabled: false Enabled: false
Style/RedundantConstantBase:
Enabled: false
-1
View File
@@ -127,7 +127,6 @@ jobs:
bundle config path vendor/bundle bundle config path vendor/bundle
bundle install --jobs 4 --retry 4 bundle install --jobs 4 --retry 4
yarn install yarn install
sudo apt-get update
sudo apt-get install libvips sudo apt-get install libvips
- name: Run - name: Run
env: env:
-1
View File
@@ -35,4 +35,3 @@ yarn-debug.log*
/coverage /coverage
/attachments /attachments
/docuseal /docuseal
/ee
+3 -9
View File
@@ -27,7 +27,7 @@ Lint/MissingSuper:
Enabled: false Enabled: false
Metrics/MethodLength: Metrics/MethodLength:
Max: 25 Max: 20
Exclude: Exclude:
- 'db/migrate/**' - 'db/migrate/**'
@@ -53,19 +53,13 @@ RSpec/NestedGroups:
Max: 6 Max: 6
RSpec/MultipleExpectations: RSpec/MultipleExpectations:
Max: 20 Max: 7
RSpec/ExampleLength: RSpec/ExampleLength:
Max: 40 Max: 15
RSpec/MultipleMemoizedHelpers:
Max: 6
Rails/I18nLocaleTexts: Rails/I18nLocaleTexts:
Enabled: false Enabled: false
Rails/SkipsModelValidations:
Enabled: false
Rails/ApplicationController: Rails/ApplicationController:
Enabled: false Enabled: false
+2 -10
View File
@@ -31,21 +31,13 @@ ENV BUNDLE_WITHOUT="development:test"
WORKDIR /app 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 ttf-liberation && cp /usr/share/fonts/liberation/LiberationSans-Regular.ttf /LiberationSans-Regular.ttf && apk del ttf-liberation RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler vips-heif libc6-compat ttf-freefont
COPY ./Gemfile ./Gemfile.lock ./ COPY ./Gemfile ./Gemfile.lock ./
RUN bundle update --bundler && bundle install && rm -rf ~/.bundle RUN bundle update --bundler && bundle install && rm -rf ~/.bundle
COPY ./bin ./bin COPY . ./
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 COPY --from=webpack /app/public/packs ./public/packs
-7
View File
@@ -13,25 +13,18 @@ gem 'faraday'
gem 'google-cloud-storage', require: false gem 'google-cloud-storage', require: false
gem 'hexapdf' gem 'hexapdf'
gem 'image_processing' gem 'image_processing'
gem 'jwt'
gem 'lograge' gem 'lograge'
gem 'mysql2', require: false gem 'mysql2', require: false
gem 'oj' gem 'oj'
gem 'omniauth-google-oauth2'
gem 'omniauth-rails_csrf_protection'
gem 'pagy' gem 'pagy'
gem 'pdf-reader'
gem 'pg', require: false gem 'pg', require: false
gem 'premailer-rails' gem 'premailer-rails'
gem 'puma' gem 'puma'
gem 'rails' gem 'rails'
gem 'rails_autolink'
gem 'rails-i18n' gem 'rails-i18n'
gem 'rollbar', require: ENV.key?('ROLLBAR_ACCESS_TOKEN') gem 'rollbar', require: ENV.key?('ROLLBAR_ACCESS_TOKEN')
gem 'ruby-vips' gem 'ruby-vips'
gem 'rubyXL'
gem 'shakapacker' gem 'shakapacker'
gem 'sidekiq', require: ENV.key?('REDIS_URL')
gem 'sqlite3', require: false gem 'sqlite3', require: false
gem 'strip_attributes' gem 'strip_attributes'
gem 'turbo-rails' gem 'turbo-rails'
+99 -170
View File
@@ -1,94 +1,92 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
Ascii85 (1.1.0) actioncable (7.0.5)
actioncable (7.0.7) actionpack (= 7.0.5)
actionpack (= 7.0.7) activesupport (= 7.0.5)
activesupport (= 7.0.7)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
actionmailbox (7.0.7) actionmailbox (7.0.5)
actionpack (= 7.0.7) actionpack (= 7.0.5)
activejob (= 7.0.7) activejob (= 7.0.5)
activerecord (= 7.0.7) activerecord (= 7.0.5)
activestorage (= 7.0.7) activestorage (= 7.0.5)
activesupport (= 7.0.7) activesupport (= 7.0.5)
mail (>= 2.7.1) mail (>= 2.7.1)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
actionmailer (7.0.7) actionmailer (7.0.5)
actionpack (= 7.0.7) actionpack (= 7.0.5)
actionview (= 7.0.7) actionview (= 7.0.5)
activejob (= 7.0.7) activejob (= 7.0.5)
activesupport (= 7.0.7) activesupport (= 7.0.5)
mail (~> 2.5, >= 2.5.4) mail (~> 2.5, >= 2.5.4)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
actionpack (7.0.7) actionpack (7.0.5)
actionview (= 7.0.7) actionview (= 7.0.5)
activesupport (= 7.0.7) activesupport (= 7.0.5)
rack (~> 2.0, >= 2.2.4) rack (~> 2.0, >= 2.2.4)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (7.0.7) actiontext (7.0.5)
actionpack (= 7.0.7) actionpack (= 7.0.5)
activerecord (= 7.0.7) activerecord (= 7.0.5)
activestorage (= 7.0.7) activestorage (= 7.0.5)
activesupport (= 7.0.7) activesupport (= 7.0.5)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (7.0.7) actionview (7.0.5)
activesupport (= 7.0.7) activesupport (= 7.0.5)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.4) erubi (~> 1.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0)
activejob (7.0.7) activejob (7.0.5)
activesupport (= 7.0.7) activesupport (= 7.0.5)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (7.0.7) activemodel (7.0.5)
activesupport (= 7.0.7) activesupport (= 7.0.5)
activerecord (7.0.7) activerecord (7.0.5)
activemodel (= 7.0.7) activemodel (= 7.0.5)
activesupport (= 7.0.7) activesupport (= 7.0.5)
activestorage (7.0.7) activestorage (7.0.5)
actionpack (= 7.0.7) actionpack (= 7.0.5)
activejob (= 7.0.7) activejob (= 7.0.5)
activerecord (= 7.0.7) activerecord (= 7.0.5)
activesupport (= 7.0.7) activesupport (= 7.0.5)
marcel (~> 1.0) marcel (~> 1.0)
mini_mime (>= 1.1.0) mini_mime (>= 1.1.0)
activesupport (7.0.7) activesupport (7.0.5)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
minitest (>= 5.1) minitest (>= 5.1)
tzinfo (~> 2.0) tzinfo (~> 2.0)
addressable (2.8.5) addressable (2.8.4)
public_suffix (>= 2.0.2, < 6.0) public_suffix (>= 2.0.2, < 6.0)
afm (0.2.2)
annotate (3.2.0) annotate (3.2.0)
activerecord (>= 3.2, < 8.0) activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0) rake (>= 10.4, < 14.0)
ast (2.4.2) ast (2.4.2)
aws-eventstream (1.2.0) aws-eventstream (1.2.0)
aws-partitions (1.807.0) aws-partitions (1.781.0)
aws-sdk-core (3.180.3) aws-sdk-core (3.175.0)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.651.0) aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
jmespath (~> 1, >= 1.6.1) jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.71.0) aws-sdk-kms (1.67.0)
aws-sdk-core (~> 3, >= 3.177.0) aws-sdk-core (~> 3, >= 3.174.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.132.1) aws-sdk-s3 (1.126.0)
aws-sdk-core (~> 3, >= 3.179.0) aws-sdk-core (~> 3, >= 3.174.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.6) aws-sigv4 (~> 1.4)
aws-sigv4 (1.6.0) aws-sigv4 (1.5.2)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
azure-storage-blob (2.0.3) azure-storage-blob (2.0.3)
azure-storage-common (~> 2.0) azure-storage-common (~> 2.0)
@@ -98,9 +96,8 @@ GEM
faraday_middleware (~> 1.0, >= 1.0.0.rc1) faraday_middleware (~> 1.0, >= 1.0.0.rc1)
net-http-persistent (~> 4.0) net-http-persistent (~> 4.0)
nokogiri (~> 1, >= 1.10.8) nokogiri (~> 1, >= 1.10.8)
base64 (0.1.1)
bcrypt (3.1.19) bcrypt (3.1.19)
better_html (2.0.2) better_html (2.0.1)
actionview (>= 6.0) actionview (>= 6.0)
activesupport (>= 6.0) activesupport (>= 6.0)
ast (~> 2.0) ast (~> 2.0)
@@ -108,7 +105,7 @@ GEM
parser (>= 2.4) parser (>= 2.4)
smart_properties smart_properties
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.16.0) bootsnap (1.15.0)
msgpack (~> 1.2) msgpack (~> 1.2)
builder (3.2.4) builder (3.2.4)
bullet (7.0.7) bullet (7.0.7)
@@ -147,7 +144,7 @@ GEM
responders responders
warden (~> 1.2.3) warden (~> 1.2.3)
diff-lcs (1.5.0) diff-lcs (1.5.0)
digest-crc (0.6.5) digest-crc (0.6.4)
rake (>= 12.0.0, < 14.0.0) rake (>= 12.0.0, < 14.0.0)
docile (1.4.0) docile (1.4.0)
dotenv (2.8.1) dotenv (2.8.1)
@@ -164,7 +161,7 @@ GEM
factory_bot_rails (6.2.0) factory_bot_rails (6.2.0)
factory_bot (~> 6.2.0) factory_bot (~> 6.2.0)
railties (>= 5.0.0) railties (>= 5.0.0)
faker (3.2.1) faker (3.2.0)
i18n (>= 1.8.11, < 2) i18n (>= 1.8.11, < 2)
faraday (1.10.3) faraday (1.10.3)
faraday-em_http (~> 1.0) faraday-em_http (~> 1.0)
@@ -197,10 +194,10 @@ GEM
webrick (~> 1.7) webrick (~> 1.7)
websocket-driver (>= 0.6, < 0.8) websocket-driver (>= 0.6, < 0.8)
ffi (1.15.5) ffi (1.15.5)
geom2d (0.4.1) geom2d (0.3.1)
globalid (1.1.0) globalid (1.1.0)
activesupport (>= 5.0) activesupport (>= 5.0)
google-apis-core (0.11.1) google-apis-core (0.11.0)
addressable (~> 2.5, >= 2.5.1) addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a) googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a) httpclient (>= 2.8.1, < 3.a)
@@ -227,7 +224,7 @@ GEM
google-cloud-core (~> 1.6) google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a) googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0) mini_mime (~> 1.0)
googleauth (1.7.0) googleauth (1.6.0)
faraday (>= 0.17.3, < 3.a) faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0) jwt (>= 1.4, < 3.0)
memoist (~> 0.16) memoist (~> 0.16)
@@ -235,11 +232,9 @@ GEM
os (>= 0.9, < 2.0) os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a) signet (>= 0.16, < 2.a)
hashdiff (1.0.1) hashdiff (1.0.1)
hashery (2.1.2) hexapdf (0.32.2)
hashie (5.0.0)
hexapdf (0.33.0)
cmdparse (~> 3.0, >= 3.0.3) cmdparse (~> 3.0, >= 3.0.3)
geom2d (~> 0.4, >= 0.4.1) geom2d (~> 0.3)
openssl (>= 2.2.1) openssl (>= 2.2.1)
htmlentities (4.3.4) htmlentities (4.3.4)
httpclient (2.8.3) httpclient (2.8.3)
@@ -249,8 +244,8 @@ GEM
mini_magick (>= 4.9.5, < 5) mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3) ruby-vips (>= 2.0.17, < 3)
io-console (0.6.0) io-console (0.6.0)
irb (1.7.4) irb (1.7.0)
reline (>= 0.3.6) reline (>= 0.3.0)
jmespath (1.6.2) jmespath (1.6.2)
json (2.6.3) json (2.6.3)
jwt (2.7.1) jwt (2.7.1)
@@ -264,7 +259,7 @@ GEM
letter_opener (~> 1.7) letter_opener (~> 1.7)
railties (>= 5.2) railties (>= 5.2)
rexml rexml
lograge (0.13.0) lograge (0.12.0)
actionpack (>= 4) actionpack (>= 4)
activesupport (>= 4) activesupport (>= 4)
railties (>= 4) railties (>= 4)
@@ -282,17 +277,16 @@ GEM
memoist (0.16.2) memoist (0.16.2)
method_source (1.0.0) method_source (1.0.0)
mini_magick (4.12.0) mini_magick (4.12.0)
mini_mime (1.1.5) mini_mime (1.1.2)
mini_portile2 (2.8.4) mini_portile2 (2.8.2)
minitest (5.19.0) minitest (5.18.1)
msgpack (1.7.2) msgpack (1.7.1)
multi_json (1.15.0) multi_json (1.15.0)
multi_xml (0.6.0)
multipart-post (2.3.0) multipart-post (2.3.0)
mysql2 (0.5.5) mysql2 (0.5.5)
net-http-persistent (4.0.2) net-http-persistent (4.0.2)
connection_pool (~> 2.2) connection_pool (~> 2.2)
net-imap (0.3.7) net-imap (0.3.6)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@@ -302,34 +296,9 @@ GEM
net-smtp (0.3.3) net-smtp (0.3.3)
net-protocol net-protocol
nio4r (2.5.9) nio4r (2.5.9)
nokogiri (1.15.4) nokogiri (1.15.2-arm64-darwin)
mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.15.4-arm64-darwin) oj (3.15.0)
racc (~> 1.4)
oauth2 (2.0.9)
faraday (>= 0.17.3, < 3.0)
jwt (>= 1.0, < 3.0)
multi_xml (~> 0.5)
rack (>= 1.2, < 4)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
oj (3.16.0)
omniauth (2.1.1)
hashie (>= 3.4.6)
rack (>= 2.2.3)
rack-protection
omniauth-google-oauth2 (1.1.1)
jwt (>= 2.0)
oauth2 (~> 2.0.6)
omniauth (~> 2.0)
omniauth-oauth2 (~> 1.8.0)
omniauth-oauth2 (1.8.0)
oauth2 (>= 1.4, < 3)
omniauth (~> 2.0)
omniauth-rails_csrf_protection (1.0.1)
actionpack (>= 4.2)
omniauth (~> 2.0)
openssl (3.1.0) openssl (3.1.0)
orm_adapter (0.5.0) orm_adapter (0.5.0)
os (1.1.4) os (1.1.4)
@@ -338,12 +307,6 @@ GEM
parser (3.2.2.3) parser (3.2.2.3)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
pdf-reader (2.11.0)
Ascii85 (~> 1.0)
afm (~> 0.2.1)
hashery (~> 2.0)
ruby-rc4
ttfunk
pg (1.5.3) pg (1.5.3)
premailer (1.21.0) premailer (1.21.0)
addressable addressable
@@ -358,58 +321,49 @@ GEM
method_source (~> 1.0) method_source (~> 1.0)
pry-rails (0.3.9) pry-rails (0.3.9)
pry (>= 0.10.4) pry (>= 0.10.4)
public_suffix (5.0.3) public_suffix (5.0.1)
puma (6.3.1) puma (6.3.0)
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.7.1) racc (1.7.1)
rack (2.2.8) rack (2.2.7)
rack-protection (3.1.0)
rack (~> 2.2, >= 2.2.4)
rack-proxy (0.7.6) rack-proxy (0.7.6)
rack rack
rack-test (2.1.0) rack-test (2.1.0)
rack (>= 1.3) rack (>= 1.3)
rails (7.0.7) rails (7.0.5)
actioncable (= 7.0.7) actioncable (= 7.0.5)
actionmailbox (= 7.0.7) actionmailbox (= 7.0.5)
actionmailer (= 7.0.7) actionmailer (= 7.0.5)
actionpack (= 7.0.7) actionpack (= 7.0.5)
actiontext (= 7.0.7) actiontext (= 7.0.5)
actionview (= 7.0.7) actionview (= 7.0.5)
activejob (= 7.0.7) activejob (= 7.0.5)
activemodel (= 7.0.7) activemodel (= 7.0.5)
activerecord (= 7.0.7) activerecord (= 7.0.5)
activestorage (= 7.0.7) activestorage (= 7.0.5)
activesupport (= 7.0.7) activesupport (= 7.0.5)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 7.0.7) railties (= 7.0.5)
rails-dom-testing (2.2.0) rails-dom-testing (2.0.3)
activesupport (>= 5.0.0) activesupport (>= 4.2.0)
minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.6.0) rails-html-sanitizer (1.6.0)
loofah (~> 2.21) loofah (~> 2.21)
nokogiri (~> 1.14) nokogiri (~> 1.14)
rails-i18n (7.0.8) rails-i18n (7.0.7)
i18n (>= 0.7, < 2) i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8) railties (>= 6.0.0, < 8)
rails_autolink (1.1.8) railties (7.0.5)
actionview (> 3.1) actionpack (= 7.0.5)
activesupport (> 3.1) activesupport (= 7.0.5)
railties (> 3.1)
railties (7.0.7)
actionpack (= 7.0.7)
activesupport (= 7.0.7)
method_source method_source
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0) thor (~> 1.0)
zeitwerk (~> 2.5) zeitwerk (~> 2.5)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.0.6) rake (13.0.6)
redis-client (0.16.0)
connection_pool
regexp_parser (2.8.1) regexp_parser (2.8.1)
reline (0.3.8) reline (0.3.5)
io-console (~> 0.5) io-console (~> 0.5)
representable (3.2.0) representable (3.2.0)
declarative (< 0.1.0) declarative (< 0.1.0)
@@ -421,14 +375,14 @@ GEM
actionpack (>= 5.2) actionpack (>= 5.2)
railties (>= 5.2) railties (>= 5.2)
retriable (3.1.2) retriable (3.1.2)
rexml (3.2.6) rexml (3.2.5)
rollbar (3.4.0) rollbar (3.4.0)
rspec-core (3.12.2) rspec-core (3.12.2)
rspec-support (~> 3.12.0) rspec-support (~> 3.12.0)
rspec-expectations (3.12.3) rspec-expectations (3.12.3)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0) rspec-support (~> 3.12.0)
rspec-mocks (3.12.6) rspec-mocks (3.12.5)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0) rspec-support (~> 3.12.0)
rspec-rails (6.0.3) rspec-rails (6.0.3)
@@ -439,9 +393,8 @@ GEM
rspec-expectations (~> 3.12) rspec-expectations (~> 3.12)
rspec-mocks (~> 3.12) rspec-mocks (~> 3.12)
rspec-support (~> 3.12) rspec-support (~> 3.12)
rspec-support (3.12.1) rspec-support (3.12.0)
rubocop (1.56.1) rubocop (1.53.0)
base64 (~> 0.1.1)
json (~> 2.3) json (~> 2.3)
language_server-protocol (>= 3.17.0) language_server-protocol (>= 3.17.0)
parallel (~> 1.10) parallel (~> 1.10)
@@ -449,7 +402,7 @@ GEM
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0) regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0) rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.28.1, < 2.0) rubocop-ast (>= 1.28.0, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0) unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.29.0) rubocop-ast (1.29.0)
@@ -458,37 +411,27 @@ GEM
rubocop (~> 1.41) rubocop (~> 1.41)
rubocop-factory_bot (2.23.1) rubocop-factory_bot (2.23.1)
rubocop (~> 1.33) rubocop (~> 1.33)
rubocop-performance (1.19.0) rubocop-performance (1.18.0)
rubocop (>= 1.7.0, < 2.0) rubocop (>= 1.7.0, < 2.0)
rubocop-ast (>= 0.4.0) rubocop-ast (>= 0.4.0)
rubocop-rails (2.20.2) rubocop-rails (2.20.2)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0) rubocop (>= 1.33.0, < 2.0)
rubocop-rspec (2.23.2) rubocop-rspec (2.22.0)
rubocop (~> 1.33) rubocop (~> 1.33)
rubocop-capybara (~> 2.17) rubocop-capybara (~> 2.17)
rubocop-factory_bot (~> 2.22) rubocop-factory_bot (~> 2.22)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
ruby-rc4 (0.1.5)
ruby-vips (2.1.4) ruby-vips (2.1.4)
ffi (~> 1.12) ffi (~> 1.12)
ruby2_keywords (0.0.5) 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) semantic_range (3.0.0)
shakapacker (7.0.3) shakapacker (7.0.0)
activesupport (>= 5.2) activesupport (>= 5.2)
rack-proxy (>= 0.6.1) rack-proxy (>= 0.6.1)
railties (>= 5.2) railties (>= 5.2)
semantic_range (>= 2.3.0) semantic_range (>= 2.3.0)
sidekiq (7.1.2)
concurrent-ruby (< 2)
connection_pool (>= 2.3.0)
rack (>= 2.2.4)
redis-client (>= 0.14.0)
signet (0.17.0) signet (0.17.0)
addressable (~> 2.8) addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a) faraday (>= 0.17.5, < 3.a)
@@ -501,18 +444,13 @@ GEM
simplecov-html (0.12.3) simplecov-html (0.12.3)
simplecov_json_formatter (0.1.4) simplecov_json_formatter (0.1.4)
smart_properties (1.17.0) smart_properties (1.17.0)
snaky_hash (2.0.1) sqlite3 (1.5.4)
hashie
version_gem (~> 1.1, >= 1.1.1)
sqlite3 (1.6.3)
mini_portile2 (~> 2.8.0) mini_portile2 (~> 2.8.0)
sqlite3 (1.6.3-arm64-darwin)
strip_attributes (1.13.0) strip_attributes (1.13.0)
activemodel (>= 3.0, < 8.0) activemodel (>= 3.0, < 8.0)
thor (1.2.2) thor (1.2.2)
timeout (0.4.0) timeout (0.4.0)
trailblazer-option (0.1.2) trailblazer-option (0.1.2)
ttfunk (1.7.0)
turbo-rails (1.4.0) turbo-rails (1.4.0)
actionpack (>= 6.0.0) actionpack (>= 6.0.0)
activejob (>= 6.0.0) activejob (>= 6.0.0)
@@ -524,7 +462,6 @@ GEM
uber (0.1.0) uber (0.1.0)
unicode-display_width (2.4.2) unicode-display_width (2.4.2)
uniform_notifier (1.16.0) uniform_notifier (1.16.0)
version_gem (1.1.3)
warden (1.2.9) warden (1.2.9)
rack (>= 2.0.9) rack (>= 2.0.9)
web-console (4.2.0) web-console (4.2.0)
@@ -537,16 +474,15 @@ GEM
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.8.1) webrick (1.8.1)
websocket-driver (0.7.6) websocket-driver (0.7.5)
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
zeitwerk (2.6.11) zeitwerk (2.6.8)
PLATFORMS PLATFORMS
arm64-darwin-22 arm64-darwin-22
ruby
DEPENDENCIES DEPENDENCIES
annotate annotate
@@ -567,22 +503,17 @@ DEPENDENCIES
google-cloud-storage google-cloud-storage
hexapdf hexapdf
image_processing image_processing
jwt
letter_opener_web letter_opener_web
lograge lograge
mysql2 mysql2
oj oj
omniauth-google-oauth2
omniauth-rails_csrf_protection
pagy pagy
pdf-reader
pg pg
premailer-rails premailer-rails
pry-rails pry-rails
puma puma
rails rails
rails-i18n rails-i18n
rails_autolink
rollbar rollbar
rspec-rails rspec-rails
rubocop rubocop
@@ -590,9 +521,7 @@ DEPENDENCIES
rubocop-rails rubocop-rails
rubocop-rspec rubocop-rspec
ruby-vips ruby-vips
rubyXL
shakapacker shakapacker
sidekiq
simplecov simplecov
sqlite3 sqlite3
strip_attributes strip_attributes
+6 -19
View File
@@ -14,7 +14,7 @@
<a href="https://hub.docker.com/r/docuseal/docuseal"> <a href="https://hub.docker.com/r/docuseal/docuseal">
<img alt="Docker releases" src="https://img.shields.io/docker/v/docuseal/docuseal"> <img alt="Docker releases" src="https://img.shields.io/docker/v/docuseal/docuseal">
</a> </a>
<a href="https://discord.gg/qygYCDGck9"> <a href="https://discord.com/invite/B5wg5wKk">
<img src="https://img.shields.io/discord/1125112641170448454?logo=discord"/> <img src="https://img.shields.io/discord/1125112641170448454?logo=discord"/>
</a> </a>
<a href="https://twitter.com/intent/follow?screen_name=docusealco"> <a href="https://twitter.com/intent/follow?screen_name=docusealco">
@@ -26,8 +26,6 @@ DocuSeal is an open source platform that provides secure and efficient digital d
</p> </p>
<h2 align="center"> <h2 align="center">
<a href="https://demo.docuseal.co">✨ Live Demo</a> <a href="https://demo.docuseal.co">✨ Live Demo</a>
<span>|</span>
<a href="https://docuseal.co/sign_up">☁️ Try in Cloud</a>
</h2> </h2>
[![Demo](https://github.com/docusealco/docuseal/assets/5418788/d8703ea3-361a-423f-8bfe-eff1bd9dbe14)](https://demo.docuseal.co) [![Demo](https://github.com/docusealco/docuseal/assets/5418788/d8703ea3-361a-423f-8bfe-eff1bd9dbe14)](https://demo.docuseal.co)
@@ -42,16 +40,13 @@ DocuSeal is an open source platform that provides secure and efficient digital d
- [x] PDF signature verification - [x] PDF signature verification
- [x] Users management - [x] Users management
- [x] Mobile-optimized - [x] Mobile-optimized
- [x] API and Webhooks for integrations
- [x] Easy to deploy in minutes - [x] Easy to deploy in minutes
## Deploy ## Deploy
|Heroku|Railway| | Heroku | Railway | DigitalOcean |
|:--:|:---:| | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.app/template/IGoDnc?referralCode=ruU7JR)| | [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.app/template/IGoDnc?referralCode=ruU7JR) | [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal/tree/master&refcode=421d50f53990) |
|**DigitalOcean**|**Render**|
| [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [<img alt="Deploy to Render" src="https://render.com/images/deploy-to-render-button.svg" height="40">](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render)
#### Docker #### Docker
@@ -69,19 +64,11 @@ Download docker-compose.yml into your private server:
curl https://raw.githubusercontent.com/docusealco/docuseal/master/docker-compose.yml > docker-compose.yml curl https://raw.githubusercontent.com/docusealco/docuseal/master/docker-compose.yml > docker-compose.yml
``` ```
Run the app under a custom domain over https using docker compose (make sure your DNS points to the server to automatically issue ssl certs with Caddy): Run docker compose under a custom domain over https (make sure your DNS points to the server to automatically issue ssl certs with Caddy):
```sh ```sh
HOST=your-domain-name.com docker-compose up 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, 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 ## License
Distributed under the AGPLv3 License. See [LICENSE](https://github.com/docusealco/docuseal/blob/master/LICENSE) for more information. DocuSeal is released under the GNU Affero General Public License v3.0.
Unless otherwise noted, all files © 2023 Oleksandr Turchyn.
-2
View File
@@ -40,8 +40,6 @@ class AccountsController < ApplicationController
end end
def app_url_params def app_url_params
return {} if params[:encrypted_config].blank?
params.require(:encrypted_config).permit(:value) params.require(:encrypted_config).permit(:value)
end end
end end
+14 -1
View File
@@ -7,7 +7,20 @@ module Api
def create def create
submitter = Submitter.find_by!(slug: params[:submitter_slug]) submitter = Submitter.find_by!(slug: params[:submitter_slug])
attachment = Submitters.create_attachment!(submitter, params) 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
)
render json: attachment.as_json(only: %i[uuid], methods: %i[url filename content_type]) render json: attachment.as_json(only: %i[uuid], methods: %i[url filename content_type])
end end
@@ -1,75 +0,0 @@
# 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
+2 -15
View File
@@ -2,18 +2,9 @@
module Api module Api
class TemplatesController < ApiBaseController class TemplatesController < ApiBaseController
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 def update
@template = current_account.templates.find(params[:id])
@template.update!(template_params) @template.update!(template_params)
render :ok render :ok
@@ -28,9 +19,5 @@ module Api
fields: [[:uuid, :submitter_uuid, :name, :type, :required, fields: [[:uuid, :submitter_uuid, :name, :type, :required,
{ options: [], areas: [%i[x y w h cell_w attachment_uuid page]] }]]) { options: [], areas: [%i[x y w h cell_w attachment_uuid page]] }]])
end end
def load_template
@template = current_account.templates.find(params[:id])
end
end end
end end
@@ -3,11 +3,14 @@
module Api module Api
class TemplatesDocumentsController < ApiBaseController class TemplatesDocumentsController < ApiBaseController
def create def create
return head :unprocessable_entity if params[:blobs].blank? && params[:files].blank?
@template = current_account.templates.find(params[:template_id]) @template = current_account.templates.find(params[:template_id])
documents = Templates::CreateAttachments.call(@template, params) documents =
find_or_create_blobs.map do |blob|
document = @template.documents.create!(blob:)
Templates::ProcessDocument.call(document)
end
schema = documents.map do |doc| schema = documents.map do |doc|
{ attachment_uuid: doc.uuid, name: doc.filename.base } { attachment_uuid: doc.uuid, name: doc.filename.base }
@@ -22,5 +25,19 @@ module Api
) )
} }
end 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
end end
@@ -1,5 +0,0 @@
# frozen_string_literal: true
class ApiSettingsController < ApplicationController
def index; end
end
+3 -3
View File
@@ -23,7 +23,7 @@ class ApplicationController < ActionController::Base
private private
def sign_in_for_demo def sign_in_for_demo
sign_in(User.order('random()').take) unless signed_in? sign_in(User.first) unless signed_in?
end end
def current_account def current_account
@@ -34,8 +34,8 @@ class ApplicationController < ActionController::Base
redirect_to setup_index_path unless User.exists? redirect_to setup_index_path unless User.exists?
end end
def button_title(title: 'Submit', disabled_with: 'Submitting', icon: nil, icon_disabled: nil) def button_title(title: 'Submit', disabled_with: 'Submitting', icon: nil)
render_to_string(partial: 'shared/button_title', locals: { title:, disabled_with:, icon:, icon_disabled: }) render_to_string(partial: 'shared/button_title', locals: { title:, disabled_with:, icon: })
end end
def svg_icon(icon_name, class: '') def svg_icon(icon_name, class: '')
@@ -1,17 +0,0 @@
# 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
-1
View File
@@ -4,7 +4,6 @@ class DashboardController < ApplicationController
skip_before_action :authenticate_user!, only: %i[index] skip_before_action :authenticate_user!, only: %i[index]
def index def index
return redirect_to Docuseal::PRODUCT_URL, allow_other_host: true if Docuseal.multitenant? && !signed_in?
return render 'pages/landing' unless signed_in? return render 'pages/landing' unless signed_in?
templates = current_account.templates.active.preload(:author).order(id: :desc) templates = current_account.templates.active.preload(:author).order(id: :desc)
+2 -8
View File
@@ -6,17 +6,11 @@ class EmailSettingsController < ApplicationController
def index; end def index; end
def create def create
if @encrypted_config.update(email_configs) if @encrypted_config.update(storage_configs)
SettingsMailer.smtp_successful_setup(@encrypted_config.value['from_email']).deliver_now!
redirect_to settings_email_index_path, notice: 'Changes have been saved' redirect_to settings_email_index_path, notice: 'Changes have been saved'
else else
render :index, status: :unprocessable_entity render :index, status: :unprocessable_entity
end end
rescue StandardError => e
flash[:alert] = e.message
render :index, status: :unprocessable_entity
end end
private private
@@ -26,7 +20,7 @@ class EmailSettingsController < ApplicationController
EncryptedConfig.find_or_initialize_by(account: current_account, key: EncryptedConfig::EMAIL_SMTP_KEY) EncryptedConfig.find_or_initialize_by(account: current_account, key: EncryptedConfig::EMAIL_SMTP_KEY)
end end
def email_configs def storage_configs
params.require(:encrypted_config).permit(value: {}).tap do |e| params.require(:encrypted_config).permit(value: {}).tap do |e|
e[:value].compact_blank! e[:value].compact_blank!
end end
+10 -102
View File
@@ -1,111 +1,19 @@
# frozen_string_literal: true # frozen_string_literal: true
class EsignSettingsController < ApplicationController 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 def create
@cert_record = CertFormRecord.new(**cert_params) pdfs =
params[:files].map do |file|
HexaPDF::Document.new(io: file.open)
end
cert_configs = EncryptedConfig.find_or_initialize_by(account: current_account, certs = Accounts.load_signing_certs(current_account)
key: EncryptedConfig::ESIGN_CERTS_KEY)
if (cert_configs.value && cert_configs.value['custom']&.any? { |e| e['name'] == @cert_record.name }) || trusted_certs = [certs[:cert], certs[:sub_ca], certs[:root_ca]]
@cert_record.name == DEFAULT_CERT_NAME
@cert_record.errors.add(:name, 'already exists') render turbo_stream: turbo_stream.replace('result', partial: 'result',
locals: { pdfs:, files: params[:files], trusted_certs: })
return render turbo_stream: turbo_stream.replace(:modal, template: 'esign_settings/new'), rescue HexaPDF::MalformedPDFError
status: :unprocessable_entity render turbo_stream: turbo_stream.replace('result', html: helpers.tag.div('Invalid PDF', id: 'result'))
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
end end
@@ -1,16 +0,0 @@
# frozen_string_literal: true
class OmniauthCallbacksController < Devise::OmniauthCallbacksController
def google_oauth2
@user = Users.from_omniauth(request.env['omniauth.auth'])
if @user.persisted?
flash[:notice] = I18n.t('devise.omniauth_callbacks.success', kind: 'Google')
sign_in_and_redirect @user, event: :authentication
else
redirect_to new_registration_path(oauth_callback: true, user: @user.slice(:email, :first_name, :last_name)),
notice: 'Please complete registration with Google auth'
end
end
end
@@ -1,20 +0,0 @@
# 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 -40
View File
@@ -1,62 +1,24 @@
# frozen_string_literal: true # frozen_string_literal: true
class RegistrationsController < Devise::RegistrationsController 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
def create
super
Accounts.create_default_template(resource.account) if resource.account.persisted?
end
private 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
flash.clear
end
def build_resource(_hash = {}) def build_resource(_hash = {})
account = Account.new(account_params) account = Account.new(account_params)
account.timezone = Accounts.normalize_timezone(account.timezone) account.timezone = Accounts.normalize_timezone(account.timezone)
self.resource = account.users.new(user_params) self.resource = account.users.new(user_params)
account.name ||= "#{resource.full_name}'s Company" if params[:action] == 'create'
end end
def user_params def user_params
return {} if params[:user].blank? return {} if params[:user].blank?
params.require(:user).permit(:first_name, :last_name, :email, :password).compact_blank.tap do |attrs| params.require(:user).permit(:first_name, :last_name, :email, :password)
attrs[:password] ||= SecureRandom.hex if params[:action] == 'create'
end
end end
def account_params def account_params
return {} if params[:account].blank? return {} if params[:account].blank?
params.require(:account).permit(:name, :timezone).compact_blank params.require(:account).permit(:name, :timezone)
end end
end end
-30
View File
@@ -1,30 +0,0 @@
# frozen_string_literal: true
class SessionsController < Devise::SessionsController
def create
if Docuseal.multitenant? && !User.exists?(email: sign_in_params[:email])
return redirect_to new_registration_path(sign_up: true, user: sign_in_params.slice(:email)),
notice: 'Create a new account'
end
super
end
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
flash.clear
end
end
+2 -3
View File
@@ -16,17 +16,16 @@ class SetupController < ApplicationController
def create def create
@account = Account.new(account_params) @account = Account.new(account_params)
@account.timezone = Accounts.normalize_timezone(@account.timezone) @account.timezone = Accounts.normalize_timezone(@account.timezone)
@user = @account.users.new(user_params) @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]) 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') @encrypted_config.errors.add(:value, 'should be a valid URL')
return render :index, status: :unprocessable_entity return render :index, status: :unprocessable_entity
end end
return render :index, status: :unprocessable_entity unless @account.valid?
if @user.save if @user.save
encrypted_configs = [ encrypted_configs = [
{ key: EncryptedConfig::APP_URL_KEY, value: encrypted_config_params[:value] }, { key: EncryptedConfig::APP_URL_KEY, value: encrypted_config_params[:value] },
@@ -1,5 +0,0 @@
# frozen_string_literal: true
class SmsSettingsController < ApplicationController
def index; end
end
+2 -4
View File
@@ -25,7 +25,7 @@ class StartFormController < ApplicationController
ua: request.user_agent ua: request.user_agent
) )
@submitter.submission ||= Submission.new(template: @template, source: :link) @submitter.submission ||= Submission.new(template: @template)
if @submitter.save if @submitter.save
redirect_to submit_form_path(@submitter.slug) redirect_to submit_form_path(@submitter.slug)
@@ -36,9 +36,7 @@ class StartFormController < ApplicationController
end end
def completed def completed
@submitter = Submitter.where(submission: @template.submissions) @submitter = Submitter.where(submission: @template.submissions).find_by!(email: params[:email])
.where.not(completed_at: nil)
.find_by!(email: params[:email])
end end
private private
+34 -16
View File
@@ -6,7 +6,7 @@ class SubmissionsController < ApplicationController
def show def show
@submission = @submission =
Submission.joins(:template).where(template: { account_id: current_account.id }) Submission.joins(:template).where(template: { account_id: current_account.id })
.preload(:template, template_schema_documents: [:blob, { preview_images_attachments: :blob }]) .preload(template: { documents_attachments: { preview_images_attachments: :blob } })
.find(params[:id]) .find(params[:id])
render :show, layout: 'plain' render :show, layout: 'plain'
@@ -17,25 +17,19 @@ class SubmissionsController < ApplicationController
def create def create
submissions = submissions =
if params[:emails].present? if params[:emails].present?
Submissions.create_from_emails(template: @template, create_submissions_from_emails
user: current_user,
source: :invite,
mark_as_sent: params[:send_email] == '1',
emails: params[:emails])
else else
Submissions.create_from_submitters(template: @template, create_submissions_from_submitters
user: current_user,
source: :invite,
mark_as_sent: params[:send_email] == '1',
submissions_attrs: submissions_params[:submission].to_h.values)
end end
submitters = submissions.flat_map(&:submitters) if params[:send_email] == '1'
submissions.flat_map(&:submitters).each do |submitter|
Submitters.send_signature_requests(submitters, params) SubmitterMailer.invitation_email(submitter, message: params[:message]).deliver_later!
end
end
redirect_to template_path(@template), redirect_to template_path(@template),
notice: "#{submitters.size} #{'recipient'.pluralize(submitters.size)} added" notice: "#{submissions.size} #{'recipient'.pluralize(submissions.size)} added"
end end
def destroy def destroy
@@ -49,8 +43,32 @@ class SubmissionsController < ApplicationController
private 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 def submissions_params
params.permit(submission: { submitters: [%i[uuid email phone name]] }) params.permit(submission: { submitters: [%i[uuid email]] })
end end
def load_template def load_template
@@ -1,28 +0,0 @@
# 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
+25 -4
View File
@@ -7,9 +7,7 @@ class SubmitFormController < ApplicationController
def show def show
@submitter = @submitter =
Submitter.preload(submission: [ Submitter.preload(submission: { template: { documents_attachments: { preview_images_attachments: :blob } } })
:template, { template_schema_documents: [:blob, { preview_images_attachments: :blob }] }
])
.find_by!(slug: params[:slug]) .find_by!(slug: params[:slug])
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at? return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
@@ -19,8 +17,19 @@ class SubmitFormController < ApplicationController
def update def update
submitter = Submitter.find_by!(slug: params[:slug]) 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
Submitters::SubmitValues.call(submitter, params, request) submitter.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
head :ok head :ok
end end
@@ -28,4 +37,16 @@ class SubmitFormController < ApplicationController
def completed def completed
@submitter = Submitter.find_by!(slug: params[:submit_form_slug]) @submitter = Submitter.find_by!(slug: params[:submit_form_slug])
end 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 end
@@ -1,16 +0,0 @@
# 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 end
def edit def edit
@template = current_account.templates.preload(schema_documents: { preview_images_attachments: :blob }) @template = current_account.templates.preload(documents_attachments: { preview_images_attachments: :blob })
.find(params[:id]) .find(params[:id])
render :edit, layout: 'plain' render :edit, layout: 'plain'
+3 -9
View File
@@ -24,30 +24,24 @@ class UsersController < ApplicationController
if @user.save if @user.save
UserMailer.invitation_email(@user).deliver_later! UserMailer.invitation_email(@user).deliver_later!
redirect_to settings_users_path, notice: 'User has been invited' redirect_to settings_users_path, notice: 'User has been invited.'
else else
render turbo_stream: turbo_stream.replace(:modal, template: 'users/new'), status: :unprocessable_entity render turbo_stream: turbo_stream.replace(:modal, template: 'users/new'), status: :unprocessable_entity
end end
end end
def update def update
return redirect_to settings_users_path, notice: 'Unable to update user.' if Docuseal.demo?
if @user.update(user_params.compact_blank) if @user.update(user_params.compact_blank)
redirect_to settings_users_path, notice: 'User has been updated' redirect_to settings_users_path, notice: 'User has been updated.'
else else
render turbo_stream: turbo_stream.replace(:modal, template: 'users/edit'), status: :unprocessable_entity render turbo_stream: turbo_stream.replace(:modal, template: 'users/edit'), status: :unprocessable_entity
end end
end end
def destroy def destroy
if Docuseal.demo? || @user.id == current_user.id
return redirect_to settings_users_path, notice: 'Unable to remove user'
end
@user.update!(deleted_at: Time.current) @user.update!(deleted_at: Time.current)
redirect_to settings_users_path, notice: 'User has been removed' redirect_to settings_users_path, notice: 'User has been removed.'
end end
private private
@@ -1,32 +0,0 @@
# 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
@@ -1,31 +0,0 @@
# 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
-23
View File
@@ -45,36 +45,13 @@ window.customElements.define('set-timezone', SetTimezone)
window.customElements.define('autoresize-textarea', AutoresizeTextarea) window.customElements.define('autoresize-textarea', AutoresizeTextarea)
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody) 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 { window.customElements.define('template-builder', class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.appElem = document.createElement('div') this.appElem = document.createElement('div')
this.appElem.classList.add('max-h-screen')
this.app = createApp(TemplateBuilder, { this.app = createApp(TemplateBuilder, {
template: reactive(JSON.parse(this.dataset.template)), template: reactive(JSON.parse(this.dataset.template)),
backgroundColor: '#faf7f5',
isDirectUpload: this.dataset.isDirectUpload === 'true' isDirectUpload: this.dataset.isDirectUpload === 'true'
}) })
-4
View File
@@ -70,7 +70,3 @@ button[disabled] .enabled {
.base-radio { .base-radio {
@apply radio bg-white radio-sm no-animation; @apply radio bg-white radio-sm no-animation;
} }
.base-select {
@apply select base-input w-full font-normal;
}
+1 -5
View File
@@ -38,11 +38,7 @@ export default actionable(targetable(class extends HTMLElement {
this.uploadFiles(this.input.files) this.uploadFiles(this.input.files)
} }
toggleLoading = (e) => { toggleLoading = () => {
if (e && e.target && !e.target.contains(this)) {
return
}
this.loading.classList.toggle('hidden') this.loading.classList.toggle('hidden')
this.icon.classList.toggle('hidden') this.icon.classList.toggle('hidden')
this.classList.toggle('opacity-50') this.classList.toggle('opacity-50')
+1 -1
View File
@@ -1,7 +1,7 @@
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.querySelectorAll('a').forEach((link) => { this.querySelectorAll('a').forEach((link) => {
if (document.location.pathname.startsWith(link.pathname) && !link.getAttribute('href').startsWith('http')) { if (document.location.pathname.startsWith(link.pathname)) {
link.classList.add('bg-base-300') link.classList.add('bg-base-300')
} }
}) })
+2 -6
View File
@@ -5,19 +5,15 @@ export default actionable(class extends HTMLElement {
document.body.classList.add('overflow-hidden') document.body.classList.add('overflow-hidden')
document.addEventListener('keyup', this.onEscKey) document.addEventListener('keyup', this.onEscKey)
document.addEventListener('turbo:submit-end', this.onSubmit)
document.addEventListener('turbo:before-cache', this.close) document.addEventListener('turbo:before-cache', this.close)
if (this.dataset.closeAfterSubmit !== 'false') {
document.addEventListener('turbo:submit-end', this.onSubmit)
}
} }
disconnectedCallback () { disconnectedCallback () {
document.body.classList.remove('overflow-hidden') document.body.classList.remove('overflow-hidden')
document.removeEventListener('keyup', this.onEscKey) document.removeEventListener('keyup', this.onEscKey)
document.removeEventListener('turbo:submit-end', this.onSubmit) document.removeEventListener('turbo:submit-end', this.handleSubmit)
document.removeEventListener('turbo:before-cache', this.close) document.removeEventListener('turbo:before-cache', this.close)
} }
+2 -4
View File
@@ -9,13 +9,11 @@ window.customElements.define('submission-form', class extends HTMLElement {
this.appElem = document.createElement('div') this.appElem = document.createElement('div')
this.app = createApp(Form, { this.app = createApp(Form, {
submitter: JSON.parse(this.dataset.submitter), submitterSlug: this.dataset.submitterSlug,
submitterUuid: this.dataset.submitterUuid,
authenticityToken: this.dataset.authenticityToken, authenticityToken: this.dataset.authenticityToken,
canSendEmail: this.dataset.canSendEmail === 'true', canSendEmail: this.dataset.canSendEmail === 'true',
isDirectUpload: this.dataset.isDirectUpload === '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)), values: reactive(JSON.parse(this.dataset.values)),
attachments: reactive(JSON.parse(this.dataset.attachments)), attachments: reactive(JSON.parse(this.dataset.attachments)),
fields: JSON.parse(this.dataset.fields) fields: JSON.parse(this.dataset.fields)
+4 -9
View File
@@ -21,14 +21,14 @@
</span> </span>
</div> </div>
<div <div
v-if="isActive && withLabel" v-if="isActive"
class="absolute -top-7 rounded bg-base-content text-base-100 px-2 text-sm whitespace-nowrap" class="absolute -top-7 rounded bg-base-content text-base-100 px-2 text-sm whitespace-nowrap"
> >
{{ field.name || fieldNames[field.type] }} {{ field.name || fieldNames[field.type] }}
<template v-if="field.type === 'checkbox' && !field.name"> <template v-if="field.type === 'checkbox'">
{{ fieldIndex + 1 }} {{ fieldIndex + 1 }}
</template> </template>
<template v-else-if="!field.required && field.type !== 'checkbox'"> <template v-else-if="!field.required">
(optional) (optional)
</template> </template>
</div> </div>
@@ -146,11 +146,6 @@ export default {
required: false, required: false,
default: false default: false
}, },
withLabel: {
type: Boolean,
required: false,
default: true
},
fieldIndex: { fieldIndex: {
type: Number, type: Number,
required: false, required: false,
@@ -212,7 +207,7 @@ export default {
}, },
formattedDate () { formattedDate () {
if (this.field.type === 'date' && this.modelValue) { if (this.field.type === 'date' && this.modelValue) {
return new Intl.DateTimeFormat([], { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }).format(new Date(this.modelValue)) return new Intl.DateTimeFormat([], { year: 'numeric', month: 'long', day: 'numeric' }).format(new Date(this.modelValue))
} else { } else {
return '' return ''
} }
+4 -24
View File
@@ -11,9 +11,7 @@
v-for="(area, areaIndex) in field.areas" v-for="(area, areaIndex) in field.areas"
:key="areaIndex" :key="areaIndex"
> >
<Teleport <Teleport :to="`#page-${area.attachment_uuid}-${area.page}`">
:to="findPageElementForArea(area)"
>
<FieldArea <FieldArea
:ref="setAreaRef" :ref="setAreaRef"
v-model="values[field.uuid]" v-model="values[field.uuid]"
@@ -22,7 +20,6 @@
:submittable="true" :submittable="true"
:field-index="fieldIndex" :field-index="fieldIndex"
:is-active="currentStep === step" :is-active="currentStep === step"
:with-label="withLabel"
:is-value-set="step.some((f) => f.uuid in values)" :is-value-set="step.some((f) => f.uuid in values)"
:attachments-index="attachmentsIndex" :attachments-index="attachmentsIndex"
@click="$emit('focus-step', step)" @click="$emit('focus-step', step)"
@@ -57,11 +54,6 @@ export default {
required: false, required: false,
default: () => ({}) default: () => ({})
}, },
withLabel: {
type: Boolean,
required: false,
default: true
},
currentStep: { currentStep: {
type: Array, type: Array,
required: false, required: false,
@@ -78,16 +70,10 @@ export default {
this.areaRefs = [] this.areaRefs = []
}, },
methods: { methods: {
findPageElementForArea (area) {
return (this.$root.$el?.parentNode?.getRootNode() || document).getElementById(`page-${area.attachment_uuid}-${area.page}`)
},
scrollIntoField (field) { scrollIntoField (field) {
this.areaRefs.find((area) => { this.areaRefs.find((area) => {
if (area.field === field) { if (area.field === field) {
const root = this.$root.$el.parentNode.getRootNode() if (document.body.style.overflow === 'hidden') {
const container = root.body || root.querySelector('div')
if (container.style.overflow === 'hidden') {
this.scrollInContainer(area.$el) this.scrollInContainer(area.$el)
} else { } else {
area.$refs.scrollToElem.scrollIntoView({ behavior: 'smooth', block: 'start' }) area.$refs.scrollToElem.scrollIntoView({ behavior: 'smooth', block: 'start' })
@@ -100,19 +86,13 @@ export default {
}) })
}, },
scrollInContainer (target) { 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 padding = 64
const boxRect = scrollbox.children[0].getBoundingClientRect() const boxRect = window.scrollbox.children[0].getBoundingClientRect()
const targetRect = target.getBoundingClientRect() const targetRect = target.getBoundingClientRect()
const targetTopRelativeToBox = targetRect.top - boxRect.top const targetTopRelativeToBox = targetRect.top - boxRect.top
scrollbox.scrollTop = targetTopRelativeToBox - container.offsetHeight + formContainer.offsetHeight + target.offsetHeight + padding window.scrollbox.scrollTop = targetTopRelativeToBox - document.body.offsetHeight + window.form_container.offsetHeight + target.offsetHeight + padding
}, },
setAreaRef (el) { setAreaRef (el) {
if (el) { if (el) {
+17 -61
View File
@@ -7,13 +7,13 @@
:height="30" :height="30"
/> />
<span> <span>
{{ t('form_has_been_completed') }} Form has been completed!
</span> </span>
</p> </p>
<div class="space-y-3 mt-5"> <div class="space-y-3 mt-5">
<button <button
v-if="canSendEmail && !isDemo" v-if="canSendEmail"
class="white-button !h-auto flex items-center space-x-1 w-full" class="white-button flex items-center space-x-1 w-full"
:disabled="isSendingCopy" :disabled="isSendingCopy"
@click.prevent="sendCopyToEmail" @click.prevent="sendCopyToEmail"
> >
@@ -23,7 +23,7 @@
/> />
<IconMail v-else /> <IconMail v-else />
<span> <span>
{{ t('send_copy_via_email') }} Send copy via email
</span> </span>
</button> </button>
<button <button
@@ -37,79 +37,37 @@
/> />
<IconDownload v-else /> <IconDownload v-else />
<span> <span>
{{ t('download') }} Download
</span> </span>
</button> </button>
<a
v-if="isDemo"
target="_blank"
href="https://github.com/docusealco/docuseal"
class="white-button flex items-center space-x-1 w-full"
>
<IconBrandGithub />
<span>
Star on Github
</span>
</a>
<a
v-if="isDemo"
href="https://docuseal.co/sign_up"
class="white-button flex items-center space-x-1 w-full"
>
<IconLogin />
<span>
{{ t('create_a_free_account') }}
</span>
</a>
</div> </div>
<div <div class="text-center mt-4">
v-if="attribution" Signed with
class="text-center mt-4"
>
{{ t('signed_with') }}
<a <a
href="https://www.docuseal.co" href="https://www.docuseal.co"
target="_blank" target="_blank"
class="underline" class="underline"
>DocuSeal</a> - {{ t('open_source_documents_software') }} >DocuSeal</a> - open source documents software
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import { IconCircleCheck, IconBrandGithub, IconMail, IconDownload, IconInnerShadowTop, IconLogin } from '@tabler/icons-vue' import { IconCircleCheck, IconMail, IconDownload, IconInnerShadowTop } from '@tabler/icons-vue'
export default { export default {
name: 'FormCompleted', name: 'FormCompleted',
components: { components: {
IconCircleCheck, IconCircleCheck,
IconInnerShadowTop, IconInnerShadowTop,
IconBrandGithub,
IconMail, IconMail,
IconLogin,
IconDownload IconDownload
}, },
inject: ['baseUrl', 't'],
props: { props: {
submitterSlug: { submitterSlug: {
type: String, type: String,
required: true required: true
}, },
isDemo: {
type: Boolean,
required: false,
default: false
},
attribution: {
type: Boolean,
required: false,
default: true
},
withConfetti: {
type: Boolean,
required: false,
default: false
},
canSendEmail: { canSendEmail: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -123,21 +81,19 @@ export default {
} }
}, },
async mounted () { async mounted () {
if (this.withConfetti) { const { default: confetti } = await import('canvas-confetti')
const { default: confetti } = await import('canvas-confetti')
confetti({ confetti({
particleCount: 50, particleCount: 50,
startVelocity: 30, startVelocity: 30,
spread: 140 spread: 140
}) })
}
}, },
methods: { methods: {
sendCopyToEmail () { sendCopyToEmail () {
this.isSendingCopy = true this.isSendingCopy = true
fetch(this.baseUrl + `/send_submission_email.json?submitter_slug=${this.submitterSlug}`, { fetch(`/send_submission_email.json?submitter_slug=${this.submitterSlug}`, {
method: 'POST' method: 'POST'
}).then(() => { }).then(() => {
alert('Email has been sent') alert('Email has been sent')
@@ -148,7 +104,7 @@ export default {
download () { download () {
this.isDownloading = true this.isDownloading = true
fetch(this.baseUrl + `/submitters/${this.submitterSlug}/download`).then((response) => response.json()).then((urls) => { fetch(`/submitters/${this.submitterSlug}/download`).then((response) => response.json()).then((urls) => {
const fileRequests = urls.map((url) => { const fileRequests = urls.map((url) => {
return () => { return () => {
return fetch(url).then(async (resp) => { return fetch(url).then(async (resp) => {
+3 -4
View File
@@ -29,7 +29,7 @@
{{ message }} {{ message }}
</div> </div>
<div class="text-xs"> <div class="text-xs">
<span class="font-medium">{{ t('click_to_upload') }}</span> {{ t('or_drag_and_drop_files') }} <span class="font-medium">Click to upload</span> or drag and drop files
</div> </div>
</div> </div>
</div> </div>
@@ -55,7 +55,6 @@ export default {
IconCloudUpload, IconCloudUpload,
IconInnerShadowTop IconInnerShadowTop
}, },
inject: ['baseUrl', 't'],
props: { props: {
message: { message: {
type: String, type: String,
@@ -142,7 +141,7 @@ export default {
return await Promise.all( return await Promise.all(
blobs.map((blob) => { blobs.map((blob) => {
return fetch(this.baseUrl + '/api/attachments', { return fetch('/api/attachments', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
name: 'attachments', name: 'attachments',
@@ -167,7 +166,7 @@ export default {
formData.append('submitter_slug', this.submitterSlug) formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments') formData.append('name', 'attachments')
return fetch(this.baseUrl + '/api/attachments', { return fetch('/api/attachments', {
method: 'POST', method: 'POST',
body: formData body: formData
}).then(resp => resp.json()).then((data) => { }).then(resp => resp.json()).then((data) => {
+237 -333
View File
@@ -4,311 +4,261 @@
:steps="stepFields" :steps="stepFields"
:values="values" :values="values"
:attachments-index="attachmentsIndex" :attachments-index="attachmentsIndex"
:with-label="!isAnonymousChecboxes"
:current-step="currentStepFields" :current-step="currentStepFields"
@focus-step="[saveStep(), goToStep($event, false, true), currentField.type !== 'checkbox' ? isFormVisible = true : '']" @focus-step="[saveStep(), goToStep($event, false, true)]"
/> />
<button <div>
v-if="!isFormVisible" <form
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"
>
{{ t('submit_form') }}
<IconArrowsDiagonal
class="absolute right-0 mr-4"
:width="20"
:height="20"
/>
</button>
<div
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" v-if="!isCompleted"
class="absolute right-0 mr-2 mt-2 top-0 hidden md:block" ref="form"
title="Minimize" :action="submitPath"
@click.prevent="isFormVisible = false" method="post"
class="md:mx-16"
@submit.prevent="submitStep"
> >
<IconArrowsDiagonalMinimize2 <input
:width="20" type="hidden"
:height="20" name="authenticity_token"
/> :value="authenticityToken"
</button>
<div>
<form
v-if="!isCompleted"
ref="form"
:action="submitPath"
method="post"
class="md:mx-16"
@submit.prevent="submitStep"
> >
<input <input
type="hidden" value="put"
name="authenticity_token" name="_method"
:value="authenticityToken" type="hidden"
> >
<input <div class="md:mt-4">
value="put" <div v-if="['cells', 'text'].includes(currentField.type)">
name="_method" <label
type="hidden" v-if="currentField.name"
> :for="currentField.uuid"
<div class="md:mt-4"> class="label text-2xl mb-2"
<div v-if="['cells', 'text'].includes(currentField.type)"> >{{ currentField.name }}
<label <template v-if="!currentField.required">(optional)</template>
v-if="currentField.name" </label>
:for="currentField.uuid" <div
class="label text-2xl mb-2" v-else
>{{ currentField.name }} class="py-1"
<template v-if="!currentField.required">({{ t('optional') }})</template> />
</label> <div>
<div <input
v-else
class="py-1"
/>
<div>
<input
:id="currentField.uuid"
v-model="values[currentField.uuid]"
class="base-input !text-2xl w-full"
:required="currentField.required"
:placeholder="`${t('type_here')}...${currentField.required ? '' : ` (${t('optional')})`}`"
type="text"
:name="`values[${currentField.uuid}]`"
@focus="$refs.areas.scrollIntoField(currentField)"
>
</div>
</div>
<div v-else-if="currentField.type === 'date'">
<label
v-if="currentField.name"
:for="currentField.uuid"
class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">({{ t('optional') }})</template>
</label>
<div
v-else
class="py-1"
/>
<div class="text-center">
<input
:id="currentField.uuid"
v-model="values[currentField.uuid]"
class="base-input !text-2xl text-center w-full"
:required="currentField.required"
type="date"
:name="`values[${currentField.uuid}]`"
@focus="$refs.areas.scrollIntoField(currentField)"
>
</div>
</div>
<div v-else-if="currentField.type === 'select'">
<label
v-if="currentField.name"
:for="currentField.uuid"
class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">({{ t('optional') }})</template>
</label>
<div
v-else
class="py-1"
/>
<select
:id="currentField.uuid" :id="currentField.uuid"
v-model="values[currentField.uuid]"
class="base-input !text-2xl w-full"
:required="currentField.required" :required="currentField.required"
class="select base-input !text-2xl w-full text-center font-normal" :placeholder="`Type here...${currentField.required ? '' : ' (optional)'}`"
type="text"
:name="`values[${currentField.uuid}]`" :name="`values[${currentField.uuid}]`"
@change="values[currentField.uuid] = $event.target.value"
@focus="$refs.areas.scrollIntoField(currentField)" @focus="$refs.areas.scrollIntoField(currentField)"
> >
<option </div>
value="" </div>
:selected="!values[currentField.uuid]" <div v-else-if="currentField.type === 'date'">
> <label
{{ t('select_your_option') }} v-if="currentField.name"
</option> :for="currentField.uuid"
<option class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">(optional)</template>
</label>
<div
v-else
class="py-1"
/>
<div class="text-center">
<input
:id="currentField.uuid"
v-model="values[currentField.uuid]"
class="base-input !text-2xl text-center w-full"
:required="currentField.required"
type="date"
:name="`values[${currentField.uuid}]`"
@focus="$refs.areas.scrollIntoField(currentField)"
>
</div>
</div>
<div v-else-if="currentField.type === 'select'">
<label
v-if="currentField.name"
:for="currentField.uuid"
class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">(optional)</template>
</label>
<div
v-else
class="py-1"
/>
<select
:id="currentField.uuid"
:required="currentField.required"
class="select base-input !text-2xl w-full text-center font-normal"
:name="`values[${currentField.uuid}]`"
@change="values[currentField.uuid] = $event.target.value"
@focus="$refs.areas.scrollIntoField(currentField)"
>
<option
value=""
:selected="!values[currentField.uuid]"
>
Select your option
</option>
<option
v-for="(option, index) in currentField.options"
:key="index"
:selected="values[currentField.uuid] == option"
:value="option"
>
{{ option }}
</option>
</select>
</div>
<div v-else-if="currentField.type === 'radio'">
<label
v-if="currentField.name"
:for="currentField.uuid"
class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">(optional)</template>
</label>
<div class="flex w-full">
<div class="space-y-3.5 mx-auto">
<div
v-for="(option, index) in currentField.options" v-for="(option, index) in currentField.options"
:key="index" :key="index"
:selected="values[currentField.uuid] == option"
:value="option"
> >
{{ option }} <label
</option> :for="currentField.uuid + option"
</select> class="flex items-center space-x-3"
</div>
<div v-else-if="currentField.type === 'radio'">
<label
v-if="currentField.name"
:for="currentField.uuid"
class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">({{ t('optional') }})</template>
</label>
<div class="flex w-full">
<div class="space-y-3.5 mx-auto">
<div
v-for="(option, index) in currentField.options"
:key="index"
> >
<label <input
:for="currentField.uuid + option" :id="currentField.uuid + option"
class="flex items-center space-x-3" v-model="values[currentField.uuid]"
type="radio"
class="base-radio !h-7 !w-7"
:name="`values[${currentField.uuid}]`"
:value="option"
:required="currentField.required"
> >
<input <span class="text-xl">
:id="currentField.uuid + option" {{ option }}
v-model="values[currentField.uuid]" </span>
type="radio" </label>
class="base-radio !h-7 !w-7"
:name="`values[${currentField.uuid}]`"
:value="option"
:required="currentField.required"
>
<span class="text-xl">
{{ option }}
</span>
</label>
</div>
</div> </div>
</div> </div>
</div> </div>
<MultiSelectStep </div>
v-else-if="currentField.type === 'multiple'" <MultiSelectStep
v-model="values[currentField.uuid]" v-else-if="currentField.type === 'multiple'"
:field="currentField" v-model="values[currentField.uuid]"
/> :field="currentField"
/>
<div
v-else-if="currentField.type === 'checkbox'"
class="flex w-full"
>
<input
type="hidden"
name="cast_boolean"
value="true"
>
<div <div
v-else-if="currentField.type === 'checkbox'" class="space-y-3.5 mx-auto"
class="flex w-full max-h-44 overflow-y-auto"
> >
<input
type="hidden"
name="cast_boolean"
value="true"
>
<div <div
class="space-y-3.5 mx-auto" v-for="(field, index) in currentStepFields"
:key="field.uuid"
> >
<template v-if="isAnonymousChecboxes"> <label
<span class="text-xl"> :for="field.uuid"
{{ t('complete_hightlighted_checkboxes_and_click') }} <span class="font-semibold">{{ stepFields.length === currentStep + 1 ? t('submit') : t('next') }}</span>. class="flex items-center space-x-3"
</span> >
<input <input
v-for="field in currentStepFields"
:key="field.uuid"
type="hidden" type="hidden"
:name="`values[${field.uuid}]`" :name="`values[${field.uuid}]`"
:value="!!values[field.uuid]" :value="!!values[field.uuid]"
> >
</template> <input
<template v-else> :id="field.uuid"
<div type="checkbox"
v-for="(field, index) in currentStepFields" class="base-checkbox !h-7 !w-7"
:key="field.uuid" :checked="!!values[field.uuid]"
@click="[$refs.areas.scrollIntoField(field), values[field.uuid] = !values[field.uuid]]"
> >
<label <span class="text-xl">
:for="field.uuid" {{ currentField.name || currentField.type + ' ' + (index + 1) }}
class="flex items-center space-x-3" </span>
> </label>
<input
type="hidden"
:name="`values[${field.uuid}]`"
:value="!!values[field.uuid]"
>
<input
:id="field.uuid"
type="checkbox"
class="base-checkbox !h-7 !w-7"
:checked="!!values[field.uuid]"
@click="[$refs.areas.scrollIntoField(field), values[field.uuid] = !values[field.uuid]]"
>
<span class="text-xl">
{{ field.name || field.type + ' ' + (index + 1) }}
</span>
</label>
</div>
</template>
</div> </div>
</div> </div>
<ImageStep
v-else-if="currentField.type === 'image'"
v-model="values[currentField.uuid]"
:field="currentField"
:is-direct-upload="isDirectUpload"
:attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug"
@attached="[attachments.push($event), $refs.areas.scrollIntoField(currentField)]"
/>
<SignatureStep
v-else-if="currentField.type === 'signature'"
ref="currentStep"
v-model="values[currentField.uuid]"
:field="currentField"
:is-direct-upload="isDirectUpload"
:attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug"
@attached="attachments.push($event)"
@start="$refs.areas.scrollIntoField(currentField)"
@minimize="isFormVisible = false"
/>
<AttachmentStep
v-else-if="currentField.type === 'file'"
v-model="values[currentField.uuid]"
:is-direct-upload="isDirectUpload"
:field="currentField"
:attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug"
@attached="[attachments.push($event), $refs.areas.scrollIntoField(currentField)]"
/>
</div> </div>
<div class="mt-6 md:mt-8"> <ImageStep
<button v-else-if="currentField.type === 'image'"
type="submit" v-model="values[currentField.uuid]"
class="base-button w-full flex justify-center" :field="currentField"
:disabled="isButtonDisabled" :is-direct-upload="isDirectUpload"
> :attachments-index="attachmentsIndex"
<span class="flex"> :submitter-slug="submitterSlug"
<IconInnerShadowTop @attached="[attachments.push($event), $refs.areas.scrollIntoField(currentField)]"
v-if="isSubmitting" />
class="mr-1 animate-spin" <SignatureStep
/> v-else-if="currentField.type === 'signature'"
<span v-if="stepFields.length === currentStep + 1"> ref="currentStep"
{{ t('submit') }} v-model="values[currentField.uuid]"
</span> :field="currentField"
<span v-else> :is-direct-upload="isDirectUpload"
{{ t('next') }} :attachments-index="attachmentsIndex"
</span><span :submitter-slug="submitterSlug"
v-if="isSubmitting" @attached="attachments.push($event)"
class="w-6 flex justify-start mr-1" />
><span>...</span></span> <AttachmentStep
v-else-if="currentField.type === 'file'"
v-model="values[currentField.uuid]"
:is-direct-upload="isDirectUpload"
:field="currentField"
:attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug"
@attached="[attachments.push($event), $refs.areas.scrollIntoField(currentField)]"
/>
</div>
<div class="mt-6 md:mt-8">
<button
type="submit"
class="base-button w-full flex justify-center"
:disabled="isButtonDisabled"
>
<span class="flex">
<IconInnerShadowTop
v-if="isSubmitting"
class="mr-1 animate-spin"
/>
<span v-if="stepFields.length === currentStep + 1">
Submit
</span> </span>
</button> <span v-else>
</div> Next
</form> </span><span
<FormCompleted v-if="isSubmitting"
v-else class="w-6 flex justify-start mr-1"
:is-demo="isDemo" ><span>...</span></span>
:attribution="attribution" </span>
:with-confetti="withConfetti" </button>
:can-send-email="canSendEmail && submitter.email" </div>
:submitter-slug="submitterSlug" </form>
/> <FormCompleted
<div class="flex justify-center"> v-else
<div class="flex items-center mt-5 mb-1"> :can-send-email="canSendEmail"
<a :submitter-slug="submitterSlug"
v-for="(step, index) in stepFields" />
:key="step[0].uuid" <div class="flex justify-center">
href="#" <div class="flex items-center mt-5 mb-1">
class="inline border border-base-300 h-3 w-3 rounded-full mx-1" <a
:class="{ 'bg-base-300': index === currentStep, 'bg-base-content': index < currentStep || isCompleted, 'bg-white': index > currentStep }" v-for="(step, index) in stepFields"
@click.prevent="isCompleted ? '' : [saveStep(), goToStep(step, true)]" :key="step[0].uuid"
/> href="#"
</div> class="inline border border-base-300 h-3 w-3 rounded-full mx-1"
:class="{ 'bg-base-300': index === currentStep, 'bg-base-content': index < currentStep || isCompleted, 'bg-white': index > currentStep }"
@click.prevent="isCompleted ? '' : [saveStep(), goToStep(step, true)]"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -321,8 +271,7 @@ import SignatureStep from './signature_step'
import AttachmentStep from './attachment_step' import AttachmentStep from './attachment_step'
import MultiSelectStep from './multi_select_step' import MultiSelectStep from './multi_select_step'
import FormCompleted from './completed' import FormCompleted from './completed'
import { IconInnerShadowTop, IconArrowsDiagonal, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue' import { IconInnerShadowTop } from '@tabler/icons-vue'
import { t } from './i18n'
export default { export default {
name: 'SubmissionForm', name: 'SubmissionForm',
@@ -333,19 +282,11 @@ export default {
AttachmentStep, AttachmentStep,
MultiSelectStep, MultiSelectStep,
IconInnerShadowTop, IconInnerShadowTop,
IconArrowsDiagonal,
IconArrowsDiagonalMinimize2,
FormCompleted FormCompleted
}, },
provide () {
return {
baseUrl: this.baseUrl,
t: this.t
}
},
props: { props: {
submitter: { submitterSlug: {
type: Object, type: String,
required: true required: true
}, },
canSendEmail: { canSendEmail: {
@@ -353,21 +294,15 @@ export default {
required: false, required: false,
default: false default: false
}, },
submitterUuid: {
type: String,
required: true
},
attachments: { attachments: {
type: Array, type: Array,
required: false, required: false,
default: () => [] default: () => []
}, },
withConfetti: {
type: Boolean,
required: false,
default: false
},
baseUrl: {
type: String,
required: false,
default: ''
},
fields: { fields: {
type: Array, type: Array,
required: false, required: false,
@@ -375,29 +310,13 @@ export default {
}, },
authenticityToken: { authenticityToken: {
type: String, type: String,
required: false, required: true
default: ''
},
backgroundColor: {
type: String,
required: false,
default: ''
}, },
isDirectUpload: { isDirectUpload: {
type: Boolean, type: Boolean,
required: false, required: true,
default: false default: false
}, },
isDemo: {
type: Boolean,
required: false,
default: false
},
attribution: {
type: Boolean,
required: false,
default: true
},
values: { values: {
type: Object, type: Object,
required: false, required: false,
@@ -407,7 +326,6 @@ export default {
data () { data () {
return { return {
isCompleted: false, isCompleted: false,
isFormVisible: true,
currentStep: 0, currentStep: 0,
isSubmitting: false isSubmitting: false
} }
@@ -416,12 +334,6 @@ export default {
currentStepFields () { currentStepFields () {
return this.stepFields[this.currentStep] 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 () { isButtonDisabled () {
return this.isSubmitting || return this.isSubmitting ||
(this.currentField.required && ['image', 'file'].includes(this.currentField.type) && !this.values[this.currentField.uuid]?.length) || (this.currentField.required && ['image', 'file'].includes(this.currentField.type) && !this.values[this.currentField.uuid]?.length) ||
@@ -461,21 +373,13 @@ export default {
) )
if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) { if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
this.$nextTick(() => { document.body.style.overflow = 'hidden'
const root = this.$root.$el.parentNode.getRootNode()
const scrollbox = root.getElementById('scrollbox')
const parent = root.body || root.querySelector('div')
parent.style.overflow = 'hidden' window.scrollbox.classList.add('h-full', 'overflow-y-auto')
window.scrollbox.parentNode.classList.add('h-screen', 'overflow-y-auto')
scrollbox.classList.add('h-full', 'overflow-y-auto')
scrollbox.parentNode.classList.add('h-screen', 'overflow-y-auto')
scrollbox.parentNode.style.maxHeight = '-webkit-fill-available'
})
} }
}, },
methods: { methods: {
t,
goToStep (step, scrollToArea = false, clickUpload = false) { goToStep (step, scrollToArea = false, clickUpload = false) {
this.currentStep = this.stepFields.indexOf(step) this.currentStep = this.stepFields.indexOf(step)
@@ -486,7 +390,7 @@ export default {
this.$refs.form.querySelector('input[type="date"], input[type="text"], select')?.focus() this.$refs.form.querySelector('input[type="date"], input[type="text"], select')?.focus()
if (clickUpload && !this.values[this.currentField.uuid] && ['file', 'image'].includes(this.currentField.type)) { if (clickUpload && !this.values[this.currentField.uuid]) {
this.$refs.form.querySelector('input[type="file"]')?.click() this.$refs.form.querySelector('input[type="file"]')?.click()
} }
}) })
@@ -495,7 +399,7 @@ export default {
if (this.isCompleted) { if (this.isCompleted) {
return Promise.resolve({}) return Promise.resolve({})
} else { } else {
return fetch(this.baseUrl + this.submitPath, { return fetch(this.submitPath, {
method: 'POST', method: 'POST',
body: formData || new FormData(this.$refs.form) body: formData || new FormData(this.$refs.form)
}) })
-152
View File
@@ -1,152 +0,0 @@
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'
}
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'
}
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'
}
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'
}
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'
}
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'
}
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: 'відкритий програмний засіб для документів'
}
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'
}
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 }
+26 -212
View File
@@ -4,63 +4,22 @@
<label <label
class="label text-2xl" class="label text-2xl"
>{{ field.name || 'Signature' }}</label> >{{ field.name || 'Signature' }}</label>
<div class="space-x-2 flex"> <button
<span v-if="modelValue"
class="tooltip" class="btn btn-outline btn-sm"
data-tip="Type text" @click.prevent="remove"
> >
<button <IconReload :width="16" />
id="type_text_button" Redraw
class="btn btn-sm btn-circle" </button>
:class="{ 'btn-neutral': isTextSignature, 'btn-outline': !isTextSignature }" <button
@click.prevent="toggleTextInput" v-else
> class="btn btn-outline btn-sm"
<IconTextSize :width="16" /> @click.prevent="clear"
</button> >
</span> <IconReload :width="16" />
<span Clear
class="tooltip" </button>
data-tip="Take photo"
>
<label
class="btn btn-outline btn-sm btn-circle"
>
<IconCamera :width="16" />
<input
type="file"
hidden
accept="image/*"
@change="drawImage"
>
</label>
</span>
<button
v-if="modelValue"
class="btn btn-outline btn-sm"
@click.prevent="remove"
>
<IconReload :width="16" />
Redraw
</button>
<button
v-else
class="btn btn-outline btn-sm"
@click.prevent="clear"
>
<IconReload :width="16" />
Clear
</button>
<button
title="Minimize"
class="py-1.5 inline md:hidden"
@click.prevent="$emit('minimize')"
>
<IconArrowsDiagonalMinimize2
:width="20"
:height="20"
/>
</button>
</div>
</div> </div>
<input <input
:value="modelValue" :value="modelValue"
@@ -70,38 +29,24 @@
<img <img
v-if="modelValue" v-if="modelValue"
:src="attachmentsIndex[modelValue].url" :src="attachmentsIndex[modelValue].url"
class="mx-auto bg-white border border-base-300 rounded max-h-72" class="w-full bg-white border border-base-300 rounded"
> >
<canvas <canvas
v-show="!modelValue" v-show="!modelValue"
ref="canvas" ref="canvas"
class="bg-white border border-base-300 rounded" class="bg-white border border-base-300 rounded"
/> />
<input
v-if="isTextSignature"
id="signature_text_input"
ref="textInput"
class="base-input !text-2xl w-full mt-6"
:required="field.required"
:placeholder="`Type signature here...`"
type="text"
@input="updateWrittenSignature"
>
</div> </div>
</template> </template>
<script> <script>
import { IconReload, IconCamera, IconTextSize, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue' import { IconReload } from '@tabler/icons-vue'
export default { export default {
name: 'SignatureStep', name: 'SignatureStep',
components: { components: {
IconReload, IconReload
IconCamera,
IconTextSize,
IconArrowsDiagonalMinimize2
}, },
inject: ['baseUrl'],
props: { props: {
field: { field: {
type: Object, type: Object,
@@ -127,11 +72,10 @@ export default {
default: '' default: ''
} }
}, },
emits: ['attached', 'update:model-value', 'start', 'minimize'], emits: ['attached', 'update:model-value'],
data () { data () {
return { return {
isSignatureStarted: false, isSignatureStarted: false
isTextSignature: false
} }
}, },
async mounted () { async mounted () {
@@ -150,8 +94,6 @@ export default {
this.pad.addEventListener('beginStroke', () => { this.pad.addEventListener('beginStroke', () => {
this.isSignatureStarted = true this.isSignatureStarted = true
this.$emit('start')
}) })
}, },
methods: { methods: {
@@ -162,134 +104,6 @@ export default {
this.pad.clear() this.pad.clear()
this.isSignatureStarted = false this.isSignatureStarted = false
if (this.$refs.textInput) {
this.$refs.textInput.value = ''
}
},
updateWrittenSignature (e) {
this.isSignatureStarted = true
const canvas = this.$refs.canvas
const context = canvas.getContext('2d')
const fontFamily = 'Arial'
const fontSize = '44px'
const fontStyle = 'italic'
const fontWeight = ''
context.font = fontStyle + ' ' + fontWeight + ' ' + fontSize + ' ' + fontFamily
context.textAlign = 'center'
context.clearRect(0, 0, canvas.width, canvas.height)
context.fillText(e.target.value, canvas.width / 2, canvas.height / 2 + 11)
},
toggleTextInput () {
this.remove()
this.isTextSignature = !this.isTextSignature
if (this.isTextSignature) {
this.$nextTick(() => {
this.$refs.textInput.focus()
this.$emit('start')
})
}
},
drawImage (event) {
this.remove()
this.isSignatureStarted = true
const file = event.target.files[0]
if (file && file.type.match('image.*')) {
const reader = new FileReader()
reader.onload = (event) => {
const img = new Image()
img.src = event.target.result
img.onload = () => {
const canvas = this.$refs.canvas
const context = canvas.getContext('2d')
const aspectRatio = img.width / img.height
let targetWidth = canvas.width
let targetHeight = canvas.height
if (canvas.width / canvas.height > aspectRatio) {
targetWidth = canvas.height * aspectRatio
} else {
targetHeight = canvas.width / aspectRatio
}
if (targetHeight > targetWidth) {
const scale = targetHeight / targetWidth
targetWidth = targetWidth * scale
targetHeight = targetHeight * scale
}
const x = (canvas.width - targetWidth) / 2
const y = (canvas.height - targetHeight) / 2
context.clearRect(0, 0, canvas.width, canvas.height)
context.drawImage(img, x, y, targetWidth, targetHeight)
this.$emit('start')
}
}
reader.readAsDataURL(file)
}
},
cropCanvasAndExportToPNG (canvas) {
const ctx = canvas.getContext('2d')
const width = canvas.width
const height = canvas.height
let topmost = height
let bottommost = 0
let leftmost = width
let rightmost = 0
const imageData = ctx.getImageData(0, 0, width, height)
const pixels = imageData.data
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const pixelIndex = (y * width + x) * 4
const alpha = pixels[pixelIndex + 3]
if (alpha !== 0) {
topmost = Math.min(topmost, y)
bottommost = Math.max(bottommost, y)
leftmost = Math.min(leftmost, x)
rightmost = Math.max(rightmost, x)
}
}
}
const croppedWidth = rightmost - leftmost + 1
const croppedHeight = bottommost - topmost + 1
const croppedCanvas = document.createElement('canvas')
croppedCanvas.width = croppedWidth
croppedCanvas.height = croppedHeight
const croppedCtx = croppedCanvas.getContext('2d')
croppedCtx.drawImage(canvas, leftmost, topmost, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight)
return new Promise((resolve, reject) => {
croppedCanvas.toBlob((blob) => {
if (blob) {
resolve(blob)
} else {
reject(new Error('Failed to create a PNG blob.'))
}
}, 'image/png')
})
}, },
async submit () { async submit () {
if (this.modelValue) { if (this.modelValue) {
@@ -297,7 +111,7 @@ export default {
} }
return new Promise((resolve) => { return new Promise((resolve) => {
this.cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => { this.$refs.canvas.toBlob(async (blob) => {
const file = new File([blob], 'signature.png', { type: 'image/png' }) const file = new File([blob], 'signature.png', { type: 'image/png' })
if (this.isDirectUpload) { if (this.isDirectUpload) {
@@ -307,7 +121,7 @@ export default {
file, file,
'/direct_uploads' '/direct_uploads'
).create((_error, data) => { ).create((_error, data) => {
fetch(this.baseUrl + '/api/attachments', { fetch('/api/attachments', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
submitter_slug: this.submitterSlug, submitter_slug: this.submitterSlug,
@@ -329,17 +143,17 @@ export default {
formData.append('submitter_slug', this.submitterSlug) formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments') formData.append('name', 'attachments')
return fetch(this.baseUrl + '/api/attachments', { return fetch('/api/attachments', {
method: 'POST', method: 'POST',
body: formData body: formData
}).then((resp) => resp.json()).then((attachment) => { }).then((resp) => resp.json()).then((attachment) => {
this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid) this.$emit('update:model-value', attachment.uuid)
this.$emit('attached', attachment)
return resolve(attachment) return resolve(attachment)
}) })
} }
}) }, 'image/png')
}) })
} }
} }
+15 -17
View File
@@ -65,7 +65,7 @@
@blur="onNameBlur" @blur="onNameBlur"
>{{ field.name || defaultName }}</span> >{{ field.name || defaultName }}</span>
<div <div
v-if="isNameFocus && field.type !== 'checkbox'" v-if="isNameFocus"
class="flex items-center ml-1.5" class="flex items-center ml-1.5"
> >
<input <input
@@ -226,14 +226,14 @@ export default {
} }
}, },
startResizeCell (e) { startResizeCell (e) {
this.$el.getRootNode().addEventListener('mousemove', this.onResizeCell) document.addEventListener('mousemove', this.onResizeCell)
this.$el.getRootNode().addEventListener('mouseup', this.stopResizeCell) document.addEventListener('mouseup', this.stopResizeCell)
this.$emit('start-resize', 'ew') this.$emit('start-resize', 'ew')
}, },
stopResizeCell (e) { stopResizeCell (e) {
this.$el.getRootNode().removeEventListener('mousemove', this.onResizeCell) document.removeEventListener('mousemove', this.onResizeCell)
this.$el.getRootNode().removeEventListener('mouseup', this.stopResizeCell) document.removeEventListener('mouseup', this.stopResizeCell)
this.$emit('stop-resize') this.$emit('stop-resize')
@@ -266,13 +266,11 @@ export default {
}) })
}, },
onNameBlur (e) { onNameBlur (e) {
const text = this.$refs.name.innerText.trim()
this.isNameFocus = false this.isNameFocus = false
this.$refs.name.style.minWidth = '' this.$refs.name.style.minWidth = ''
if (text) { if (e.target.innerText.trim()) {
this.field.name = text this.field.name = e.target.innerText.trim()
} else { } else {
this.field.name = '' this.field.name = ''
this.$refs.name.innerText = this.defaultName this.$refs.name.innerText = this.defaultName
@@ -304,14 +302,14 @@ export default {
this.dragFrom = { x: e.clientX - rect.left, y: e.clientY - rect.top } this.dragFrom = { x: e.clientX - rect.left, y: e.clientY - rect.top }
this.$el.getRootNode().addEventListener('mousemove', this.drag) document.addEventListener('mousemove', this.drag)
this.$el.getRootNode().addEventListener('mouseup', this.stopDrag) document.addEventListener('mouseup', this.stopDrag)
this.$emit('start-drag') this.$emit('start-drag')
}, },
stopDrag () { stopDrag () {
this.$el.getRootNode().removeEventListener('mousemove', this.drag) document.removeEventListener('mousemove', this.drag)
this.$el.getRootNode().removeEventListener('mouseup', this.stopDrag) document.removeEventListener('mouseup', this.stopDrag)
if (this.isDragged) { if (this.isDragged) {
this.save() this.save()
@@ -324,14 +322,14 @@ export default {
startResize () { startResize () {
this.selectedAreaRef.value = this.area this.selectedAreaRef.value = this.area
this.$el.getRootNode().addEventListener('mousemove', this.resize) document.addEventListener('mousemove', this.resize)
this.$el.getRootNode().addEventListener('mouseup', this.stopResize) document.addEventListener('mouseup', this.stopResize)
this.$emit('start-resize', 'nwse') this.$emit('start-resize', 'nwse')
}, },
stopResize () { stopResize () {
this.$el.getRootNode().removeEventListener('mousemove', this.resize) document.removeEventListener('mousemove', this.resize)
this.$el.getRootNode().removeEventListener('mouseup', this.stopResize) document.removeEventListener('mouseup', this.stopResize)
this.$emit('stop-resize') this.$emit('stop-resize')
+55 -172
View File
@@ -1,17 +1,13 @@
<template> <template>
<div <div
style="max-width: 1600px" style="max-width: 1600px"
class="mx-auto pl-4 h-full" class="mx-auto pl-4"
> >
<div class="flex justify-between py-1.5 items-center pr-4"> <div class="flex justify-between py-1.5 items-center pr-4">
<div class="flex space-x-3"> <div class="flex space-x-3">
<a <a href="/">
v-if="withLogoLink"
href="/"
>
<Logo /> <Logo />
</a> </a>
<Logo v-else />
<Contenteditable <Contenteditable
:model-value="template.name" :model-value="template.name"
class="text-3xl font-semibold focus:text-clip" class="text-3xl font-semibold focus:text-clip"
@@ -20,53 +16,46 @@
/> />
</div> </div>
<div class="space-x-3 flex items-center"> <div class="space-x-3 flex items-center">
<slot <a
v-if="$slots.buttons" :href="`/templates/${template.id}/submissions/new`"
name="buttons" data-turbo-frame="modal"
/> class="btn btn-primary"
<template v-else> >
<a <IconUsersPlus
:href="`/templates/${template.id}/submissions/new`" width="20"
data-turbo-frame="modal" class="inline"
class="btn btn-primary" />
> <span class="hidden md:inline">
<IconUsersPlus Recipients
width="20" </span>
class="inline" </a>
/> <button
<span class="hidden md:inline"> class="base-button"
Recipients :class="{ disabled: isSaving }"
</span> v-bind="isSaving ? { disabled: true } : {}"
</a> @click.prevent="onSaveClick"
<button >
class="base-button" <IconInnerShadowTop
:class="{ disabled: isSaving }" v-if="isSaving"
v-bind="isSaving ? { disabled: true } : {}" width="20"
@click.prevent="onSaveClick" class="animate-spin"
> />
<IconInnerShadowTop <IconDeviceFloppy
v-if="isSaving" v-else
width="20" width="20"
class="animate-spin" />
/> <span class="hidden md:inline">
<IconDeviceFloppy Save
v-else </span>
width="20" </button>
/>
<span class="hidden md:inline">
Save
</span>
</button>
</template>
</div> </div>
</div> </div>
<div <div
class="flex" class="flex"
style="max-height: calc(100% - 60px)" style="max-height: calc(100vh - 60px)"
> >
<div <div
ref="previews" 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" class="overflow-y-auto overflow-x-hidden w-52 flex-none pr-3 mt-0.5 pt-0.5 hidden lg:block"
> >
<DocumentPreview <DocumentPreview
@@ -75,19 +64,13 @@
:with-arrows="template.schema.length > 1" :with-arrows="template.schema.length > 1"
:item="item" :item="item"
:document="sortedDocuments[index]" :document="sortedDocuments[index]"
:template="template"
:is-direct-upload="isDirectUpload"
@scroll-to="scrollIntoDocument(item)" @scroll-to="scrollIntoDocument(item)"
@remove="onDocumentRemove" @remove="onDocumentRemove"
@replace="onDocumentReplace"
@up="moveDocument(item, -1)" @up="moveDocument(item, -1)"
@down="moveDocument(item, 1)" @down="moveDocument(item, 1)"
@change="save" @change="save"
/> />
<div <div class="sticky bottom-0 bg-base-100 py-2">
class="sticky bottom-0 py-2"
:class="{ 'bg-base-100': withStickySubmitters }"
>
<Upload <Upload
v-if="sortedDocuments.length" v-if="sortedDocuments.length"
:template-id="template.id" :template-id="template.id"
@@ -108,46 +91,19 @@
@success="updateFromUpload" @success="updateFromUpload"
/> />
<template v-else> <template v-else>
<template <Document
v-for="document in sortedDocuments" v-for="document in sortedDocuments"
:key="document.uuid" :key="document.uuid"
> :ref="setDocumentRefs"
<Document :areas-index="fieldAreasIndex[document.uuid]"
:ref="setDocumentRefs" :selected-submitter="selectedSubmitter"
:areas-index="fieldAreasIndex[document.uuid]" :document="document"
:selected-submitter="selectedSubmitter" :is-drag="!!dragFieldType"
:document="document" :draw-field="drawField"
:is-drag="!!dragFieldType" @draw="onDraw"
:draw-field="drawField" @drop-field="onDropfield"
@draw="onDraw" @remove-area="removeArea"
@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> </template>
</div> </div>
<div <div
@@ -163,7 +119,7 @@
</div> </div>
</div> </div>
<div <div
class="relative w-80 flex-none mt-1 pr-4 pl-0.5 hidden md:block" class="relative w-80 flex-none pt-0.5 pr-4 pl-0.5 hidden md:block"
:class="drawField ? 'overflow-hidden' : 'overflow-auto'" :class="drawField ? 'overflow-hidden' : 'overflow-auto'"
> >
<div <div
@@ -177,7 +133,7 @@
<p> <p>
<button <button
class="base-button" class="base-button"
@click="drawField = null" @click="drawField = false"
> >
Cancel Cancel
</button> </button>
@@ -190,7 +146,6 @@
:fields="template.fields" :fields="template.fields"
:submitters="template.submitters" :submitters="template.submitters"
:selected-submitter="selectedSubmitter" :selected-submitter="selectedSubmitter"
:with-sticky-submitters="withStickySubmitters"
@set-draw="drawField = $event" @set-draw="drawField = $event"
@set-drag="dragFieldType = $event" @set-drag="dragFieldType = $event"
@change-submitter="selectedSubmitter = $event" @change-submitter="selectedSubmitter = $event"
@@ -211,7 +166,6 @@ import Document from './document'
import Logo from './logo' import Logo from './logo'
import Contenteditable from './contenteditable' import Contenteditable from './contenteditable'
import DocumentPreview from './preview' import DocumentPreview from './preview'
import DocumentControls from './controls'
import { IconUsersPlus, IconDeviceFloppy, IconInnerShadowTop } from '@tabler/icons-vue' import { IconUsersPlus, IconDeviceFloppy, IconInnerShadowTop } from '@tabler/icons-vue'
import { v4 } from 'uuid' import { v4 } from 'uuid'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
@@ -225,7 +179,6 @@ export default {
Logo, Logo,
Dropzone, Dropzone,
DocumentPreview, DocumentPreview,
DocumentControls,
IconInnerShadowTop, IconInnerShadowTop,
Contenteditable, Contenteditable,
IconUsersPlus, IconUsersPlus,
@@ -235,8 +188,6 @@ export default {
return { return {
template: this.template, template: this.template,
save: this.save, save: this.save,
baseFetch: this.baseFetch,
backgroundColor: this.backgroundColor,
selectedAreaRef: computed(() => this.selectedAreaRef) selectedAreaRef: computed(() => this.selectedAreaRef)
} }
}, },
@@ -247,39 +198,13 @@ export default {
}, },
isDirectUpload: { isDirectUpload: {
type: Boolean, type: Boolean,
required: false, required: true,
default: false default: false
},
backgroundColor: {
type: String,
required: false,
default: ''
},
baseUrl: {
type: String,
required: false,
default: ''
},
withLogoLink: {
type: Boolean,
required: false,
default: true
},
withStickySubmitters: {
type: Boolean,
required: false,
default: true
},
fetchOptions: {
type: Object,
required: false,
default: () => ({ headers: {} })
} }
}, },
data () { data () {
return { return {
documentRefs: [], documentRefs: [],
isBreakpointLg: false,
isSaving: false, isSaving: false,
selectedSubmitter: null, selectedSubmitter: null,
drawField: null, drawField: null,
@@ -316,28 +241,15 @@ export default {
this.selectedSubmitter = this.template.submitters[0] this.selectedSubmitter = this.template.submitters[0]
}, },
mounted () { mounted () {
this.$nextTick(() => {
this.onWindowResize()
})
document.addEventListener('keyup', this.onKeyUp) document.addEventListener('keyup', this.onKeyUp)
window.addEventListener('resize', this.onWindowResize)
}, },
unmounted () { unmounted () {
document.removeEventListener('keyup', this.onKeyUp) document.removeEventListener('keyup', this.onKeyUp)
window.removeEventListener('resize', this.onWindowResize)
}, },
beforeUpdate () { beforeUpdate () {
this.documentRefs = [] this.documentRefs = []
}, },
methods: { methods: {
onWindowResize (e) {
const breakpointLg = 1024
this.isBreakpointLg = this.$el.getRootNode().querySelector('div').offsetWidth < breakpointLg
},
setDocumentRefs (el) { setDocumentRefs (el) {
if (el) { if (el) {
this.documentRefs.push(el) this.documentRefs.push(el)
@@ -402,7 +314,7 @@ export default {
const field = { const field = {
name: '', name: '',
uuid: v4(), uuid: v4(),
required: type !== 'checkbox', required: true,
type, type,
submitter_uuid: this.selectedSubmitter.uuid, submitter_uuid: this.selectedSubmitter.uuid,
areas: [area] areas: [area]
@@ -421,7 +333,7 @@ export default {
type: this.dragFieldType, type: this.dragFieldType,
uuid: v4(), uuid: v4(),
submitter_uuid: this.selectedSubmitter.uuid, submitter_uuid: this.selectedSubmitter.uuid,
required: this.dragFieldType !== 'checkbox' required: true
} }
if (['select', 'multiple', 'radio'].includes(this.dragFieldType)) { if (['select', 'multiple', 'radio'].includes(this.dragFieldType)) {
@@ -441,7 +353,7 @@ export default {
if (this.selectedField?.type === this.dragFieldType) { if (this.selectedField?.type === this.dragFieldType) {
baseArea = this.selectedAreaRef.value baseArea = this.selectedAreaRef.value
} else if (previousField?.areas?.length) { } else if (previousField?.areas) {
baseArea = previousField.areas[previousField.areas.length - 1] baseArea = previousField.areas[previousField.areas.length - 1]
} else { } else {
if (['checkbox'].includes(this.dragFieldType)) { if (['checkbox'].includes(this.dragFieldType)) {
@@ -505,27 +417,6 @@ export default {
this.template.schema.splice(this.template.schema.indexOf(item), 1) this.template.schema.splice(this.template.schema.indexOf(item), 1)
} }
this.template.fields.forEach((field) => {
[...(field.areas || [])].forEach((area) => {
if (area.attachment_uuid === item.attachment_uuid) {
field.areas.splice(field.areas.indexOf(area), 1)
}
})
})
this.save()
},
onDocumentReplace ({ replaceSchemaItem, schema, documents }) {
this.template.schema.splice(this.template.schema.indexOf(replaceSchemaItem), 1, schema[0])
this.template.documents.push(...documents)
this.template.fields.forEach((field) => {
(field.areas || []).forEach((area) => {
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
area.attachment_uuid = schema[0].attachment_uuid
}
})
})
this.save() this.save()
}, },
moveDocument (item, direction) { moveDocument (item, direction) {
@@ -559,18 +450,10 @@ export default {
this.selectedAreaRef.value = area this.selectedAreaRef.value = area
}, },
baseFetch (path, options = {}) {
return fetch(this.baseUrl + path, {
...options,
headers: { ...this.fetchOptions.headers, ...options.headers }
})
},
save () { save () {
if (this.$el.closest('template-builder')) { this.$el.closest('template-builder').dataset.template = JSON.stringify(this.template)
this.$el.closest('template-builder').dataset.template = JSON.stringify(this.template)
}
return this.baseFetch(`/api/templates/${this.template.id}`, { return fetch(`/api/templates/${this.template.id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({
template: { template: {
@@ -1,91 +0,0 @@
<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>
+2 -12
View File
@@ -61,7 +61,6 @@ export default {
IconCloudUpload, IconCloudUpload,
IconInnerShadowTop IconInnerShadowTop
}, },
inject: ['baseFetch'],
props: { props: {
templateId: { templateId: {
type: [Number, String], type: [Number, String],
@@ -94,21 +93,12 @@ export default {
} }
} }
}, },
mounted () {
if (this.isDirectUpload) {
import('@rails/activestorage')
}
},
methods: { methods: {
upload: Upload.methods.upload, upload: Upload.methods.upload,
onDropFiles (e) { onDropFiles (e) {
if ([...e.dataTransfer.files].every((f) => f.type.match(/(?:image\/)|(?:application\/pdf)/))) { this.$refs.input.files = e.dataTransfer.files
this.$refs.input.files = e.dataTransfer.files
this.upload() this.upload()
} else {
alert('Only PDF and images are supported.')
}
} }
} }
} }
+20 -24
View File
@@ -31,21 +31,19 @@
v-if="isNameFocus" v-if="isNameFocus"
class="flex items-center relative" class="flex items-center relative"
> >
<template v-if="field.type !== 'checkbox'"> <input
<input :id="`required-checkbox-${field.uuid}`"
:id="`required-checkbox-${field.uuid}`" v-model="field.required"
v-model="field.required" type="checkbox"
type="checkbox" class="checkbox checkbox-xs no-animation rounded"
class="checkbox checkbox-xs no-animation rounded" @mousedown.prevent
@mousedown.prevent >
> <label
<label :for="`required-checkbox-${field.uuid}`"
:for="`required-checkbox-${field.uuid}`" class="label text-xs"
class="label text-xs" @click.prevent="field.required = !field.required"
@click.prevent="field.required = !field.required" @mousedown.prevent
@mousedown.prevent >Required</label>
>Required</label>
</template>
</div> </div>
<div <div
v-else v-else
@@ -58,7 +56,7 @@
<label <label
tabindex="0" tabindex="0"
title="Areas" title="Areas"
class="cursor-pointer text-transparent group-hover:text-base-content" class="cursor-pointer text-base-100 group-hover:text-base-content"
> >
<IconShape <IconShape
:width="18" :width="18"
@@ -104,7 +102,7 @@
<button <button
v-else v-else
title="Areas" title="Areas"
class="relative cursor-pointer text-transparent group-hover:text-base-content" class="relative cursor-pointer text-base-100 group-hover:text-base-content"
@click="$emit('set-draw', field)" @click="$emit('set-draw', field)"
> >
<IconShape <IconShape
@@ -113,7 +111,7 @@
/> />
</button> </button>
<button <button
class="relative text-transparent group-hover:text-base-content" class="relative text-base-100 group-hover:text-base-content"
title="Remove" title="Remove"
@click="$emit('remove', field)" @click="$emit('remove', field)"
> >
@@ -122,7 +120,7 @@
:stroke-width="1.6" :stroke-width="1.6"
/> />
</button> </button>
<div class="flex flex-col pr-1 text-transparent group-hover:text-base-content"> <div class="flex flex-col pr-1 text-base-100 group-hover:text-base-content">
<button <button
title="Up" title="Up"
class="relative" class="relative"
@@ -156,7 +154,7 @@
</span> </span>
<input <input
v-model="field.options[index]" v-model="field.options[index]"
class="w-full input input-primary input-xs text-sm bg-transparent" class="w-full input input-primary input-xs text-sm"
type="text" type="text"
required required
@blur="save" @blur="save"
@@ -254,10 +252,8 @@ export default {
}) })
}, },
onNameBlur (e) { onNameBlur (e) {
const text = this.$refs.name.$refs.contenteditable.innerText.trim() if (e.target.innerText.trim()) {
this.field.name = e.target.innerText.trim()
if (text) {
this.field.name = text
} else { } else {
this.field.name = '' this.field.name = ''
this.$refs.name.$refs.contenteditable.innerText = this.defaultName this.$refs.name.$refs.contenteditable.innerText = this.defaultName
+8 -16
View File
@@ -1,9 +1,8 @@
<template> <template>
<div :class="withStickySubmitters ? 'sticky top-0 z-10' : ''"> <div class="sticky -top-1 bg-base-100 pt-1 -mt-1 z-10">
<FieldSubmitter <FieldSubmitter
:model-value="selectedSubmitter.uuid" :model-value="selectedSubmitter.uuid"
class="w-full rounded-lg" class="w-full bg-base-100"
:class="{ 'bg-base-100': withStickySubmitters }"
:submitters="submitters" :submitters="submitters"
@new-submitter="save" @new-submitter="save"
@remove="removeSubmitter" @remove="removeSubmitter"
@@ -29,8 +28,7 @@
v-for="(icon, type) in fieldIcons" v-for="(icon, type) in fieldIcons"
:key="type" :key="type"
draggable="true" draggable="true"
class="flex items-center justify-center border border-dashed border-base-300 w-full rounded relative" class="flex items-center justify-center border border-dashed border-base-300 bg-base-100 w-full rounded relative"
:style="{ backgroundColor: backgroundColor }"
@dragstart="onDragstart(type)" @dragstart="onDragstart(type)"
@dragend="$emit('drag-end')" @dragend="$emit('drag-end')"
@click="addField(type)" @click="addField(type)"
@@ -73,12 +71,12 @@
v-if="fields.length < 4" v-if="fields.length < 4"
class="text-xs p-2 border border-base-200 rounded" class="text-xs p-2 border border-base-200 rounded"
> >
<ul class="list-disc list-outside ml-3"> <ui>
<li> <li>
Draw a text field on the page with a mouse Draw a text field on the page with a mouse
</li> </li>
<li> <li>
Single click on the page to add a checkbox Single click of the page to add a checkbox
</li> </li>
<li> <li>
Drag &amp; drop any other field type on the page Drag &amp; drop any other field type on the page
@@ -86,7 +84,7 @@
<li> <li>
Click on the field type above to add it to the form without drawing it on the document Click on the field type above to add it to the form without drawing it on the document
</li> </li>
</ul> </ui>
</div> </div>
</template> </template>
@@ -102,17 +100,12 @@ export default {
Field, Field,
FieldSubmitter FieldSubmitter
}, },
inject: ['save', 'backgroundColor'], inject: ['save'],
props: { props: {
fields: { fields: {
type: Array, type: Array,
required: true required: true
}, },
withStickySubmitters: {
type: Boolean,
required: false,
default: true
},
submitters: { submitters: {
type: Array, type: Array,
required: true required: true
@@ -178,8 +171,7 @@ export default {
const field = { const field = {
name: '', name: '',
uuid: v4(), uuid: v4(),
required: type !== 'checkbox', required: true,
areas: [],
submitter_uuid: this.selectedSubmitter.uuid, submitter_uuid: this.selectedSubmitter.uuid,
type type
} }
+1 -1
View File
@@ -4,7 +4,7 @@
ref="image" ref="image"
:src="image.url" :src="image.url"
:width="width" :width="width"
class="border rounded mb-4" class="shadow-md mb-4"
:height="height" :height="height"
loading="lazy" loading="lazy"
> >
+29 -59
View File
@@ -9,51 +9,39 @@
loading="lazy" loading="lazy"
> >
<div <div
class="group flex justify-end cursor-pointer top-0 bottom-0 left-0 right-0 absolute p-1" class="group flex justify-end cursor-pointer top-0 bottom-0 left-0 right-0 absolute"
@click="$emit('scroll-to', item)" @click="$emit('scroll-to', item)"
> >
<div class="flex justify-between w-full"> <div
<div style="width: 26px" /> class="flex flex-col justify-between opacity-0 group-hover:opacity-100"
<div class=""> >
<ReplaceButton <div class="rounded-bl rounded-tr bg-white border">
:is-direct-upload="isDirectUpload" <button
:template-id="template.id" class="rounded-bl rounded-tr hover:text-base-100 hover:bg-base-content w-full transition-colors"
class="opacity-0 group-hover:opacity-100" style="width: 24px"
@click.stop @click.stop="$emit('remove', item)"
@success="$emit('replace', { replaceSchemaItem: item, ...$event })" >
/> &times;
</button>
</div> </div>
<div <div
class="flex flex-col justify-between opacity-0 group-hover:opacity-100" v-if="withArrows"
class="flex flex-col border rounded-br rounded-tl bg-white divide-y"
> >
<div> <button
<button class="rounded-tl hover:text-base-100 hover:bg-base-content w-full transition-colors"
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"
style="width: 24px; height: 24px" @click.stop="$emit('up', item)"
@click.stop="$emit('remove', item)"
>
&times;
</button>
</div>
<div
v-if="withArrows"
class="flex flex-col space-y-1"
> >
<button &uarr;
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" </button>
style="width: 24px; height: 24px" <button
@click.stop="$emit('up', item)" class="rounded-br hover:text-base-100 hover:bg-base-content w-full transition-colors"
> style="width: 24px"
&uarr; @click.stop="$emit('down', item)"
</button> >
<button &darr;
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" </button>
style="width: 24px; height: 24px"
@click.stop="$emit('down', item)"
>
&darr;
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -72,52 +60,34 @@
<script> <script>
import Contenteditable from './contenteditable' import Contenteditable from './contenteditable'
import Upload from './upload'
import ReplaceButton from './replace'
export default { export default {
name: 'DocumentPreview', name: 'DocumentPreview',
components: { components: {
Contenteditable, Contenteditable
ReplaceButton
}, },
props: { props: {
item: { item: {
type: Object, type: Object,
required: true required: true
}, },
template: {
type: Object,
required: true
},
document: { document: {
type: Object, type: Object,
required: true required: true
}, },
isDirectUpload: {
type: Boolean,
required: true,
default: false
},
withArrows: { withArrows: {
type: Boolean, type: Boolean,
required: false, required: false,
default: true default: true
} }
}, },
emits: ['scroll-to', 'change', 'remove', 'up', 'down', 'replace'], emits: ['scroll-to', 'change', 'remove', 'up', 'down'],
computed: { computed: {
previewImage () { previewImage () {
return this.document.preview_images[0] return this.document.preview_images[0]
} }
}, },
mounted () {
if (this.isDirectUpload) {
import('@rails/activestorage')
}
},
methods: { methods: {
upload: Upload.methods.upload,
onUpdateName (value) { onUpdateName (value) {
this.item.name = value this.item.name = value
@@ -1,71 +0,0 @@
<template>
<label
:for="inputId"
class="btn btn-neutral btn-xs text-white transition-none"
:class="{ 'opacity-100': isLoading || isProcessing }"
>
{{ message }}
<form
ref="form"
class="hidden"
>
<input
:id="inputId"
ref="input"
name="files[]"
type="file"
accept="image/*, application/pdf"
@change="upload"
>
</form>
</label>
</template>
<script>
import Upload from './upload'
export default {
name: 'ReplaceDocument',
inject: ['baseFetch'],
props: {
templateId: {
type: [Number, String],
required: true
},
isDirectUpload: {
type: Boolean,
required: true,
default: false
}
},
emits: ['success'],
data () {
return {
isLoading: false,
isProcessing: false
}
},
computed: {
inputId () {
return 'el' + Math.random().toString(32).split('.')[1]
},
message () {
if (this.isLoading) {
return 'Uploading...'
} else if (this.isProcessing) {
return 'Processing...'
} else {
return 'Replace'
}
}
},
mounted () {
if (this.isDirectUpload) {
import('@rails/activestorage')
}
},
methods: {
upload: Upload.methods.upload
}
}
</script>
+2 -3
View File
@@ -50,7 +50,6 @@ export default {
IconUpload, IconUpload,
IconInnerShadowTop IconInnerShadowTop
}, },
inject: ['baseFetch'],
props: { props: {
templateId: { templateId: {
type: [Number, String], type: [Number, String],
@@ -114,7 +113,7 @@ export default {
this.isProcessing = true this.isProcessing = true
this.baseFetch(`/api/templates/${this.templateId}/documents`, { fetch(`/api/templates/${this.templateId}/documents`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ blobs }), body: JSON.stringify({ blobs }),
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
@@ -125,7 +124,7 @@ export default {
this.isProcessing = false this.isProcessing = false
}) })
} else { } else {
this.baseFetch(`/api/templates/${this.templateId}/documents`, { fetch(`/api/templates/${this.templateId}/documents`, {
method: 'POST', method: 'POST',
body: new FormData(this.$refs.form) body: new FormData(this.$refs.form)
}).then(resp => resp.json()).then((data) => { }).then(resp => resp.json()).then((data) => {
-24
View File
@@ -1,24 +0,0 @@
# 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,10 +6,6 @@ class ApplicationMailer < ActionMailer::Base
register_interceptor ActionMailerConfigsInterceptor register_interceptor ActionMailerConfigsInterceptor
before_action do
ActiveStorage::Current.url_options = Docuseal.default_url_options
end
def default_url_options def default_url_options
Docuseal.default_url_options Docuseal.default_url_options
end end
-7
View File
@@ -1,7 +0,0 @@
# frozen_string_literal: true
class SettingsMailer < ApplicationMailer
def smtp_successful_setup(email)
mail(to: email, subject: 'SMTP has been configured')
end
end
+6 -47
View File
@@ -3,47 +3,23 @@
class SubmitterMailer < ApplicationMailer class SubmitterMailer < ApplicationMailer
DEFAULT_MESSAGE = %(You have been invited to submit the "%<name>s" form:) DEFAULT_MESSAGE = %(You have been invited to submit the "%<name>s" form:)
def invitation_email(submitter, message: '') def invitation_email(submitter, message: format(DEFAULT_MESSAGE, name: submitter.submission.template.name))
@current_account = submitter.submission.template.account
@submitter = submitter @submitter = submitter
@message = message.presence || format(DEFAULT_MESSAGE, name: submitter.submission.template.name) @message = message
@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, mail(to: @submitter.email,
from: from_address_for_submitter(submitter), subject: 'You have been invited to submit a form')
subject:,
reply_to: submitter.submission.created_by_user&.friendly_name)
end end
def completed_email(submitter, user) def completed_email(submitter, user)
@current_account = submitter.submission.template.account
@submitter = submitter @submitter = submitter
@user = user @user = user
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY) mail(to: user.email,
subject: %(#{submitter.email} has completed the "#{submitter.submission.template.name}" form))
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 end
def documents_copy_email(submitter) def documents_copy_email(submitter)
@current_account = submitter.submission.template.account
@submitter = submitter @submitter = submitter
Submissions::EnsureResultGenerated.call(@submitter) Submissions::EnsureResultGenerated.call(@submitter)
@@ -54,23 +30,6 @@ class SubmitterMailer < ApplicationMailer
attachments[attachment.filename.to_s] = attachment.download attachments[attachment.filename.to_s] = attachment.download
end end
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY) mail(to: submitter.email, subject: 'Your copy of documents')
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
end end
-1
View File
@@ -2,7 +2,6 @@
class UserMailer < ApplicationMailer class UserMailer < ApplicationMailer
def invitation_email(user) def invitation_email(user)
@current_account = user.account
@user = user @user = user
@token = @user.send(:set_reset_password_token) @token = @user.send(:set_reset_password_token)
-39
View File
@@ -1,39 +0,0 @@
# 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,10 +14,7 @@
class Account < ApplicationRecord class Account < ApplicationRecord
has_many :users, dependent: :destroy has_many :users, dependent: :destroy
has_many :encrypted_configs, dependent: :destroy has_many :encrypted_configs, dependent: :destroy
has_many :account_configs, dependent: :destroy
has_many :templates, dependent: :destroy has_many :templates, dependent: :destroy
has_many :submissions, through: :templates
has_many :submitters, through: :submissions
has_many :active_users, -> { active }, dependent: :destroy, has_many :active_users, -> { active }, dependent: :destroy,
inverse_of: :account, class_name: 'User' inverse_of: :account, class_name: 'User'
-60
View File
@@ -1,60 +0,0 @@
# 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.email}} has completed the "{{template.name}}" form',
'body' => "Hi,\n\n" \
"{{submitter.email}} 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
+4 -7
View File
@@ -21,13 +21,10 @@
# fk_rails_... (account_id => accounts.id) # fk_rails_... (account_id => accounts.id)
# #
class EncryptedConfig < ApplicationRecord class EncryptedConfig < ApplicationRecord
CONFIG_KEYS = [ FILES_STORAGE_KEY = 'active_storage'
FILES_STORAGE_KEY = 'active_storage', EMAIL_SMTP_KEY = 'action_mailer_smtp'
EMAIL_SMTP_KEY = 'action_mailer_smtp', ESIGN_CERTS_KEY = 'esign_certs'
ESIGN_CERTS_KEY = 'esign_certs', APP_URL_KEY = 'app_url'
APP_URL_KEY = 'app_url',
WEBHOOK_URL_KEY = 'webhook_url'
].freeze
belongs_to :account belongs_to :account
+6 -28
View File
@@ -4,16 +4,12 @@
# #
# Table name: submissions # Table name: submissions
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# deleted_at :datetime # deleted_at :datetime
# source :text not null # created_at :datetime not null
# template_fields :text # updated_at :datetime not null
# template_schema :text # created_by_user_id :bigint
# template_submitters :text # template_id :bigint not null
# created_at :datetime not null
# updated_at :datetime not null
# created_by_user_id :bigint
# template_id :bigint not null
# #
# Indexes # Indexes
# #
@@ -27,27 +23,9 @@
# #
class Submission < ApplicationRecord class Submission < ApplicationRecord
belongs_to :template belongs_to :template
has_one :account, through: :template
belongs_to :created_by_user, class_name: 'User', optional: true belongs_to :created_by_user, class_name: 'User', optional: true
has_many :submitters, dependent: :destroy has_many :submitters, dependent: :destroy
serialize :template_fields, JSON
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) } scope :active, -> { where(deleted_at: nil) }
enum :source, {
invite: 'invite',
api: 'api',
embed: 'embed',
link: 'link'
}, scope: false, prefix: true
end end
+3 -7
View File
@@ -6,16 +6,14 @@
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# completed_at :datetime # completed_at :datetime
# email :string # email :string not null
# ip :string # ip :string
# name :string
# opened_at :datetime # opened_at :datetime
# phone :string
# sent_at :datetime # sent_at :datetime
# slug :string not null # slug :string not null
# ua :string # ua :string
# uuid :string not null # uuid :string not null
# values :text not null # values :string not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# submission_id :bigint not null # submission_id :bigint not null
@@ -32,11 +30,9 @@
# #
class Submitter < ApplicationRecord class Submitter < ApplicationRecord
belongs_to :submission belongs_to :submission
has_one :template, through: :submission
has_one :account, through: :template
attribute :values, :string, default: -> { {} } attribute :values, :string, default: -> { {} }
attribute :slug, :string, default: -> { SecureRandom.base58(14) } attribute :slug, :string, default: -> { SecureRandom.base58(10) }
serialize :values, JSON serialize :values, JSON
+4 -9
View File
@@ -6,12 +6,11 @@
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# deleted_at :datetime # deleted_at :datetime
# fields :text not null # fields :string not null
# name :string not null # name :string not null
# schema :text not null # schema :string not null
# slug :string not null # slug :string not null
# source :text not null # submitters :string not null
# submitters :text not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# account_id :bigint not null # account_id :bigint not null
@@ -37,8 +36,7 @@ class Template < ApplicationRecord
attribute :fields, :string, default: -> { [] } attribute :fields, :string, default: -> { [] }
attribute :schema, :string, default: -> { [] } attribute :schema, :string, default: -> { [] }
attribute :submitters, :string, default: -> { [{ name: DEFAULT_SUBMITTER_NAME, uuid: SecureRandom.uuid }] } attribute :submitters, :string, default: -> { [{ name: DEFAULT_SUBMITTER_NAME, uuid: SecureRandom.uuid }] }
attribute :slug, :string, default: -> { SecureRandom.base58(14) } attribute :slug, :string, default: -> { SecureRandom.base58(10) }
attribute :source, :string, default: 'native'
serialize :fields, JSON serialize :fields, JSON
serialize :schema, JSON serialize :schema, JSON
@@ -46,9 +44,6 @@ class Template < ApplicationRecord
has_many_attached :documents has_many_attached :documents
has_many :schema_documents, ->(e) { where(uuid: e.schema.pluck('attachment_uuid')) },
class_name: 'ActiveStorage::Attachment', dependent: :destroy, as: :record, inverse_of: :record
has_many :submissions, dependent: :destroy has_many :submissions, dependent: :destroy
scope :active, -> { where(deleted_at: nil) } scope :active, -> { where(deleted_at: nil) }
+10 -29
View File
@@ -11,8 +11,8 @@
# email :string not null # email :string not null
# encrypted_password :string not null # encrypted_password :string not null
# failed_attempts :integer default(0), not null # failed_attempts :integer default(0), not null
# first_name :string # first_name :string not null
# last_name :string # last_name :string not null
# last_sign_in_at :datetime # last_sign_in_at :datetime
# last_sign_in_ip :string # last_sign_in_ip :string
# locked_at :datetime # locked_at :datetime
@@ -22,7 +22,6 @@
# role :string not null # role :string not null
# sign_in_count :integer default(0), not null # sign_in_count :integer default(0), not null
# unlock_token :string # unlock_token :string
# uuid :text not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# account_id :bigint not null # account_id :bigint not null
@@ -33,57 +32,39 @@
# index_users_on_email (email) UNIQUE # index_users_on_email (email) UNIQUE
# index_users_on_reset_password_token (reset_password_token) UNIQUE # index_users_on_reset_password_token (reset_password_token) UNIQUE
# index_users_on_unlock_token (unlock_token) UNIQUE # index_users_on_unlock_token (unlock_token) UNIQUE
# index_users_on_uuid (uuid) UNIQUE
# #
# Foreign Keys # Foreign Keys
# #
# fk_rails_... (account_id => accounts.id) # fk_rails_... (account_id => accounts.id)
# #
class User < ApplicationRecord class User < ApplicationRecord
ROLES = [ ROLES = %w[admin].freeze
ADMIN_ROLE = 'admin'
].freeze
EMAIL_REGEXP = /[^@,\s]+@[^@,\s]+/ EMAIL_REGEXP =
/[a-z0-9][.']?(?:(?:[a-z0-9_-]++[.'])*[a-z0-9_-]++)*@(?:[a-z0-9]++[.-])*[a-z0-9]++\.[a-z]{2,}/i
belongs_to :account 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 :database_authenticatable, :recoverable, :rememberable, :validatable, :trackable
devise :registerable, :omniauthable, omniauth_providers: [:google_oauth2] if Docuseal.multitenant? devise :registerable if Docuseal.multitenant?
attribute :role, :string, default: ADMIN_ROLE attribute :role, :string, default: 'admin'
attribute :uuid, :string, default: -> { SecureRandom.uuid }
scope :active, -> { where(deleted_at: nil) } 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? def active_for_authentication?
!deleted_at? !deleted_at?
end end
def remember_me
true
end
def initials def initials
[first_name&.first, last_name&.first].compact_blank.join.upcase [first_name.first, last_name.first].join.upcase
end end
def full_name def full_name
[first_name, last_name].compact_blank.join(' ') [first_name, last_name].join(' ')
end end
def friendly_name def friendly_name
if full_name.present? "#{full_name} <#{email}>"
"#{full_name} <#{email}>"
else
email
end
end end
end end
+2 -2
View File
@@ -11,13 +11,13 @@
<div class="grid md:grid-cols-2 gap-4"> <div class="grid md:grid-cols-2 gap-4">
<div class="form-control"> <div class="form-control">
<%= ff.label :timezone, class: 'label' %> <%= ff.label :timezone, class: 'label' %>
<%= ff.select :timezone, nil, {}, class: 'base-select' do %> <%= ff.select :timezone, nil, {}, class: 'select base-input w-full font-normal' do %>
<%= time_zone_options_for_select(current_account.timezone) %> <%= time_zone_options_for_select(current_account.timezone) %>
<% end %> <% end %>
</div> </div>
<div class="form-control"> <div class="form-control">
<%= ff.label :locale, 'Time format', class: 'label' %> <%= ff.label :locale, 'Time format', class: 'label' %>
<%= ff.select :locale, options_for_select(controller.class::LOCALE_OPTIONS.invert, current_account.locale), {}, class: 'base-select' %> <%= ff.select :locale, options_for_select(controller.class::LOCALE_OPTIONS.invert, current_account.locale), {}, class: 'select base-input w-full font-normal' %>
</div> </div>
</div> </div>
<% end %> <% end %>
-105
View File
@@ -1,105 +0,0 @@
<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 -2
View File
@@ -1,4 +1,3 @@
<% if Docuseal.demo? %><%= render 'shared/demo_alert' %><% end %>
<% if @templates.any? %> <% if @templates.any? %>
<div class="flex justify-between mb-4 items-center"> <div class="flex justify-between mb-4 items-center">
<h1 class="text-4xl font-bold">Templates</h1> <h1 class="text-4xl font-bold">Templates</h1>
@@ -17,7 +16,7 @@
<div class="pb-6 pt-1 space-y-1"> <div class="pb-6 pt-1 space-y-1">
<p class="flex items-center space-x-1 text-xs text-base-content/60"> <p class="flex items-center space-x-1 text-xs text-base-content/60">
<%= svg_icon('user', class: 'w-4 h-4') %> <%= svg_icon('user', class: 'w-4 h-4') %>
<span><%= template.author.full_name.presence || template.author.email %></span> <span><%= template.author.full_name %></span>
</p> </p>
<p class="flex items-center space-x-1 text-xs text-base-content/60"> <p class="flex items-center space-x-1 text-xs text-base-content/60">
<%= svg_icon('calendar', class: 'w-4 h-4') %> <%= svg_icon('calendar', class: 'w-4 h-4') %>
+7 -10
View File
@@ -1,9 +1,6 @@
<div class="max-w-xl mx-auto px-2"> <div class="max-w-xl mx-auto px-2">
<h1 class="text-3xl font-bold text-center my-8">Profile Details</h1> <h1 class="text-4xl font-bold text-center my-8">Sign up</h1>
<%= form_for('', as: resource_name, html: { class: 'space-y-6' }, data: { turbo: params[:redir].blank? }, url: new_registration_path) do |f| %> <%= form_for('', as: resource_name, html: { class: 'space-y-6' }, url: registration_path) do |f| %>
<% if params[:redir].present? %>
<%= hidden_field_tag :redir, params[:redir] %>
<% end %>
<div class="space-y-2"> <div class="space-y-2">
<%= render 'devise/shared/error_messages', resource: %> <%= render 'devise/shared/error_messages', resource: %>
<%= f.fields_for resource do |ff| %> <%= f.fields_for resource do |ff| %>
@@ -19,7 +16,7 @@
</div> </div>
<% end %> <% end %>
<%= f.fields_for resource do |ff| %> <%= f.fields_for resource do |ff| %>
<div class="form-control <%= 'hidden' if (params[:oauth_callback] || params[:sign_up]) && resource.email? %>"> <div class="form-control">
<%= ff.label :email, class: 'label' %> <%= ff.label :email, class: 'label' %>
<%= ff.email_field :email, required: true, class: 'base-input' %> <%= ff.email_field :email, required: true, class: 'base-input' %>
</div> </div>
@@ -28,14 +25,14 @@
<set-timezone data-input-id="_account_timezone"></set-timezone> <set-timezone data-input-id="_account_timezone"></set-timezone>
<%= ff.hidden_field :timezone %> <%= ff.hidden_field :timezone %>
<div class="form-control"> <div class="form-control">
<%= ff.label :name, 'Company name (optional)', class: 'label' %> <%= ff.label :name, 'Company name', class: 'label' %>
<%= ff.text_field :name, class: 'base-input' %> <%= ff.text_field :name, required: true, class: 'base-input' %>
</div> </div>
<% end %> <% end %>
<%= f.fields_for resource do |ff| %> <%= f.fields_for resource do |ff| %>
<div class="form-control <%= 'hidden' if params[:oauth_callback] %>"> <div class="form-control">
<%= ff.label :password, class: 'label' %> <%= ff.label :password, class: 'label' %>
<%= ff.password_field :password, required: !params[:oauth_callback], class: 'base-input' %> <%= ff.password_field :password, required: true, class: 'base-input' %>
</div> </div>
<% end %> <% end %>
</div> </div>
@@ -1,22 +0,0 @@
<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, 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' %>
<%= f.email_field :email, autofocus: true, autocomplete: 'email', class: 'base-input' %>
</div>
</div>
<div class="form-control">
<%= f.button button_title(title: 'Sign up', disabled_with: 'Sign up'), name: 'sign_up', value: true, class: 'base-button' %>
</div>
<% end %>
<% if devise_mapping.omniauthable? %>
<%= button_to button_title(title: 'Sign up with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), omniauth_authorize_path(resource_name, :google_oauth2), class: 'white-button w-full mt-4', data: { turbo: false }, method: :post %>
<% end %>
<%= render 'devise/shared/links' %>
</div>
+9 -8
View File
@@ -1,10 +1,6 @@
<div class="max-w-xl mx-auto px-2"> <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> <h1 class="text-4xl font-bold text-center my-8">Log In</h1>
<%= form_for(resource, as: resource_name, html: { class: 'space-y-6' }, data: { turbo: params[:redir].blank? }, url: session_path(resource_name)) do |f| %> <%= form_for(resource, as: resource_name, html: { class: 'space-y-6' }, 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="space-y-2">
<div class="form-control"> <div class="form-control">
<%= f.label :email, class: 'label' %> <%= f.label :email, class: 'label' %>
@@ -14,13 +10,18 @@
<%= f.label :password, class: 'label' %> <%= f.label :password, class: 'label' %>
<%= f.password_field :password, autocomplete: 'current-password', class: 'base-input' %> <%= f.password_field :password, autocomplete: 'current-password', class: 'base-input' %>
</div> </div>
<% if devise_mapping.rememberable? %>
<div class="form-control">
<%= f.label :remember_me, class: 'flex items-center cursor-pointer' do %>
<%= f.check_box :remember_me, checked: true, class: 'base-checkbox' %>
<span class="label">Remember me</span>
<% end %>
</div>
<% end %>
</div> </div>
<div class="form-control"> <div class="form-control">
<%= f.button button_title(title: 'Log In', disabled_with: 'Logging In'), class: 'base-button' %> <%= f.button button_title(title: 'Log In', disabled_with: 'Logging In'), class: 'base-button' %>
</div> </div>
<% end %> <% end %>
<% if devise_mapping.omniauthable? %>
<%= button_to button_title(title: 'Log in with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), omniauth_authorize_path(resource_name, :google_oauth2), class: 'white-button w-full mt-4', data: { turbo: false }, method: :post %>
<% end %>
<%= render 'devise/shared/links' %> <%= render 'devise/shared/links' %>
</div> </div>
+6 -1
View File
@@ -3,7 +3,7 @@
<%= link_to 'Log in', new_session_path(resource_name), class: 'link link-hover' %> <%= link_to 'Log in', new_session_path(resource_name), class: 'link link-hover' %>
<% end %> <% end %>
<%- if devise_mapping.registerable? && controller_name != 'registrations' %> <%- if devise_mapping.registerable? && controller_name != 'registrations' %>
<%= link_to 'Create free account', registration_path({ redir: params[:redir] }.compact_blank), class: 'link link-hover' %> <%= link_to 'Sign up', new_registration_path, class: 'link link-hover' %>
<% end %> <% end %>
<%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %> <%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %>
<%= link_to 'Forgot your password?', new_password_path(resource_name), class: 'link link-hover' %> <%= link_to 'Forgot your password?', new_password_path(resource_name), class: 'link link-hover' %>
@@ -14,4 +14,9 @@
<%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %> <%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %>
<%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name), class: 'link link-hover' %> <%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name), class: 'link link-hover' %>
<% end %> <% end %>
<%- if devise_mapping.omniauthable? %>
<%- resource_class.omniauth_providers.each do |provider| %>
<%= button_to "Sign in with #{OmniAuth::Utils.camelize(provider)}", omniauth_authorize_path(resource_name, provider), class: 'link link-hover', data: { turbo: false } %>
<% end %>
<% end %>
</div> </div>
@@ -1,10 +0,0 @@
<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,27 +25,6 @@
<%= ff.password_field :password, value: value['password'], required: true, class: 'base-input' %> <%= ff.password_field :password, value: value['password'], required: true, class: 'base-input' %>
</div> </div>
</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"> <div class="form-control">
<%= ff.label :from_email, 'Send from Email', class: 'label' %> <%= ff.label :from_email, 'Send from Email', class: 'label' %>
<%= ff.email_field :from_email, value: value['from_email'], required: true, class: 'base-input' %> <%= ff.email_field :from_email, value: value['from_email'], required: true, class: 'base-input' %>
Whitespace-only changes.
@@ -29,10 +29,10 @@
</p> </p>
<% if message == 'Signature valid' %> <% if message == 'Signature valid' %>
<p class="flex space-x-1 items-center"> <p class="flex space-x-1 items-center">
<% if trusted_certs.any? { |e| e.public_key.to_der == signature.signature_handler.signer_certificate.public_key.to_der } %> <% if signature.signature_handler.signer_certificate.public_key.to_der == trusted_certs.first.public_key.to_der %>
<%= svg_icon('circle_check', class: 'w-6 h-6 text-green-500') %> <%= svg_icon('circle_check', class: 'w-6 h-6 text-green-500') %>
<span> <span>
Signed with trusted certificate Signed with DocuSeal certificate
</span> </span>
<% else %> <% else %>
<%= svg_icon('x_circle', class: 'w-6 h-6 text-red-500') %> <%= svg_icon('x_circle', class: 'w-6 h-6 text-red-500') %>
@@ -54,7 +54,7 @@
</div> </div>
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1">
<%= svg_icon('certificate', class: 'w-5 h-5 inline') %> <%= svg_icon('certificate', class: 'w-5 h-5 inline') %>
<span><%= signature.signer_name.force_encoding('UTF-8') %></span> <span><%= signature.signer_name %></span>
</div> </div>
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1">
<%= svg_icon('lock_access', class: 'w-5 h-5 inline') %> <%= svg_icon('lock_access', class: 'w-5 h-5 inline') %>
+41
View File
@@ -0,0 +1,41 @@
<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
@@ -1,24 +0,0 @@
<%= 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
@@ -1,99 +0,0 @@
<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>
-16
View File
@@ -1,16 +0,0 @@
<svg width="800" height="800" viewBox="-0.5 0 48 48" version="1.1" xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>">
<g id="Icons" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Color-" transform="translate(-401.000000, -860.000000)">
<g id="Google" transform="translate(401.000000, 860.000000)">
<path d="M9.82727273,24 C9.82727273,22.4757333 10.0804318,21.0144 10.5322727,19.6437333 L2.62345455,13.6042667 C1.08206818,16.7338667 0.213636364,20.2602667 0.213636364,24 C0.213636364,27.7365333 1.081,31.2608 2.62025,34.3882667 L10.5247955,28.3370667 C10.0772273,26.9728 9.82727273,25.5168 9.82727273,24" id="Fill-1" fill="#FBBC05">
</path>
<path d="M23.7136364,10.1333333 C27.025,10.1333333 30.0159091,11.3066667 32.3659091,13.2266667 L39.2022727,6.4 C35.0363636,2.77333333 29.6954545,0.533333333 23.7136364,0.533333333 C14.4268636,0.533333333 6.44540909,5.84426667 2.62345455,13.6042667 L10.5322727,19.6437333 C12.3545909,14.112 17.5491591,10.1333333 23.7136364,10.1333333" id="Fill-2" fill="#EB4335">
</path>
<path d="M23.7136364,37.8666667 C17.5491591,37.8666667 12.3545909,33.888 10.5322727,28.3562667 L2.62345455,34.3946667 C6.44540909,42.1557333 14.4268636,47.4666667 23.7136364,47.4666667 C29.4455,47.4666667 34.9177955,45.4314667 39.0249545,41.6181333 L31.5177727,35.8144 C29.3995682,37.1488 26.7323182,37.8666667 23.7136364,37.8666667" id="Fill-3" fill="#34A853">
</path>
<path d="M46.1454545,24 C46.1454545,22.6133333 45.9318182,21.12 45.6113636,19.7333333 L23.7136364,19.7333333 L23.7136364,28.8 L36.3181818,28.8 C35.6879545,31.8912 33.9724545,34.2677333 31.5177727,35.8144 L39.0249545,41.6181333 C43.3393409,37.6138667 46.1454545,31.6490667 46.1454545,24" id="Fill-4" fill="#4285F4">
</path>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

-6
View File
@@ -1,6 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 470 B

-4
View File
@@ -1,4 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 409 B

-6
View File
@@ -1,6 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 428 B

-6
View File
@@ -1,6 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 394 B

+2 -1
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html data-theme="docuseal" lang="en"> <html data-theme="docuseal">
<head> <head>
<title> <title>
DocuSeal | Open Source Document Filling and Signing DocuSeal | Open Source Document Filling and Signing
@@ -15,6 +15,7 @@
<body> <body>
<turbo-frame id="modal"></turbo-frame> <turbo-frame id="modal"></turbo-frame>
<%= render 'shared/navbar' %> <%= render 'shared/navbar' %>
<% if Docuseal.demo? %><%= render 'shared/demo_alert' %><% end %>
<% if flash.present? %><%= render 'shared/flash' %><% end %> <% if flash.present? %><%= render 'shared/flash' %><% end %>
<div class="max-w-6xl mx-auto px-4 md:px-2 mb-8"> <div class="max-w-6xl mx-auto px-4 md:px-2 mb-8">
<%= yield %> <%= yield %>
+1 -1
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html data-theme="docuseal" lang="en"> <html data-theme="docuseal">
<head> <head>
<title> <title>
DocuSeal | Open Source Document Filling and Signing DocuSeal | Open Source Document Filling and Signing
+6 -1
View File
@@ -8,6 +8,11 @@
</head> </head>
<body> <body>
<%= yield %> <%= yield %>
<%= render partial: 'shared/mailer_attribution' %> <p>
---
</p>
<p>
Sent using <a href="<%= Docuseal::PRODUCT_URL %>"><%= Docuseal::PRODUCT_NAME %></a> documents software.
</p>
</body> </body>
</html> </html>
+1 -1
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html data-theme="docuseal" lang="en"> <html data-theme="docuseal">
<head> <head>
<title> <title>
DocuSeal | Open Source Document Filling and Signing DocuSeal | Open Source Document Filling and Signing
+2 -3
View File
@@ -8,7 +8,7 @@
DocuSeal DocuSeal
</h1> </h1>
</div> </div>
<h2 class="mb-16 text-lg text-center text-gray-600"> <h2 class="mb-16 text-lg text-center text-gray-500">
A self-hosted and open-source web platform that provides secure and efficient digital document signing and processing. A self-hosted and open-source web platform that provides secure and efficient digital document signing and processing.
</h2> </h2>
</div> </div>
@@ -22,7 +22,7 @@
</div> </div>
<h3 class="mb-4 text-2xl font-semibold">Easy to Start</h3> <h3 class="mb-4 text-2xl font-semibold">Easy to Start</h3>
<p class="text-base text-gray-500"> <p class="text-base text-gray-500">
Run on your own host using <a href="https://hub.docker.com/r/docuseal/docuseal" class="link link-neutral font-bold" target="_blank">Docker</a> container, or deploy on your favorite managed PaaS with a single <a href="https://www.docuseal.co/install" class="link link-neutral font-bold">click</a>. Run on your own host using <a href="https://hub.docker.com/r/docuseal/docuseal" class="link link-neutral font-bold" target="_blank">Docker</a> container, or deploy on your favorite managed PaaS with a single <a href="https:///www.docuseal.co/install" class="link link-neutral font-bold">click</a>.
</p> </p>
</div> </div>
</div> </div>
@@ -70,4 +70,3 @@
</div> </div>
</div> </div>
</section> </section>
<%= render 'shared/attribution' %>
@@ -1,28 +0,0 @@
<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>
@@ -1 +0,0 @@
<%= render 'logo_placeholder' %>
@@ -1,11 +0,0 @@
<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>
@@ -1,28 +0,0 @@
<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>
Loaded 100 of 215 files, more files were not shown because too many files have changed in this diff. Show more