Compare commits

..

50 Commits

Author SHA1 Message Date
Pete Matsyburka e6785b0afd fix sign in form without oauth 2024-01-22 09:32:19 +02:00
Pete Matsyburka 009033a411 fix params validation 2024-01-22 09:32:18 +02:00
Alex Turchyn fdc3d7742a fix recipients switch 2024-01-21 18:07:29 +02:00
Alex Turchyn f9ccca1376 add base API error messages 2024-01-21 15:19:16 +02:00
Alex Turchyn 0a00fe6ab8 fix minor mobile UI issues 2024-01-21 13:36:58 +02:00
Pete Matsyburka 6253e80a70 add testing environment 2024-01-21 11:20:34 +02:00
Pete Matsyburka 3dcb7c813f fix rspec 2024-01-19 12:45:43 +02:00
Pete Matsyburka 39cdc41a67 fix erblint 2024-01-19 12:41:36 +02:00
Pete Matsyburka d3000487f4 prevent builder submitter click # nav 2024-01-19 10:15:21 +02:00
Pete Matsyburka 80254da1c1 adjust consider json middleware 2024-01-19 09:54:49 +02:00
Pete Matsyburka 73fc2d92f7 fix annotations 2024-01-19 01:15:13 +02:00
Pete Matsyburka f69aa33f3d i18n builder confirm 2024-01-18 21:12:08 +02:00
Pete Matsyburka e16202c9f7 ajust 2fa message 2024-01-18 10:33:25 +02:00
Pete Matsyburka 7453d0d274 reduce image size 2024-01-17 22:22:50 +02:00
Pete Matsyburka c102828c93 fix manual send button 2024-01-17 13:52:38 +02:00
Alex Turchyn afeb6dd318 optimize submissions show page for mobile devices 2024-01-17 01:45:44 +02:00
Pete Matsyburka 6156623f0e remove welcome message 2024-01-17 01:45:44 +02:00
Pete Matsyburka ccc06fcc3d allow to remove template permanently 2024-01-17 01:45:40 +02:00
Pete Matsyburka bbe3942056 validate sender email 2024-01-16 23:46:41 +02:00
Pete Matsyburka 49cea59b94 do not authenticate archived users 2024-01-16 00:18:58 +02:00
Pete Matsyburka ce1323e849 sign in-person button 2024-01-15 17:43:27 +02:00
Pete Matsyburka fc3c1e65e3 reduce image size 2024-01-15 02:30:10 +02:00
Pete Matsyburka f1ebd8f912 text utils for RTL 2024-01-15 02:22:53 +02:00
Pete Matsyburka 98f5bba832 update pdf processor 2024-01-15 02:22:53 +02:00
Alex Turchyn e864e8a47f process password protected pdf files 2024-01-15 02:22:40 +02:00
Alex Turchyn a573545ead fix right-to-left text direction for legacy Outlook clients 2024-01-15 00:02:44 +02:00
Pete Matsyburka 15ff4386e5 remove via linkk tab if 2+ parties 2024-01-14 14:09:23 +02:00
Pete Matsyburka 7a4aa3bf87 fix success page 2024-01-14 12:49:54 +02:00
Pete Matsyburka c1ba6288c8 audit log toggle in documents email 2024-01-14 12:38:08 +02:00
Pete Matsyburka d0ba180729 add submitter completed redirect url 2024-01-14 12:11:13 +02:00
Pete Matsyburka 2edc76414b do not show archived submission 2024-01-14 11:11:02 +02:00
Pete Matsyburka 452db5dc39 allow to disable audit log PDF attachment 2024-01-14 01:59:13 +02:00
Pete Matsyburka 21c4c53feb update readme docker compose command 2024-01-14 01:11:16 +02:00
Pete Matsyburka 6292789cac return error if template is blank 2024-01-14 00:49:07 +02:00
Pete Matsyburka d445bbc02d fix accepted builder field types 2024-01-14 00:43:14 +02:00
Pete Matsyburka 8627f58cbc add via link recipients tab 2024-01-14 00:43:13 +02:00
Pete Matsyburka 66ab0059bb accept field title and description 2024-01-13 13:52:58 +02:00
Pete Matsyburka 3f06932277 send bcc_completed as multiple to 2024-01-12 23:53:42 +02:00
Pete Matsyburka 59cf1b435e enable_starttls_auto true when using ENV 2024-01-12 22:24:03 +02:00
Pete Matsyburka c9a5b0e66e add submitter slug variable 2024-01-12 18:22:09 +02:00
Pete Matsyburka bcd5e1da98 fix auth token rotate 2024-01-12 09:45:40 +02:00
Pete Matsyburka f74ad050b4 add field types prop 2024-01-10 23:18:30 +02:00
Pete Matsyburka b6f43c9b23 add onSave builder event prop 2024-01-10 17:54:35 +02:00
Pete Matsyburka 298c64a119 fix RTL reverse 2024-01-10 10:28:06 +02:00
Pete Matsyburka 8b80679b3a add bcc completed submission param 2024-01-10 02:06:31 +02:00
Pete Matsyburka 82a2bc561a remove talk to sales checkbox 2024-01-09 11:31:13 +02:00
Pete Matsyburka e019542d3f fix template 2024-01-09 10:49:51 +02:00
Pete Matsyburka 9356247ef0 add signer user forms translations 2024-01-09 01:32:39 +02:00
Pete Matsyburka 4db77c18e3 add Hebrew form translations 2024-01-08 20:18:57 +02:00
Pete Matsyburka 0d0b248988 use arabic letter connector 2024-01-08 19:32:20 +02:00
101 changed files with 1569 additions and 324 deletions
+2 -1
View File
@@ -8,7 +8,8 @@
},
"rules": {
"vue/no-deprecated-html-element-is": 0,
"vue/no-mutating-props": 0
"vue/no-mutating-props": 0,
"vue/no-v-html": 0
},
"parserOptions": {
"ecmaVersion": 2022,
+1 -1
View File
@@ -43,7 +43,7 @@ RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips
COPY ./Gemfile ./Gemfile.lock ./
RUN bundle update --bundler && bundle install && rm -rf ~/.bundle
RUN bundle update --bundler && bundle install && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf
COPY ./bin ./bin
COPY ./app ./app
+3
View File
@@ -4,6 +4,7 @@ source 'https://rubygems.org'
ruby '3.2.2'
gem 'arabic-letter-connector', require: 'arabic-letter-connector/logic'
gem 'aws-sdk-s3', require: false
gem 'azure-storage-blob', require: false
gem 'bootsnap', require: false
@@ -25,6 +26,7 @@ gem 'pagy'
gem 'pdf-reader'
gem 'pg', require: false
gem 'premailer-rails'
gem 'pretender'
gem 'puma'
gem 'rack'
gem 'rails'
@@ -40,6 +42,7 @@ gem 'sidekiq', require: ENV.key?('REDIS_URL')
gem 'sqlite3', require: false
gem 'strip_attributes'
gem 'turbo-rails'
gem 'twitter_cldr', require: false
gem 'tzinfo-data'
group :development, :test do
+12
View File
@@ -82,6 +82,7 @@ GEM
annotate (3.2.0)
activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0)
arabic-letter-connector (0.1.1)
ast (2.4.2)
aws-eventstream (1.3.0)
aws-partitions (1.874.0)
@@ -124,6 +125,7 @@ GEM
bullet (7.1.4)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
camertron-eprun (1.1.1)
cancancan (3.5.0)
capybara (3.39.2)
addressable
@@ -135,6 +137,7 @@ GEM
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
chunky_png (1.4.0)
cldr-plurals-runtime-rb (1.1.0)
cmdparse (3.0.7)
coderay (1.1.3)
concurrent-ruby (1.2.2)
@@ -351,6 +354,8 @@ GEM
actionmailer (>= 3)
net-smtp
premailer (~> 1.7, >= 1.7.9)
pretender (0.5.0)
actionpack (>= 6.1)
pry (0.14.2)
coderay (~> 1.1)
method_source (~> 1.0)
@@ -526,6 +531,10 @@ GEM
actionpack (>= 6.0.0)
activejob (>= 6.0.0)
railties (>= 6.0.0)
twitter_cldr (6.12.0)
camertron-eprun
cldr-plurals-runtime-rb (~> 1.1)
tzinfo
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
tzinfo-data (1.2023.4)
@@ -558,6 +567,7 @@ PLATFORMS
DEPENDENCIES
annotate
arabic-letter-connector
aws-sdk-s3
azure-storage-blob
better_html
@@ -588,6 +598,7 @@ DEPENDENCIES
pdf-reader
pg
premailer-rails
pretender
pry-rails
puma
rack
@@ -610,6 +621,7 @@ DEPENDENCIES
sqlite3
strip_attributes
turbo-rails
twitter_cldr
tzinfo-data
web-console
webmock
+1 -1
View File
@@ -73,7 +73,7 @@ curl https://raw.githubusercontent.com/docusealco/docuseal/master/docker-compose
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):
```sh
HOST=your-domain-name.com docker-compose up
sudo HOST=your-domain-name.com docker compose up
```
## For Businesses
+7 -1
View File
@@ -8,11 +8,17 @@ module Api
DEFAULT_LIMIT = 10
MAX_LIMIT = 100
impersonates :user, with: ->(uuid) { User.find_by(uuid:) }
wrap_parameters false
before_action :authenticate_user!
check_authorization
rescue_from Params::BaseValidator::InvalidParameterError do |e|
render json: { error: e.message }, status: :unprocessable_entity
end
if Rails.env.production?
rescue_from CanCan::AccessDenied do |e|
Rollbar.error(e) if defined?(Rollbar)
@@ -44,7 +50,7 @@ module Api
if request.headers['X-Auth-Token'].present?
sha256 = Digest::SHA256.hexdigest(request.headers['X-Auth-Token'])
User.joins(:access_token).find_by(access_token: { sha256: })
User.joins(:access_token).active.find_by(access_token: { sha256: })
end
render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user
+12 -3
View File
@@ -63,8 +63,16 @@ module Api
end
def create
Params::SubmissionCreateValidator.call(params, dry_run: true)
return render json: { error: 'Template not found' }, status: :unprocessable_entity if @template.nil?
if @template.fields.blank?
Rollbar.error("Template does not contain fields: #{@template.id}") if defined?(Rollbar)
return render json: { error: 'Template does not contain fields' }, status: :unprocessable_entity
end
params[:send_email] = true unless params.key?(:send_email)
params[:send_sms] = false unless params.key?(:send_sms)
@@ -136,12 +144,13 @@ module Api
params.permit(
key => [
[:send_email, :send_sms, {
[:send_email, :send_sms, :bcc_completed, :completed_redirect_url, {
message: %i[subject body],
submitters: [[:send_email, :send_sms, :uuid, :name, :email, :role,
submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role,
:completed, :phone, :application_key,
{ values: {}, readonly_fields: [], message: %i[subject body],
fields: [%i[name default_value readonly validation_pattern invalid_message]] }]]
fields: [%i[name default_value title description
readonly validation_pattern invalid_message]] }]]
}]
]
).fetch(key, [])
+5 -1
View File
@@ -38,7 +38,11 @@ module Api
end
def destroy
@template.update!(archived_at: Time.current)
if params[:permanently] == 'true' && !Docuseal.multitenant?
@template.destroy!
else
@template.update!(archived_at: Time.current)
end
render json: @template.as_json(only: %i[id archived_at])
end
@@ -22,6 +22,8 @@ module Api
}
)
}
rescue Templates::CreateAttachments::PdfEncrypted
render json: { error: 'PDF encrypted' }, status: :unprocessable_entity
end
end
end
+29 -1
View File
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class ApplicationController < ActionController::Base
BROWSER_LOCALE_REGEXP = /\A\w{2}(?:-\w{2})?/
include ActiveStorage::SetCurrent
include Pagy::Backend
@@ -14,6 +16,8 @@ class ApplicationController < ActionController::Base
:current_account,
:svg_icon
impersonates :user, with: ->(uuid) { User.find_by(uuid:) }
rescue_from Pagy::OverflowError do
redirect_to request.path
end
@@ -22,7 +26,7 @@ class ApplicationController < ActionController::Base
rescue_from CanCan::AccessDenied do |e|
Rollbar.error(e) if defined?(Rollbar)
redirect_back(fallback_location: root_path, alert: e.message)
redirect_to root_path, alert: e.message
end
end
@@ -30,8 +34,32 @@ class ApplicationController < ActionController::Base
Docuseal.default_url_options
end
def impersonate_user(user)
raise ArgumentError unless user
raise Pretender::Error unless true_user
@impersonated_user = user
request.session[:impersonated_user_id] = user.uuid
end
private
def with_browser_locale(&)
locale = request.env['HTTP_ACCEPT_LANGUAGE'].to_s[BROWSER_LOCALE_REGEXP].to_s
locale =
if locale.starts_with?('en-') && locale != 'en-US'
'en-GB'
else
locale.split('-').first.presence || 'en-GB'
end
locale = 'en-GB' unless I18n.locale_available?(locale)
I18n.with_locale(locale, &)
end
def sign_in_for_demo
sign_in(User.active.order('random()').take) unless signed_in?
end
@@ -5,9 +5,9 @@ class ConsoleRedirectController < ApplicationController
skip_authorization_check
def index
return redirect_to(new_user_session_path({ redir: params[:redir] }.compact)) if current_user.blank?
return redirect_to(new_user_session_path({ redir: params[:redir] }.compact)) if true_user.blank?
auth = JsonWebToken.encode(uuid: current_user.uuid,
auth = JsonWebToken.encode(uuid: true_user.uuid,
scope: :console,
exp: 1.minute.from_now.to_i)
@@ -7,18 +7,28 @@ class PersonalizationSettingsController < ApplicationController
def create
account_config =
current_account.account_configs.find_or_initialize_by(key: encrypted_config_params[:key])
current_account.account_configs.find_or_initialize_by(key: account_config_params[:key])
authorize!(:create, account_config)
account_config.update!(encrypted_config_params)
account_config.update!(account_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!
def account_config_params
attrs = params.require(:account_config).permit!
attrs[:value]&.transform_values! do |value|
if value.in?(%w[true false])
value == 'true'
else
value
end
end
attrs
end
end
+1
View File
@@ -6,6 +6,7 @@ class StartFormController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
around_action :with_browser_locale, only: %i[show completed]
before_action :load_template
def show
+4 -1
View File
@@ -3,6 +3,7 @@
class SubmitFormController < ApplicationController
layout 'form'
around_action :with_browser_locale, only: %i[show completed success]
skip_before_action :authenticate_user!
skip_authorization_check
@@ -32,7 +33,7 @@ class SubmitFormController < ApplicationController
cookies[:submitter_sid] = @submitter.signed_id
render @submitter.submission.template.archived_at? ? :archived : :show
render(@submitter.submission.template.archived_at? || @submitter.submission.archived_at? ? :archived : :show)
end
def update
@@ -58,4 +59,6 @@ class SubmitFormController < ApplicationController
def completed
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
end
def success; end
end
@@ -11,9 +11,7 @@ class SubmittersSendEmailController < ApplicationController
alert: 'Email has been sent already.')
end
SubmitterMailer.invitation_email(@submitter).deliver_later!
SubmissionEvent.create!(submitter: @submitter, event_type: 'send_email')
SendSubmitterInvitationEmailJob.perform_later('submitter_id' => @submitter.id)
@submitter.sent_at ||= Time.current
@submitter.save!
@@ -10,12 +10,7 @@ class TemplatesUploadsController < ApplicationController
def create
url_params = create_file_params_from_url if params[:url].present?
@template.account = current_account
@template.author = current_user
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
@template.name = File.basename((url_params || params)[:files].first.original_filename, '.*')
@template.save!
save_template!(@template, url_params)
documents = Templates::CreateAttachments.call(@template, url_params || params)
@@ -24,6 +19,8 @@ class TemplatesUploadsController < ApplicationController
@template.update!(schema:)
redirect_to edit_template_path(@template)
rescue Templates::CreateAttachments::PdfEncrypted
render turbo_stream: turbo_stream.append(params[:form_id], html: helpers.tag.prompt_password)
rescue StandardError => e
Rollbar.error(e) if defined?(Rollbar)
@@ -32,6 +29,17 @@ class TemplatesUploadsController < ApplicationController
private
def save_template!(template, url_params)
template.account = current_account
template.author = current_user
template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
template.name = File.basename((url_params || params)[:files].first.original_filename, '.*')
template.save!
template
end
def create_file_params_from_url
tempfile = Tempfile.new
tempfile.binmode
@@ -0,0 +1,20 @@
# frozen_string_literal: true
class TestingAccountsController < ApplicationController
skip_authorization_check only: :destroy
def show
authorize!(:manage, current_account)
authorize!(:manage, current_user)
impersonate_user(Accounts.find_or_create_testing_user(current_account))
redirect_back(fallback_location: root_path)
end
def destroy
stop_impersonating_user
redirect_back(fallback_location: root_path)
end
end
@@ -0,0 +1,12 @@
# frozen_string_literal: true
class TestingApiSettingsController < ApplicationController
def index
authorize!(:manage, current_user.access_token)
@webhook_config =
current_account.encrypted_configs.find_or_initialize_by(key: EncryptedConfig::WEBHOOK_URL_KEY)
authorize!(:manage, @webhook_config)
end
end
+2
View File
@@ -19,6 +19,7 @@ import SubmittersAutocomplete from './elements/submitter_autocomplete'
import FolderAutocomplete from './elements/folder_autocomplete'
import SignatureForm from './elements/signature_form'
import SubmitForm from './elements/submit_form'
import PromptPassword from './elements/prompt_password'
import * as TurboInstantClick from './lib/turbo_instant_click'
@@ -51,6 +52,7 @@ window.customElements.define('submitters-autocomplete', SubmittersAutocomplete)
window.customElements.define('folder-autocomplete', FolderAutocomplete)
window.customElements.define('signature-form', SignatureForm)
window.customElements.define('submit-form', SubmitForm)
window.customElements.define('prompt-password', PromptPassword)
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
document.addEventListener('turbo:submit-end', async (event) => {
@@ -0,0 +1,19 @@
export default class extends HTMLElement {
connectedCallback () {
const input = document.createElement('input')
input.type = 'hidden'
input.name = 'password'
input.value = prompt('Enter PDF password')
this.form.append(input)
this.form.requestSubmit()
this.remove()
}
get form () {
return this.closest('form')
}
}
+1
View File
@@ -19,6 +19,7 @@ window.customElements.define('submission-form', class extends HTMLElement {
withTypedSignature: this.dataset.withTypedSignature !== 'false',
values: reactive(JSON.parse(this.dataset.values)),
completedButton: JSON.parse(this.dataset.completedButton),
completedRedirectUrl: this.dataset.completedRedirectUrl,
attachments: reactive(JSON.parse(this.dataset.attachments)),
fields: JSON.parse(this.dataset.fields)
})
@@ -1,6 +1,7 @@
<template>
<div
v-if="uniqueAreas.length > 1"
dir="auto"
class="mb-2"
>
<div class="flex space-x-1 text-sm ml-1 -mt-3">
@@ -40,7 +41,7 @@ export default {
uniqueAreas () {
const areas = {}
this.field.areas.forEach((area) => {
this.field.areas?.forEach((area) => {
areas[area.attachment_uuid + area.page] ||= area
})
+4 -1
View File
@@ -1,5 +1,8 @@
<template>
<div class="mx-auto max-w-md flex flex-col">
<div
class="mx-auto max-w-md flex flex-col"
dir="auto"
>
<p class="font-medium text-2xl flex items-center space-x-1.5 mx-auto">
<IconCircleCheck
class="inline text-green-600"
+22 -5
View File
@@ -204,7 +204,15 @@
:checked="!!values[field.uuid]"
@click="[scrollIntoField(field), values[field.uuid] = !values[field.uuid]]"
>
<span class="text-xl">
<span
v-if="field.title"
class="text-xl"
v-html="field.title"
/>
<span
v-else
class="text-xl"
>
{{ field.name || field.type + ' ' + (index + 1) }}
</span>
</label>
@@ -324,9 +332,9 @@
v-else
:is-demo="isDemo"
:attribution="attribution"
:completed-button="completedButton"
:with-send-copy-button="withSendCopyButton"
:with-download-button="withDownloadButton"
:completed-button="completedRedirectUrl ? {} : completedButton"
:with-send-copy-button="withSendCopyButton && !completedRedirectUrl"
:with-download-button="withDownloadButton && !completedRedirectUrl"
:with-confetti="withConfetti"
:can-send-email="canSendEmail && !!submitter.email"
:submitter-slug="submitterSlug"
@@ -488,6 +496,11 @@ export default {
required: false,
default: true
},
completedRedirectUrl: {
type: String,
required: false,
default: ''
},
completedButton: {
type: Object,
required: false,
@@ -737,7 +750,7 @@ export default {
stepPromise().then(async () => {
const emptyRequiredField = this.stepFields.find((fields, index) => {
return index < this.currentStep && fields[0].required && (fields[0].type === 'phone' || !this.allowToSkip) && !this.submittedValues[fields[0].uuid]?.length
return index < this.currentStep && fields[0].required && (fields[0].type === 'phone' || !this.allowToSkip) && (this.submittedValues[fields[0].uuid] === true ? false : !this.submittedValues[fields[0].uuid]?.length)
})
const formData = new FormData(this.$refs.form)
@@ -776,6 +789,10 @@ export default {
if (respData) {
this.onComplete(JSON.parse(respData))
}
if (this.completedRedirectUrl) {
window.location.href = this.completedRedirectUrl
}
}
}).catch(error => {
console.error(error)
+58 -10
View File
@@ -1,6 +1,6 @@
const en = {
submit_form: 'Submit Form',
type_here: 'Type here',
type_here_: 'Type here...',
optional: 'optional',
option: 'Option',
appears_on: 'Appears on',
@@ -48,7 +48,7 @@ const en = {
const es = {
submit_form: 'Enviar Formulario',
type_here: 'Escribe aquí',
type_here_: 'Escribe aquí...',
optional: 'opcional',
appears_on: 'Aparece en',
page: 'Página',
@@ -95,7 +95,7 @@ const es = {
const it = {
submit_form: 'Invia Modulo',
type_here: 'Digita qui',
type_here_: 'Digita qui...',
optional: 'opzionale',
appears_on: 'Compare su',
page: 'Pagina',
@@ -142,7 +142,7 @@ const it = {
const de = {
submit_form: 'Formular absenden',
type_here: 'Hier eingeben',
type_here_: 'Hier eingeben...',
optional: 'optional',
appears_on: 'Erscheint auf',
page: 'Seite',
@@ -189,7 +189,7 @@ const de = {
const fr = {
submit_form: 'Envoyer le Formulaire',
type_here: 'Tapez ici',
type_here_: 'Tapez ici...',
optional: 'facultatif',
appears_on: 'Apparaît sur',
page: 'Page',
@@ -236,7 +236,7 @@ const fr = {
const pl = {
submit_form: 'Wyślij Formularz',
type_here: 'Wpisz tutaj',
type_here_: 'Wpisz tutaj...',
optional: 'opcjonalny',
appears_on: 'Pojawia się na',
page: 'Strona',
@@ -283,7 +283,7 @@ const pl = {
const uk = {
submit_form: 'Надіслати Форму',
type_here: 'Введіть тут',
type_here_: 'Введіть тут',
optional: 'необов’язково',
appears_on: "З'являється на",
page: 'Сторінка',
@@ -330,7 +330,7 @@ const uk = {
const cs = {
submit_form: 'Odeslat formulář',
type_here: 'Zadejte zde',
type_here_: 'Zadejte zde',
optional: 'volitelné',
appears_on: 'Zobrazuje se na',
page: 'Stránka',
@@ -377,7 +377,7 @@ const cs = {
const pt = {
submit_form: 'Enviar Formulário',
type_here: 'Digite aqui',
type_here_: 'Digite aqui',
optional: 'opcional',
appears_on: 'Aparece em',
page: 'Página',
@@ -422,7 +422,55 @@ const pt = {
pay_with_strip: 'Pagar com Stripe'
}
const i18n = { en, es, it, de, fr, pl, uk, cs, pt }
const he = {
submit_form: 'שלח טופס',
type_here_: 'הקלד כאן',
optional: 'אופציונלי',
option: 'אפשרות',
appears_on: 'מופיע ב',
page: 'עמוד',
select_your_option: 'בחר את האפשרות שלך',
complete_hightlighted_checkboxes_and_click: 'השלם את תיבות הסימון המודגשות ולחץ',
submit: 'שליחה',
next: 'הבא',
click_to_upload: 'לחץ להעלאה',
or_drag_and_drop_files: 'או גרור ושחרר קבצים',
send_copy_via_email: 'שלח עותק באמצעות אימייל',
download: 'הורדה',
signature: 'חתימה',
initials: 'ציוני ראשי תיבות',
clear: 'נקה',
redraw: 'צייר מחדש',
draw_initials: 'צייר ציוני ראשי תיבות',
type_signature_here: 'הקלד חתימה כאן',
type_initial_here: 'הקלד ציוני ראשי תיבות כאן',
form_has_been_completed: 'הטופס הושלם',
create_a_free_account: 'צור חשבון חינם',
signed_with: 'חתום באמצעות',
please_check_the_box_to_continue: 'אנא סמן את התיבה כדי להמשיך',
open_source_documents_software: 'תוכנה פתוחה למסמכים',
verified_phone_number: 'אימות מספר טלפון',
use_international_format: 'השתמש בפורמט בינלאומי: +1xxx',
six_digits_code: 'קוד משתמש שש ספרות',
change_phone_number: 'שינוי מספר טלפון',
sending: 'שולח',
resend_code: 'שלח מחדש קוד',
verification_code_has_been_resent: 'קוד האימות נשלח מחדש בSMS',
please_fill_all_required_fields: 'אנא מלא את כל השדות הדרושים',
set_today: 'קבע היום',
toggle_multiline_text: 'שנה בין טקסט במספר שורות לטקסט בשורה אחת',
draw_signature: 'צייר חתימה',
type_initial: 'הקלד ציוני ראשי תיבות',
draw: 'צייר',
type: 'הקלד',
type_text: 'הקלד טקסט',
date: 'תאריך',
email_has_been_sent: 'האימייל נשלח',
processing: 'מעבד',
pay_with_strip: 'שלם עם סטרייפ'
}
const i18n = { en, es, it, de, fr, pl, uk, cs, pt, he }
const browserLanguage = (navigator.language || navigator.userLanguage || 'en').split('-')[0]
+12 -4
View File
@@ -3,14 +3,21 @@
v-if="field.name"
:for="field.uuid"
dir="auto"
class="label text-2xl mb-2"
>{{ field.name }}
class="label text-2xl"
:class="{ 'mb-2': !field.description }"
><template v-if="field.title"><span v-html="field.title" /></template>
<template v-else>{{ field.name }}</template>
<template v-if="!field.required">({{ t('optional') }})</template>
</label>
<div
v-else
class="py-1"
/>
<div
v-if="field.description"
class="mb-3 px-1 text-lg"
v-html="field.description"
/>
<AppearsOn :field="field" />
<div class="items-center flex">
<input
@@ -24,7 +31,7 @@
:pattern="field.validation?.pattern"
:oninvalid="field.validation?.message ? `this.setCustomValidity(${JSON.stringify(field.validation.message)})` : ''"
:oninput="field.validation?.message ? `this.setCustomValidity('')` : ''"
:placeholder="`${t('type_here')}...${field.required ? '' : ` (${t('optional')})`}`"
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
type="text"
:name="`values[${field.uuid}]`"
@focus="$emit('focus')"
@@ -34,8 +41,9 @@
:id="field.uuid"
ref="textarea"
v-model="text"
dir="auto"
class="base-textarea !text-2xl w-full"
:placeholder="`${t('type_here')}...${field.required ? '' : ` (${t('optional')})`}`"
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
:required="field.required"
:name="`values[${field.uuid}]`"
@input="resizeTextarea"
+28 -4
View File
@@ -47,7 +47,7 @@
</span>
</a>
<a
:href="`/templates/${template.id}/submissions/new`"
:href="`/templates/${template.id}/submissions/new?with_link=true`"
data-turbo-frame="modal"
class="white-button md:!px-6"
@click="maybeShowEmptyTemplateAlert"
@@ -112,7 +112,7 @@
/>
<div
class="sticky bottom-0 py-2"
:style="withStickySubmitters ? { backgroundColor } : {}"
:style="{ backgroundColor }"
>
<Upload
v-if="sortedDocuments.length && editable && withUploadButton"
@@ -179,6 +179,7 @@
v-if="withUploadButton"
:template-id="template.id"
:is-direct-upload="isDirectUpload"
:accept-file-types="acceptFileTypes"
@success="updateFromUpload"
/>
</div>
@@ -199,6 +200,7 @@
v-if="sortedDocuments.length && !drawField && editable"
:fields="template.fields"
:default-fields="defaultFields"
:field-types="fieldTypes"
:selected-submitter="selectedSubmitter"
@select="startFieldDraw($event)"
/>
@@ -244,6 +246,7 @@
:with-help="withHelp"
:default-submitters="defaultSubmitters"
:default-fields="defaultFields"
:field-types="fieldTypes"
:with-sticky-submitters="withStickySubmitters"
:only-defined-fields="onlyDefinedFields"
:editable="editable"
@@ -299,6 +302,7 @@ export default {
save: this.save,
t: this.t,
baseFetch: this.baseFetch,
fieldTypes: this.fieldTypes,
backgroundColor: this.backgroundColor,
withPhone: this.withPhone,
withPayment: this.withPayment,
@@ -345,6 +349,11 @@ export default {
required: false,
default: () => []
},
fieldTypes: {
type: Array,
required: false,
default: () => []
},
defaultSubmitters: {
type: Array,
required: false,
@@ -372,6 +381,13 @@ export default {
return () => {}
}
},
onSave: {
type: Function,
required: false,
default () {
return () => {}
}
},
withStickySubmitters: {
type: Boolean,
required: false,
@@ -717,7 +733,11 @@ export default {
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
const pageMask = documentRef.pageRefs[area.page].$refs.mask
const type = (pageMask.clientWidth * area.w) < 35 ? 'checkbox' : 'text'
let type = (pageMask.clientWidth * area.w) < 35 ? 'checkbox' : 'text'
if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes(type)) {
type = this.fieldTypes[0]
}
if (type === 'checkbox') {
const previousField = [...this.template.fields].reverse().find((f) => f.type === type)
@@ -864,7 +884,7 @@ export default {
this.save()
},
onDocumentRemove (item) {
if (window.confirm('Are you sure?')) {
if (window.confirm(this.t('are_you_sure'))) {
this.template.schema.splice(this.template.schema.indexOf(item), 1)
}
@@ -967,6 +987,10 @@ export default {
}
}),
headers: { 'Content-Type': 'application/json' }
}).then(() => {
if (this.onSave) {
this.onSave(this.template)
}
})
}
}
@@ -61,7 +61,7 @@
<button
v-if="submitters.length > 1 && editable"
class="px-2"
@click.stop="remove(submitter)"
@click.prevent.stop="remove(submitter)"
>
<IconTrashX :width="18" />
</button>
@@ -165,7 +165,7 @@
title="Up"
class="relative w-2"
style="font-size: 10px; margin-bottom: -4px"
@click.stop="[move(submitter, -1), $refs.label.focus()] "
@click.prevent.stop="[move(submitter, -1), $refs.label.focus()] "
>
</button>
@@ -173,7 +173,7 @@
title="Down"
class="relative w-2"
style="font-size: 10px; margin-top: -4px"
@click.stop="[move(submitter, 1), $refs.label.focus()] "
@click.prevent.stop="[move(submitter, 1), $refs.label.focus()] "
>
</button>
@@ -181,7 +181,7 @@
<button
v-if="!compact && submitters.length > 1 && editable"
class="hidden group-hover:block px-2"
@click.stop="remove(submitter)"
@click.prevent.stop="remove(submitter)"
>
<IconTrashX :width="18" />
</button>
@@ -296,7 +296,7 @@ export default {
this.$emit('update:model-value', submitter.uuid)
},
remove (submitter) {
if (window.confirm('Are you sure?')) {
if (window.confirm(this.t('are_you_sure'))) {
this.$emit('remove', submitter)
}
},
@@ -29,7 +29,7 @@
v-for="(icon, type) in fieldIcons"
:key="type"
>
<li v-if="(withPhone || type != 'phone') && (withPayment || type != 'payment')">
<li v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment')">
<a
href="#"
class="text-sm py-1 px-2"
@@ -54,7 +54,7 @@ import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckb
export default {
name: 'FiledTypeDropdown',
inject: ['withPhone', 'withPayment', 't'],
inject: ['withPhone', 'withPayment', 't', 'fieldTypes'],
props: {
modelValue: {
type: String,
+7 -2
View File
@@ -72,7 +72,7 @@
:key="type"
>
<button
v-if="(withPhone || type != 'phone') && (withPayment || type != 'payment')"
v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment')"
draggable="true"
class="group flex items-center justify-center border border-dashed border-base-300 hover:border-base-content/20 w-full rounded relative"
:style="{ backgroundColor: backgroundColor }"
@@ -91,7 +91,7 @@
</div>
</button>
<div
v-else-if="type == 'phone'"
v-else-if="type == 'phone' && (fieldTypes.length === 0 || fieldTypes.includes(type))"
class="tooltip tooltip-bottom flex"
:class="{'tooltip-bottom-start': !withPayment, 'tooltip-bottom': withPayment }"
data-tip="Unlock SMS-verified phone number field with paid plan. Use text field for phone numbers without verification."
@@ -190,6 +190,11 @@ export default {
required: false,
default: true
},
fieldTypes: {
type: Array,
required: false,
default: () => []
},
submitters: {
type: Array,
required: true
+1
View File
@@ -1,4 +1,5 @@
const en = {
are_you_sure: 'Are you sure?',
sign_yourself: 'Sign Yourself',
send: 'Send',
save: 'Save',
@@ -48,7 +48,7 @@
v-for="(icon, type) in fieldIcons"
:key="type"
>
<li v-if="(withPhone || type != 'phone') && (withPayment || type != 'payment')">
<li v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment')">
<a
href="#"
class="text-sm py-1 px-2"
@@ -94,6 +94,11 @@ export default {
type: Object,
required: true
},
fieldTypes: {
type: Array,
required: false,
default: () => []
},
defaultFields: {
type: Array,
required: false,
+9 -1
View File
@@ -33,7 +33,7 @@
<FieldArea
v-if="newArea"
:is-draw="true"
:field="{ submitter_uuid: selectedSubmitter.uuid, type: drawField?.type || 'text' }"
:field="{ submitter_uuid: selectedSubmitter.uuid, type: drawField?.type || defaultFieldType }"
:area="newArea"
/>
</div>
@@ -60,6 +60,7 @@ export default {
components: {
FieldArea
},
inject: ['fieldTypes'],
props: {
image: {
type: Object,
@@ -115,6 +116,13 @@ export default {
}
},
computed: {
defaultFieldType () {
if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes('text')) {
return this.fieldTypes[0]
} else {
return 'text'
}
},
isMobile () {
return /android|iphone|ipad/i.test(navigator.userAgent)
},
+51 -6
View File
@@ -123,9 +123,30 @@ export default {
method: 'POST',
body: JSON.stringify({ blobs }),
headers: { 'Content-Type': 'application/json' }
}).then(resp => resp.json()).then((data) => {
this.$emit('success', data)
this.$refs.input.value = ''
}).then((resp) => {
if (resp.ok) {
resp.json().then((data) => {
this.$emit('success', data)
this.$refs.input.value = ''
})
} else if (resp.status === 422) {
resp.json().then((data) => {
if (data.error === 'PDF encrypted') {
this.baseFetch(`/api/templates/${this.templateId}/documents`, {
method: 'POST',
body: JSON.stringify({ blobs, password: prompt('Enter PDF password') }),
headers: { 'Content-Type': 'application/json' }
}).then(async (resp) => {
if (resp.ok) {
this.$emit('success', await resp.json())
this.$refs.input.value = ''
} else {
alert('Wrong password')
}
})
}
})
}
}).finally(() => {
this.isProcessing = false
})
@@ -133,9 +154,33 @@ export default {
this.baseFetch(`/api/templates/${this.templateId}/documents`, {
method: 'POST',
body: new FormData(this.$refs.form)
}).then(resp => resp.json()).then((data) => {
this.$emit('success', data)
this.$refs.input.value = ''
}).then((resp) => {
if (resp.ok) {
resp.json().then((data) => {
this.$emit('success', data)
this.$refs.input.value = ''
})
} else if (resp.status === 422) {
resp.json().then((data) => {
if (data.error === 'PDF encrypted') {
const formData = new FormData(this.$refs.form)
formData.append('password', prompt('Enter PDF password'))
this.baseFetch(`/api/templates/${this.templateId}/documents`, {
method: 'POST',
body: formData
}).then(async (resp) => {
if (resp.ok) {
this.$emit('success', await resp.json())
this.$refs.input.value = ''
} else {
alert('Wrong password')
}
})
}
})
}
}).finally(() => {
this.isLoading = false
})
+10 -5
View File
@@ -22,16 +22,21 @@ class ProcessSubmitterCompletionJob < ApplicationJob
end
def enqueue_completed_emails(submitter)
user = submitter.submission.created_by_user || submitter.template.author
submission = submitter.submission
user = submission.created_by_user || submitter.template.author
if submitter.template.account.users.exists?(id: user.id) &&
submitter.submission.preferences['send_email'] != false
submission.preferences['send_email'] != false
SubmitterMailer.completed_email(submitter, user).deliver_later!
bcc = submitter.submission.template.account.account_configs
.find_by(key: AccountConfig::BCC_EMAILS)&.value.presence
bcc = submission.preferences['bcc_completed'].presence ||
submission.template.account.account_configs
.find_by(key: AccountConfig::BCC_EMAILS)&.value.presence
SubmitterMailer.completed_email(submitter, user, to: bcc).deliver_later! if bcc
bcc.to_s.scan(User::EMAIL_REGEXP).each do |to|
SubmitterMailer.completed_email(submitter, user, to:).deliver_later!
end
end
to = build_to_addresses(submitter)
@@ -4,7 +4,11 @@ class SendSubmitterInvitationEmailJob < ApplicationJob
def perform(params = {})
submitter = Submitter.find(params['submitter_id'])
SubmitterMailer.invitation_email(submitter).deliver_now!
mail = SubmitterMailer.invitation_email(submitter)
Submitters::ValidateSending.call(submitter, mail)
mail.deliver_now!
SubmissionEvent.create!(submitter:, event_type: 'send_email')
+9 -5
View File
@@ -43,7 +43,9 @@ class SubmitterMailer < ApplicationMailer
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY)
add_completed_email_attachments!(submitter)
add_completed_email_attachments!(
submitter, with_audit_log: @email_config.nil? || @email_config.value['attach_audit_log'] != false
)
subject =
if @email_config
@@ -65,10 +67,12 @@ class SubmitterMailer < ApplicationMailer
Submissions::EnsureResultGenerated.call(@submitter)
@documents = add_completed_email_attachments!(submitter)
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY)
@documents = add_completed_email_attachments!(
submitter, with_audit_log: @email_config.nil? || @email_config.value['attach_audit_log'] != false
)
subject =
if @email_config
ReplaceEmailVariables.call(@email_config.value['subject'], submitter:)
@@ -83,13 +87,13 @@ class SubmitterMailer < ApplicationMailer
private
def add_completed_email_attachments!(submitter)
def add_completed_email_attachments!(submitter, with_audit_log: true)
documents = Submitters.select_attachments_for_download(submitter)
total_size = 0
audit_trail_data = nil
if submitter.submission.audit_trail.present?
if with_audit_log && submitter.submission.audit_trail.present?
audit_trail_data = submitter.submission.audit_trail.download
total_size = audit_trail_data.size
+1 -1
View File
@@ -25,7 +25,7 @@ class AccessToken < ApplicationRecord
belongs_to :user
before_validation :set_sha256, on: :create
before_validation :set_sha256
attribute :token, :string, default: -> { SecureRandom.base58(TOKEN_LENGTH) }
+14
View File
@@ -22,12 +22,26 @@ class Account < ApplicationRecord
class_name: 'TemplateFolder', dependent: :destroy, inverse_of: :account
has_many :submissions, through: :templates
has_many :submitters, through: :submissions
has_many :account_linked_accounts, dependent: :destroy
has_many :account_testing_accounts, -> { testing }, dependent: :destroy,
class_name: 'AccountLinkedAccount',
inverse_of: :account
has_one :linked_account_account, dependent: :destroy,
foreign_key: :linked_account_id,
class_name: 'AccountLinkedAccount',
inverse_of: :linked_account
has_many :linked_accounts, through: :account_linked_accounts
has_many :testing_accounts, through: :account_testing_accounts, source: :linked_account
has_many :active_users, -> { active }, dependent: :destroy,
inverse_of: :account, class_name: 'User'
attribute :timezone, :string, default: 'UTC'
attribute :locale, :string, default: 'en-US'
def testing?
linked_account_account&.testing?
end
def default_template_folder
super || build_default_template_folder(name: TemplateFolder::DEFAULT_NAME,
author_id: users.minimum(:id)).tap(&:save!)
+36
View File
@@ -0,0 +1,36 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: account_linked_accounts
#
# id :bigint not null, primary key
# account_type :text not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# linked_account_id :bigint not null
#
# Indexes
#
# idx_on_account_id_linked_account_id_48ab9f79d2 (account_id,linked_account_id) UNIQUE
# index_account_linked_accounts_on_account_id (account_id)
# index_account_linked_accounts_on_linked_account_id (linked_account_id)
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (linked_account_id => accounts.id)
#
class AccountLinkedAccount < ApplicationRecord
belongs_to :account
belongs_to :linked_account, class_name: 'Account'
attribute :account_type, :string, default: 'testing'
scope :testing, -> { where(account_type: :testing) }
def testing?
account_type == 'testing'
end
end
+2 -2
View File
@@ -59,7 +59,7 @@ class User < ApplicationRecord
has_many :encrypted_configs, dependent: :destroy, class_name: 'EncryptedUserConfig'
has_many :email_messages, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable
devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable, :lockable
attribute :role, :string, default: ADMIN_ROLE
attribute :uuid, :string, default: -> { SecureRandom.uuid }
@@ -72,7 +72,7 @@ class User < ApplicationRecord
end
def active_for_authentication?
!archived_at?
super && !archived_at?
end
def remember_me
+1 -1
View File
@@ -22,7 +22,7 @@
</div>
<% end %>
<% encrypted_config = @encrypted_config || EncryptedConfig.find_or_initialize_by(account: current_account, key: EncryptedConfig::APP_URL_KEY) %>
<% if !Docuseal.multitenant? && can?(:manage, encrypted_config) %>
<% if !Docuseal.multitenant? && can?(:manage, encrypted_config) && !current_account.testing? %>
<%= f.fields_for encrypted_config do |ff| %>
<div class="form-control">
<%= ff.label :value, 'App URL', class: 'label' %>
+10 -5
View File
@@ -1,14 +1,19 @@
<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="flex justify-between">
<h1 class="text-4xl font-bold mb-4">API</h1>
<%= render 'shared/test_mode_toggle' %>
</div>
<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' %>
<%= button_to button_title(title: 'Rotate', disabled_with: 'Rotate', icon: svg_icon('reload', class: 'w-6 h-6')), settings_api_index_path, class: 'white-button', data: { turbo_confirm: 'Remove existing API token and generated a new one. Are you sure?' } %>
<div class="flex flex-col md:flex-row gap-4">
<div class="flex w-full 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>
<%= button_to button_title(title: 'Rotate', disabled_with: 'Rotate', icon: svg_icon('reload', class: 'w-6 h-6')), settings_api_index_path, class: 'white-button w-full', data: { turbo_confirm: 'Remove existing API token and generated a new one. Are you sure?' } %>
</div>
</div>
</div>
+69 -89
View File
@@ -1,100 +1,80 @@
<% if Docuseal.demo? %><%= render 'shared/demo_alert' %><% end %>
<% if @pagy.count > 0 || params[:q].present? || @template_folders.present? %>
<div class="flex justify-between mb-4 items-center">
<h1 class="text-4xl font-bold"><span class="hidden md:inline">Document</span> Templates</h1>
<div class="flex space-x-2">
<% if params[:q].present? || @pagy.pages > 1 || @template_folders.present? %>
<%= render 'shared/search_input' %>
<div class="flex justify-between mb-4 items-center">
<h1 class="text-4xl font-bold"><span class="hidden md:inline">Document</span> Templates</h1>
<div class="flex space-x-2">
<% if params[:q].present? || @pagy.pages > 1 || @template_folders.present? %>
<%= render 'shared/search_input' %>
<% end %>
<% if can?(:create, ::Template) %>
<%= render 'templates/upload_button' %>
<%= link_to new_template_path, class: 'white-button !border gap-2', data: { turbo_frame: :modal } do %>
<%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %>
<span class="hidden md:block">Create</span>
<% end %>
<% if can?(:create, ::Template) %>
<%= render 'templates/upload_button' %>
<%= link_to new_template_path, class: 'white-button !border gap-2', data: { turbo_frame: :modal } do %>
<%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %>
<span class="hidden md:block">Create</span>
<% end %>
<% end %>
</div>
<% end %>
</div>
<% view_archived_html = capture do %>
<% if current_account.templates.where.not(archived_at: nil).exists? %>
<div>
<a href="<%= templates_archived_index_path %>" class="link text-sm">View Archived</a>
</div>
<% end %>
<% end %>
<% if @template_folders.present? %>
<div class="grid gap-4 md:grid-cols-3 <%= 'mb-6' if @templates.present? %>">
<%= render partial: 'template_folders/folder', collection: @template_folders, as: :folder %>
</div>
<% view_archived_html = capture do %>
<% if current_account.templates.where.not(archived_at: nil).exists? %>
<div>
<a href="<%= templates_archived_index_path %>" class="link text-sm">View Archived</a>
</div>
<% end %>
<% if @templates.present? %>
<div class="grid gap-4 md:grid-cols-3">
<%= render partial: 'templates/template', collection: @templates %>
</div>
<% end %>
<% if params[:q].blank? && @pagy.pages == 1 && ((@template_folders.size < 10 && @templates.size.zero?) || (@template_folders.size < 7 && @templates.size < 4) || (@template_folders.size < 4 && @templates.size < 7)) %>
<%= form_for '', url: templates_upload_path, method: :post, class: 'mt-8 block', html: { enctype: 'multipart/form-data' } do %>
<button type="submit" class="hidden"></button>
<file-dropzone data-submit-on-upload="true" class="w-full">
<label for="file_dropzone_input" class="w-full block h-52 relative hover:bg-base-200/30 rounded-xl border border-2 border-base-300 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" class="flex flex-col items-center">
<span>
<%= svg_icon('cloud_upload', class: 'w-10 h-10') %>
</span>
<div class="font-medium mb-1">
Upload New Document
</div>
<div class="text-xs">
<span class="font-medium">Click to upload</span> or drag and drop
</div>
<% end %>
<% if @template_folders.present? %>
<div class="grid gap-4 md:grid-cols-3 <%= 'mb-6' if @templates.present? %>">
<%= render partial: 'template_folders/folder', collection: @template_folders, as: :folder %>
</div>
<% end %>
<% if @templates.present? %>
<div class="grid gap-4 md:grid-cols-3">
<%= render partial: 'templates/template', collection: @templates %>
</div>
<% end %>
<% if params[:q].blank? && @pagy.pages == 1 && ((@template_folders.size < 10 && @templates.size.zero?) || (@template_folders.size < 7 && @templates.size < 4) || (@template_folders.size < 4 && @templates.size < 7)) %>
<%= form_for '', url: templates_upload_path, id: form_id = SecureRandom.uuid, method: :post, class: 'mt-8 block', html: { enctype: 'multipart/form-data' } do %>
<input type="hidden" name="form_id" value="<%= form_id %>">
<button type="submit" class="hidden"></button>
<file-dropzone data-submit-on-upload="true" class="w-full">
<label for="file_dropzone_input" class="w-full block h-52 relative hover:bg-base-200/30 rounded-xl border border-2 border-base-300 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" class="flex flex-col items-center">
<span>
<%= svg_icon('cloud_upload', class: 'w-10 h-10') %>
</span>
<span data-target="file-dropzone.loading" class="flex flex-col items-center hidden">
<%= svg_icon('loader', class: 'w-10 h-10 animate-spin') %>
<div class="font-medium mb-1">
Uploading...
</div>
</span>
</div>
<input id="file_dropzone_input" name="files[]" class="hidden" data-action="change:file-dropzone#onSelectFiles" data-target="file-dropzone.input" type="file" accept="image/*, application/pdf<%= ', .docx, .doc, .xlsx, .xls' if Docuseal.multitenant? %>" multiple>
</div>
</label>
</file-dropzone>
<% end %>
<% end %>
<% if @templates.present? || params[:q].blank? %>
<% if @pagy.pages > 1 %>
<%= render 'shared/pagination', pagy: @pagy, items_name: 'templates', left_additional_html: view_archived_html %>
<% else %>
<div class="mt-2">
<%= view_archived_html %>
</div>
<% end %>
<% elsif params[:q].present? %>
<div class="text-center">
<div class="mt-16 text-3xl font-semibold">
Templates not Found
</div>
</div>
<% end %>
<% else %>
<div class="card bg-base-200 h-96 mb-2">
<div class="card-body text-center">
<div class="flex items-center h-full">
<div class="mx-auto">
<div class="max-w-xl mx-auto">
<h1 class="text-5xl font-bold text-base-content">👋 Welcome to <%= Docuseal.product_name %></h1>
</div>
<div class="max-w-lg mx-auto">
<p class="py-6 text-gray-600">Streamline document workflows, from creating customizable templates to filling and signing document forms</p>
<%= link_to new_template_path, class: 'base-button', data: { turbo_frame: :modal } do %>
<%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %>
<span class="mr-1">Create Template</span>
<% end %>
<div class="font-medium mb-1">
Upload New Document
</div>
<div class="text-xs">
<span class="font-medium">Click to upload</span> or drag and drop
</div>
</span>
<span data-target="file-dropzone.loading" class="flex flex-col items-center hidden">
<%= svg_icon('loader', class: 'w-10 h-10 animate-spin') %>
<div class="font-medium mb-1">
Uploading...
</div>
</span>
</div>
<input id="file_dropzone_input" name="files[]" class="hidden" data-action="change:file-dropzone#onSelectFiles" data-target="file-dropzone.input" type="file" accept="image/*, application/pdf<%= ', .docx, .doc, .xlsx, .xls' if Docuseal.multitenant? %>" multiple>
</div>
</div>
</label>
</file-dropzone>
<% end %>
<% end %>
<% if @templates.present? || params[:q].blank? %>
<% if @pagy.pages > 1 %>
<%= render 'shared/pagination', pagy: @pagy, items_name: 'templates', left_additional_html: view_archived_html %>
<% else %>
<div class="mt-2">
<%= view_archived_html %>
</div>
<% end %>
<% elsif params[:q].present? %>
<div class="text-center">
<div class="mt-16 text-3xl font-semibold">
Templates not Found
</div>
</div>
<% end %>
@@ -41,13 +41,6 @@
</div>
<div class="form-control">
<%= f.button button_title(title: 'Sign up', disabled_with: 'Signing up'), class: 'base-button' %>
<% if Docuseal.multitenant? %>
<button id="talk_to_sales_button" class="hidden" type="submit" formaction="<%= enquiries_path %>" formnovalidate="true" formmethod="post"></button>
<label class="flex items-center cursor-pointer">
<input type="checkbox" name="talk_to_sales" class="base-checkbox" onchange="window.talk_to_sales_button.click()">
<span class="label">I would like to talk to sales</span>
</label>
<% end %>
</div>
<% end %>
<%= render 'devise/shared/links' %>
+12 -8
View File
@@ -17,15 +17,19 @@
<% end %>
<% if devise_mapping.omniauthable? %>
<div class="space-y-4">
<%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %>
<%= f.button button_title(title: 'Sign up with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), class: 'white-button w-full mt-4' %>
<% if User.omniauth_providers.include?(:google_oauth2) %>
<%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %>
<%= f.button button_title(title: 'Sign up with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), class: 'white-button w-full mt-4' %>
<% end %>
<% end %>
<%= form_for '', url: omniauth_authorize_path(resource_name, :microsoft_office365), data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state_microsoft" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query, id: 'state_microsoft' %>
<%= f.button button_title(title: 'Sign up with Microsoft', icon: svg_icon('brand_microsoft', class: 'w-6 h-6')), class: 'white-button w-full' %>
<% if User.omniauth_providers.include?(:microsoft_office365) %>
<%= form_for '', url: omniauth_authorize_path(resource_name, :microsoft_office365), data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state_microsoft" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query, id: 'state_microsoft' %>
<%= f.button button_title(title: 'Sign up with Microsoft', icon: svg_icon('brand_microsoft', class: 'w-6 h-6')), class: 'white-button w-full' %>
<% end %>
<% end %>
</div>
<% end %>
+12 -8
View File
@@ -21,15 +21,19 @@
<% end %>
<% if devise_mapping.omniauthable? %>
<div class="space-y-4">
<%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %>
<%= f.button button_title(title: 'Sign in with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), class: 'white-button w-full mt-4' %>
<% if User.omniauth_providers.include?(:google_oauth2) %>
<%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %>
<%= f.button button_title(title: 'Sign in with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), class: 'white-button w-full mt-4' %>
<% end %>
<% end %>
<%= form_for '', url: omniauth_authorize_path(resource_name, :microsoft_office365), data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state_microsoft" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query, id: 'state_microsoft' %>
<%= f.button button_title(title: 'Sign in with Microsoft', icon: svg_icon('brand_microsoft', class: 'w-6 h-6')), class: 'white-button w-full' %>
<% if User.omniauth_providers.include?(:microsoft_office365) %>
<%= form_for '', url: omniauth_authorize_path(resource_name, :microsoft_office365), data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state_microsoft" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query, id: 'state_microsoft' %>
<%= f.button button_title(title: 'Sign in with Microsoft', icon: svg_icon('brand_microsoft', class: 'w-6 h-6')), class: 'white-button w-full' %>
<% end %>
<% end %>
</div>
<% end %>
+1 -1
View File
@@ -8,7 +8,7 @@
<% end %>
<div class="space-y-2">
<div class="form-control">
<%= f.label :otp_attempt, 'Two-Factor Code', class: 'label' %>
<%= f.label :otp_attempt, 'Two-Factor Code from Authenticator App', class: 'label' %>
<%= f.text_field :otp_attempt, autofocus: true, placeholder: 'XXX-XXX', required: true, class: 'base-input' %>
</div>
</div>
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4 13h5" />
<path d="M12 16v-8h3a2 2 0 0 1 2 2v1a2 2 0 0 1 -2 2h-3" />
<path d="M20 8v8" />
<path d="M9 16v-5.5a2.5 2.5 0 0 0 -5 0v5.5" />
</svg>

After

Width:  |  Height:  |  Size: 439 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M15 6l-6 6l6 6" />
</svg>

After

Width:  |  Height:  |  Size: 313 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M8.5 13.5l-1.5 -1.5l1.5 -1.5" />
<path d="M15.5 10.5l1.5 1.5l-1.5 1.5" />
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
<path d="M13 9.5l-2 5.5" />
</svg>

After

Width:  |  Height:  |  Size: 458 B

+1 -1
View File
@@ -6,7 +6,7 @@
<style>
</style>
</head>
<body dir="auto">
<body dir="<%= yield&.match?(TextUtils::RTL_REGEXP) ? 'rtl' : 'auto' %>">
<%= yield %>
<%= render partial: 'shared/mailer_attribution' %>
</body>
+1 -1
View File
@@ -3,7 +3,7 @@
Use an authenticator mobile app like Google Authenticator or 1Password to scan the QR code below.
</p>
<div>
<%== RQRCode::QRCode.new(@provision_url).as_svg(viewbox: true, svg_attributes: { class: 'w-80 h-80 my-4 mx-auto' }) %>
<%== RQRCode::QRCode.new(@provision_url).as_svg(viewbox: true, svg_attributes: { class: 'md:w-80 md:h-80 my-4 mx-auto' }) %>
</div>
<div class="form-control my-6 space-y-2">
<%= f.text_field :otp_attempt, required: true, placeholder: 'XXX-XXX', class: 'base-input text-center' %>
@@ -2,7 +2,7 @@
<%= f.hidden_field :key %>
<div class="form-control">
<% record = Struct.new(:first_duration, :second_duration, :third_duration).new(*(f.object.value || {}).values_at('first_duration', 'second_duration', 'third_duration')) %>
<div class="flex space-x-4">
<div class="flex flex-col md:flex-row gap-4">
<div class="w-full">
<%= f.fields_for :value, record do |ff| %>
<%= ff.label :first_duration, 'First reminder in', class: 'label' %>
@@ -8,7 +8,7 @@
<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| %>
<%= f.fields_for :value, Struct.new(:subject, :body, :attach_audit_log).new(*f.object.value.values_at('subject', 'body', 'attach_audit_log')) do |ff| %>
<div class="form-control">
<%= ff.label :subject, class: 'label' %>
<%= ff.text_field :subject, required: true, class: 'base-input', dir: 'auto' %>
@@ -24,6 +24,12 @@
<%= ff.text_area :body, required: true, class: 'base-input w-full py-2', dir: 'auto' %>
</autoresize-textarea>
</div>
<div class="flex items-center justify-between py-2.5 mx-1">
<span>
Attach audit log PDF
</span>
<%= ff.check_box :attach_audit_log, { checked: ff.object.attach_audit_log != false, class: 'toggle' }, 'true', 'false' %>
</div>
<% end %>
<div class="form-control pt-2">
<%= f.button button_title(title: 'Save', disabled_with: 'Saving'), class: 'base-button' %>
@@ -8,7 +8,7 @@
<div class="collapse-content">
<%= form_for AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY), url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
<%= f.hidden_field :key %>
<%= f.fields_for :value, Struct.new(:subject, :body).new(*f.object.value.values_at('subject', 'body')) do |ff| %>
<%= f.fields_for :value, Struct.new(:subject, :body, :attach_audit_log).new(*f.object.value.values_at('subject', 'body', 'attach_audit_log')) do |ff| %>
<div class="form-control">
<div class="flex items-center">
<%= ff.label :subject, class: 'label' %>
@@ -29,6 +29,12 @@
<%= ff.text_area :body, required: true, class: 'base-input w-full py-2', dir: 'auto' %>
</autoresize-textarea>
</div>
<div class="flex items-center justify-between py-2.5 mx-1">
<span>
Attach audit log PDF
</span>
<%= ff.check_box :attach_audit_log, { checked: ff.object.attach_audit_log != false, class: 'toggle' }, 'true', 'false' %>
</div>
<% end %>
<div class="form-control pt-2">
<%= f.button button_title(title: 'Save', disabled_with: 'Saving'), class: 'base-button' %>
@@ -1,4 +1,8 @@
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CONSOLE_URL}/on_premise", class: 'hidden md:inline-flex btn btn-warning btn-sm', data: { prefetch: false } do %>
Upgrade
<% end %>
<% if signed_in? && current_user != true_user %>
<span class="hidden md:inline-flex h-3 border-r border-base-content"></span>
<%= render 'shared/test_alert' %>
<% end %>
<span class="hidden md:inline-flex h-3 border-r border-base-content"></span>
+17 -5
View File
@@ -11,17 +11,17 @@
<%= link_to 'Account', settings_account_path, class: 'text-base hover:bg-base-300' %>
</li>
<% unless Docuseal.multitenant? %>
<% if can?(:read, EncryptedConfig.new(key: EncryptedConfig::EMAIL_SMTP_KEY, account: current_account)) && ENV['SMTP_ADDRESS'].blank? %>
<% if can?(:read, EncryptedConfig.new(key: EncryptedConfig::EMAIL_SMTP_KEY, account: current_account)) && ENV['SMTP_ADDRESS'].blank? && true_user == current_user %>
<li>
<%= link_to 'Email', settings_email_index_path, class: 'text-base hover:bg-base-300' %>
</li>
<% end %>
<% if can?(:read, EncryptedConfig.new(key: EncryptedConfig::FILES_STORAGE_KEY, account: current_account)) %>
<% if can?(:read, EncryptedConfig.new(key: EncryptedConfig::FILES_STORAGE_KEY, account: current_account)) && true_user == current_user %>
<li>
<%= link_to 'Storage', settings_storage_index_path, class: 'text-base hover:bg-base-300' %>
</li>
<% end %>
<% if can?(:read, EncryptedConfig.new(key: 'submitter_invitation_sms', account: current_account)) %>
<% if can?(:read, EncryptedConfig.new(key: 'submitter_invitation_sms', account: current_account)) && true_user == current_user %>
<li>
<%= link_to 'SMS', settings_sms_path, class: 'text-base hover:bg-base-300' %>
</li>
@@ -43,7 +43,7 @@
</li>
<% end %>
<% unless Docuseal.multitenant? %>
<% if can?(:read, EncryptedConfig.new(key: 'saml_configs', account: current_account)) %>
<% if can?(:read, EncryptedConfig.new(key: 'saml_configs', account: current_account)) && true_user == current_user %>
<li>
<%= link_to 'SSO', settings_sso_index_path, class: 'text-base hover:bg-base-300' %>
</li>
@@ -88,10 +88,22 @@
<% end %>
</li>
<% end %>
<% if (can?(:manage, EncryptedConfig) && current_user == true_user) || (current_user != true_user && current_account.testing?) %>
<li>
<%= form_for '', url: testing_account_path, method: current_account.testing? ? :delete : :get, html: { class: 'flex w-full' } do |f| %>
<label class="flex items-center text-base hover:bg-base-300 w-full justify-between" for="testing_toggle">
<span class="mr-2 w-full">
Test Environment
</span>
<%= f.check_box :testing_toggle, class: 'toggle toggle-sm', checked: current_account.testing?, onchange: 'this.form.requestSubmit()' %>
</label>
<% end %>
</li>
<% end %>
<% end %>
</ul>
</menu-active>
<% if !can?(:manage, :tenants) %>
<% if Docuseal.multitenant? || cannot?(:manage, :tenants) %>
<div class="mx-4 border-t border-base-300 hidden md:block">
<div class="text-sm mt-3">
Need help? Ask a question:
+17
View File
@@ -0,0 +1,17 @@
<% if signed_in? && current_user != true_user && current_account.testing? %>
<div class="alert py-1 text-sm font-medium gap-x-2 flex whitespace-nowrap">
<a href="<%= testing_api_settings_path %>" data-turbo-frame="modal" class="link font-semibold flex">
<%= svg_icon('code_circle', class: 'w-5 h-5 mr-1') %>Testing Environment
</a>
<span>
|
</span>
<%= button_to testing_account_path, method: :delete, class: 'inline flex' do %>
<% title = capture do %>
<span class="link">Exit</span>
<span>&times;</span>
<% end %>
<%= button_title(title:, disabled_with: 'Leave') %>
<% end %>
</div>
<% end %>
@@ -0,0 +1,10 @@
<% if can?(:manage, EncryptedConfig) || (current_user != true_user && current_account.testing?) %>
<%= form_for '', url: testing_account_path, method: current_account.testing? ? :delete : :get, html: { class: 'flex' } do |f| %>
<label class="flex items-center justify-between py-2.5" for="testing_toggle">
<span class="mr-2 text-lg">
Test Environment
</span>
<%= f.check_box :testing_toggle, class: 'toggle', checked: current_account.testing?, onchange: 'this.form.requestSubmit()' %>
</label>
<% end %>
<% end %>
+6 -6
View File
@@ -10,23 +10,23 @@
<%= svg_icon('writing_sign', class: 'w-10 h-10') %>
</div>
<div>
<p class="text-lg font-bold mb-1"><%= @submitter.submission.template.name %></p>
<p class="text-sm">Signed on <%= l(@submitter.completed_at.to_date, format: :long, locale: @submitter.submission.template.account.locale) %></p>
<p dir="auto" class="text-lg font-bold mb-1"><%= @submitter.submission.template.name %></p>
<p dir="auto" class="text-sm"><%= t('signed_on_time', time: l(@submitter.completed_at.to_date, format: :long)) %></p>
</div>
</div>
</div>
</div>
<div class="text-center text-2xl font-semibold">
Form has been submitted already
<div dir="auto" class="text-center text-2xl font-semibold">
<%= t('form_has_been_submitted_already') %>
</div>
<% if Accounts.can_send_emails?(@submitter.submission.template.account) %>
<div>
<%= button_to button_title(title: 'Send copy to Email', disabled_with: 'Sending', icon: svg_icon('mail_forward', class: 'w-6 h-6')), send_submission_email_index_path, params: { submitter_slug: @submitter.slug }, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'base-button w-full' %>
<%= button_to button_title(title: t('send_copy_to_email'), disabled_with: t('sending'), icon: svg_icon('mail_forward', class: 'w-6 h-6')), send_submission_email_index_path, params: { submitter_slug: @submitter.slug }, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'base-button w-full' %>
</div>
<% end %>
<% if @template.submitters.to_a.size == 1 %>
<div>
<%= button_to button_title(title: 'Resubmit', disabled_with: 'Resubmit', icon: svg_icon('reload', class: 'w-6 h-6')), start_form_path(@template.slug), params: { submitter: { email: params[:email] }, resubmit: @submitter.slug }, method: :put, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'white-button w-full' %>
<%= button_to button_title(title: t('resubmit'), disabled_with: t('resubmit'), icon: svg_icon('reload', class: 'w-6 h-6')), start_form_path(@template.slug), params: { submitter: { email: params[:email] }, resubmit: @submitter.slug }, method: :put, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'white-button w-full' %>
</div>
<% end %>
</div>
+8 -8
View File
@@ -4,7 +4,7 @@
<div class="text-center w-full space-y-6">
<%= render 'banner' %>
<% unless @template.archived_at? %>
<p class="text-xl font-semibold text-center">You have been invited to submit a form</p>
<p class="text-xl font-semibold text-center"><%= t('you_have_been_invited_to_submit_a_form') %></p>
<% end %>
</div>
<div class="flex items-center bg-base-200 rounded-xl p-4 mb-4">
@@ -15,9 +15,9 @@
<div>
<p class="text-lg font-bold mb-1"><%= @template.name %></p>
<% if @template.archived_at? %>
<p class="text-sm">Form has been deleted by <span class="font-semibold"><%= @template.account.name %></span>.</p>
<p dir="auto" class="text-sm"><%= t('form_has_been_deleted_by_html', name: @template.account.name) %></p>
<% else %>
<p class="text-sm">Invited by <span class="font-semibold"><%= @template.account.name %></span></p>
<p dir="auto" class="text-sm"><%= t('invited_by_html', name: @template.account.name) %></p>
<% end %>
</div>
</div>
@@ -25,13 +25,13 @@
</div>
<% unless @template.archived_at? %>
<%= form_for @submitter, url: start_form_path(@template.slug), data: { turbo_frame: :_top }, method: :put, html: { class: 'space-y-4', onsubmit: 'event.submitter.disabled = true' } do |f| %>
<div class="form-control !mt-0">
<%= f.label :email, class: 'label' %>
<%= f.email_field :email, value: current_user&.email || params[:email] || @submitter.email, required: true, class: 'base-input', placeholder: 'Provide your email to start' %>
<div dir="auto" class="form-control !mt-0">
<%= f.label :email, t('email'), class: 'label' %>
<%= f.email_field :email, value: current_user&.email || params[:email] || @submitter.email, required: true, class: 'base-input', placeholder: t('provide_your_email_to_start') %>
<%= @error_message %>
</div>
<div class="form-control">
<%= f.button button_title(title: 'Start', disabled_with: 'Starting'), class: 'base-button' %>
<div dir="auto" class="form-control">
<%= f.button button_title(title: t('start'), disabled_with: t('starting')), class: 'base-button' %>
</div>
<% end %>
<% end %>
+187
View File
@@ -0,0 +1,187 @@
<div>
<div class="">
<input id="api_key" type="text" value="<%= start_form_url(slug: template.slug) %>" class="input font-mono input-bordered w-full bg-white" autocomplete="off" readonly>
<div class="mt-4">
<%= render 'shared/clipboard_copy', icon: 'copy', text: start_form_url(slug: template.slug), class: 'base-button w-full block', icon_class: 'w-6 h-6 text-white', copy_title: 'Copy', copied_title: 'Copied' %>
</div>
</div>
<div class="divider uppercase normal-case mt-5 mb-6 text-neutral-700">Or embed on your website</div>
<toggle-visible data-element-ids="[&quot;js_1&quot;,&quot;react_1&quot;,&quot;vue_1&quot;]" class="block relative" data-catalyst="">
<ul class="items-center w-full text-sm font-medium text-gray-900 my-4 space-y-2 sm:space-y-0 sm:flex sm:space-x-2">
<li class="w-full h-10 text-sm font-medium flex items-center relative group py-3.5">
<input type="radio" name="option_1" id="js_radio_1" value="js_1" data-action="change:toggle-visible#trigger" class="relative peer z-10 hidden" checked="checked">
<label for="js_radio_1" class="absolute border-neutral-focus space-x-2 border rounded-xl left-0 right-0 top-0 bottom-0 flex items-center justify-center group-hover:bg-neutral group-hover:text-white peer-checked:btn-neutral">
<span><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" x="0px" y="0px" width="100" height="100" viewBox="0 0 48 48">
<path fill="#ffd600" d="M6,42V6h36v36H6z"></path><path fill="#000001" d="M29.538 32.947c.692 1.124 1.444 2.201 3.037 2.201 1.338 0 2.04-.665 2.04-1.585 0-1.101-.726-1.492-2.198-2.133l-.807-.344c-2.329-.988-3.878-2.226-3.878-4.841 0-2.41 1.845-4.244 4.728-4.244 2.053 0 3.528.711 4.592 2.573l-2.514 1.607c-.553-.988-1.151-1.377-2.078-1.377-.946 0-1.545.597-1.545 1.377 0 .964.6 1.354 1.985 1.951l.807.344C36.452 29.645 38 30.839 38 33.523 38 36.415 35.716 38 32.65 38c-2.999 0-4.702-1.505-5.65-3.368L29.538 32.947zM17.952 33.029c.506.906 1.275 1.603 2.381 1.603 1.058 0 1.667-.418 1.667-2.043V22h3.333v11.101c0 3.367-1.953 4.899-4.805 4.899-2.577 0-4.437-1.746-5.195-3.368L17.952 33.029z"></path>
</svg>
</span>
<span>JavaScript</span>
</label>
</li>
<li class="w-full h-10 text-sm font-medium flex items-center relative group py-3.5">
<input type="radio" name="option_1" id="react_radio_1" value="react_1" data-action="change:toggle-visible#trigger" class="relative peer z-10 hidden">
<label for="react_radio_1" class="absolute border-neutral-focus space-x-2 border rounded-xl left-0 right-0 top-0 bottom-0 flex items-center justify-center group-hover:bg-neutral group-hover:text-white peer-checked:btn-neutral">
<span><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="-11.5 -10.23174 23 20.46348">
<circle cx="0" cy="0" r="2.05" fill="#61dafb"></circle>
<g stroke="#61dafb" stroke-width="1" fill="none">
<ellipse rx="11" ry="4.2"></ellipse>
<ellipse rx="11" ry="4.2" transform="rotate(60)"></ellipse>
<ellipse rx="11" ry="4.2" transform="rotate(120)"></ellipse>
</g>
</svg>
</span>
<span>React</span>
</label>
</li>
<li class="w-full h-10 text-sm font-medium flex items-center relative group py-3.5">
<input type="radio" name="option_1" id="vue_radio_1" value="vue_1" data-action="change:toggle-visible#trigger" class="relative peer z-10 hidden">
<label for="vue_radio_1" class="absolute border-neutral-focus space-x-2 border rounded-xl left-0 right-0 top-0 bottom-0 flex items-center justify-center group-hover:bg-neutral group-hover:text-white peer-checked:btn-neutral">
<span><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" version="1.1" viewBox="0 0 261.76 226.69"><g transform="matrix(1.3333 0 0 -1.3333 -76.311 313.34)"><g transform="translate(178.06 235.01)"><path d="m0 0-22.669-39.264-22.669 39.264h-75.491l98.16-170.02 98.16 170.02z" fill="#41b883"></path></g><g transform="translate(178.06 235.01)"><path d="m0 0-22.669-39.264-22.669 39.264h-36.227l58.896-102.01 58.896 102.01z" fill="#34495e"></path></g></g></svg>
</span>
<span>Vue</span>
</label>
</li>
</ul>
</toggle-visible>
<div id="js_1" class="block my-4">
<div class="mockup-code overflow-hidden pb-0 mt-4">
<span class="top-0 right-0 absolute flex">
<%= link_to 'Learn More', console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/embedding/form"), target: '_blank', data: { turbo: false }, class: 'btn btn-ghost text-gray-100 flex', rel: 'noopener' %>
<clipboard-copy data-text="<script src=&quot;<%= Docuseal::CDN_URL %>/js/form.js&quot;></script>
<docuseal-form data-src=&quot;<%= start_form_url(slug: template.slug) %>&quot;></docuseal-form>
">
<label class="btn btn-ghost text-gray-100">
<input type="radio" class="peer hidden">
<span class="peer-checked:hidden flex items-center space-x-2">
<%= svg_icon('copy', class: 'w-6 h-6 text-white') %>
<span class="hidden md:inline">
Copy
</span>
</span>
<span class="hidden peer-checked:flex items-center space-x-2">
<%= svg_icon('clipboard_copy', class: 'w-6 h-6 text-white') %>
<span class="hidden md:inline">
Copied
</span>
</span>
</label>
</clipboard-copy>
</span>
<pre class="before:!m-0 pl-6 pb-4 overflow-auto"><code class="overflow-hidden w-full"><span style="color: #f4bf75">&lt;script </span><span style="color: #6a9fb5">src=</span><span style="color: #90a959">"<%= Docuseal::CDN_URL %>/js/form.js"</span><span style="color: #f4bf75">&gt;&lt;/script&gt;</span>
<span style="color: #f4bf75">&lt;docuseal-form</span> <span style="color: #6a9fb5">data-src=</span><span style="color: #90a959">"<%= start_form_url(slug: template.slug) %>"</span><span style="color: #f4bf75">&gt;</span><span style="color: #f4bf75">&lt;/docuseal-form&gt;</span>
</code></pre>
</div>
</div>
<div id="react_1" class="block my-4 hidden">
<div class="mockup-code overflow-hidden pb-0 mt-4">
<span class="top-0 right-0 absolute flex">
<%= link_to 'Learn More', console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/embedding/form"), target: '_blank', data: { turbo: false }, class: 'btn btn-ghost text-gray-100 flex', rel: 'noopener' %>
<clipboard-copy data-text="import React from &quot;react&quot;
import { DocusealForm } from '@docuseal/react'
export function App() {
return (
<DocusealForm
src=&quot;<%= start_form_url(slug: template.slug) %>&quot;
/>
);
}
">
<label class="btn btn-ghost text-white">
<input type="radio" class="peer hidden">
<span class="peer-checked:hidden flex items-center space-x-2">
<%= svg_icon('copy', class: 'w-6 h-6 text-white') %>
<span class="hidden md:inline">
Copy
</span>
</span>
<span class="hidden peer-checked:flex items-center space-x-2">
<%= svg_icon('clipboard_copy', class: 'w-6 h-6 text-white') %>
<span class="hidden md:inline">
Copied
</span>
</span>
</label>
</clipboard-copy>
</span>
<pre class="before:!m-0 pl-6 pb-4 overflow-auto"><code class="overflow-hidden w-full"><span style="color: #aa759f">import</span> <span style="color: #d0d0d0;background-color: #151515">React</span> <span style="color: #aa759f">from</span> <span style="color: #90a959">"</span><span style="color: #90a959">react</span><span style="color: #90a959">"</span>
<span style="color: #aa759f">import</span> <span style="color: #d0d0d0">{</span> <span style="color: #d0d0d0;background-color: #151515">DocusealForm</span> <span style="color: #d0d0d0">}</span> <span style="color: #aa759f">from</span> <span style="color: #90a959">'</span><span style="color: #90a959">@docuseal/react</span><span style="color: #90a959">'</span>
<span style="color: #aa759f">export</span> <span style="color: #d28445">function</span> <span style="color: #d0d0d0;background-color: #151515">App</span><span style="color: #d0d0d0">()</span> <span style="color: #d0d0d0">{</span>
<span style="color: #aa759f">return </span><span style="color: #d0d0d0">(</span>
<span style="color: #d0d0d0">&lt;</span><span style="color: #f4bf75">DocusealForm</span>
<span style="color: #6a9fb5">src</span><span style="color: #d0d0d0">=</span><span style="color: #90a959">"<%= start_form_url(slug: template.slug) %>"</span>
<span style="color: #d0d0d0">/&gt;</span>
<span style="color: #d0d0d0">);</span>
<span style="color: #d0d0d0">}</span>
</code></pre>
</div>
</div>
<div id="vue_1" class="block my-4 hidden">
<div class="mockup-code overflow-hidden pb-0 mt-4">
<span class="top-0 right-0 absolute flex">
<%= link_to 'Learn More', console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/embedding/form"), target: '_blank', data: { turbo: false }, class: 'btn btn-ghost text-gray-100 flex', rel: 'noopener' %>
<clipboard-copy data-text="<template>
<DocusealForm
:src=&quot;'<%= start_form_url(slug: template.slug) %>'&quot;
/>
</template>
<script>
import { DocusealForm } from '@docuseal/vue'
export default {
name: 'App',
components: {
DocusealForm
}
}
</script>
">
<label class="btn btn-ghost text-white">
<input type="radio" class="peer hidden">
<span class="peer-checked:hidden flex items-center space-x-2">
<%= svg_icon('copy', class: 'w-6 h-6 text-white') %>
<span class="hidden md:inline">
Copy
</span>
</span>
<span class="hidden peer-checked:flex items-center space-x-2">
<%= svg_icon('clipboard_copy', class: 'w-6 h-6 text-white') %>
<span class="hidden md:inline">
Copied
</span>
</span>
</label>
</clipboard-copy>
</span>
<pre class="before:!m-0 pl-6 pb-4 overflow-auto"><code class="overflow-hidden w-full"><span style="color: #f4bf75">&lt;template&gt;</span>
<span style="color: #f4bf75">&lt;DocusealForm</span>
<span style="color: #6a9fb5">:src=</span><span style="color: #90a959">"'<%= start_form_url(slug: template.slug) %>'"</span>
<span style="color: #f4bf75">/&gt;</span>
<span style="color: #f4bf75">&lt;/template&gt;</span>
<span style="color: #f4bf75">&lt;</span><span style="color: #f4bf75">script</span><span style="color: #f4bf75">&gt;</span>
<span style="color: #aa759f">import</span> <span style="color: #d0d0d0">{</span> <span style="color: #d0d0d0;background-color: #151515">DocusealForm</span> <span style="color: #d0d0d0">}</span> <span style="color: #aa759f">from</span> <span style="color: #90a959">'</span><span style="color: #90a959">@docuseal/vue</span><span style="color: #90a959">'</span>
<span style="color: #aa759f">export</span> <span style="color: #aa759f">default</span> <span style="color: #d0d0d0">{</span>
<span style="color: #6a9fb5">name</span><span style="color: #d0d0d0">:</span> <span style="color: #90a959">'</span><span style="color: #90a959">App</span><span style="color: #90a959">'</span><span style="color: #d0d0d0">,</span>
<span style="color: #6a9fb5">components</span><span style="color: #d0d0d0">:</span> <span style="color: #d0d0d0">{</span>
<span style="color: #d0d0d0;background-color: #151515">DocusealForm</span>
<span style="color: #d0d0d0">}</span>
<span style="color: #d0d0d0">}</span>
<span style="color: #f4bf75">&lt;/</span><span style="color: #f4bf75">script</span><span style="color: #f4bf75">&gt;</span>
</code></pre>
</div>
</div>
<div class="mt-4">
<a href="#" class="link text-center block" data-action="click:turbo-modal#close">Close</a>
</div>
</div>
+7 -2
View File
@@ -1,11 +1,11 @@
<%= render 'shared/turbo_modal', title: params[:selfsign] ? 'Add Recipients' : 'Add New Recipients' do %>
<% options = [['via Email', 'email'], ['via Phone', 'phone'], ['Detailed', 'detailed']] %>
<% options = [['via Email', 'email'], ['via Phone', 'phone'], %w[Detailed detailed], (Docuseal.multitenant? && params[:with_link] && @template.submitters.to_a.size < 2 ? ['via Link', 'link'] : nil)].compact %>
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="relative text-center mt-4 block">
<div class="join">
<% options.each_with_index do |(label, value), index| %>
<span>
<%= radio_button_tag 'option', value, value == 'email', class: 'peer hidden', data: { action: 'change:toggle-visible#trigger' } %>
<label for="option_<%= value %>" class="<%= '!rounded-s-full' if index.zero? %> btn btn-focus btn-sm join-item w-28 peer-checked:btn-active normal-case">
<label for="option_<%= value %>" class="<%= '!rounded-s-full' if index.zero? %> btn btn-focus btn-sm join-item md:w-28 peer-checked:btn-active normal-case">
<%= label %>
</label>
</span>
@@ -22,6 +22,11 @@
<div id="detailed" class="hidden">
<%= render 'detailed_form', template: @template %>
</div>
<% if Docuseal.multitenant? && params[:with_link] && @template.submitters.to_a.size < 2 %>
<div id="link" class="hidden">
<%= render 'link_form', template: @template %>
</div>
<% end %>
</div>
<%= content_for(:modal_extra) %>
<% end %>
+14 -3
View File
@@ -41,7 +41,7 @@
</a>
<% end %>
</div>
<div class="w-full overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5">
<div id="document_view" class="w-full overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5">
<div class="pr-3.5 pl-0.5">
<% fields_index = Templates.build_field_areas_index(@submission.template_fields || @submission.template.fields) %>
<% values = @submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } %>
@@ -71,7 +71,7 @@
<% end %>
</div>
</div>
<div class="hidden md:block relative w-80 flex-none pt-0.5 pr-4 pl-0.5 overflow-auto space">
<div id="parties_view" class="hidden md:block relative w-full md:w-80 flex-none pt-0.5 pr-4 pl-0.5 overflow-auto space">
<% colors = %w[bg-red-500 bg-sky-500 bg-emerald-500 bg-yellow-300 bg-purple-600 bg-pink-500 bg-cyan-500 bg-orange-500 bg-lime-500 bg-indigo-500] %>
<% submitter_fields_index = (@submission.template_fields || @submission.template.fields).group_by { |f| f['submitter_uuid'] } %>
<% submitter_field_counters = Hash.new { 0 } %>
@@ -126,7 +126,7 @@
<% if signed_in? && submitter && !submitter.completed_at? && can?(:create, submitter) %>
<div class="mt-2 mb-1">
<a class="btn btn-sm btn-primary w-full" target="_blank" href="<%= submit_form_path(slug: submitter.slug) %>">
Submit Form
Sign In-person
</a>
</div>
<% end %>
@@ -173,5 +173,16 @@
<% end %>
</div>
</div>
<label class="md:hidden btn btn-sm btn-neutral text-white text-base z-10 fixed bottom-2 right-2 h-16 shadow-lg">
<input type="checkbox" class="peer hidden" onclick="[document_view.classList.toggle('hidden'), parties_view.classList.toggle('hidden')]">
<span class="peer-checked:hidden flex items-center space-x-2">
<%= svg_icon('users', class: 'w-8 h-8') %>
<span>Signers</span>
</span>
<span class="hidden peer-checked:flex items-center">
<%= svg_icon('chevron_left', class: 'w-8 h-8') %>
<span>Back</span>
</span>
</label>
</div>
<%= render 'scripts/autosize_field' %>
@@ -1,4 +1,4 @@
<% data_attachments = attachments_index.values.select { |e| e.record_id == submitter.id }.to_json(only: %i[uuid], methods: %i[url filename content_type]) %>
<% data_fields = (submitter.submission.template_fields || submitter.submission.template.fields).select { |f| f['submitter_uuid'] == submitter.uuid }.to_json %>
<% configs = Submitters::FormConfigs.call(submitter) %>
<submission-form data-is-demo="<%= Docuseal.demo? %>" data-completed-button="<%= configs[:completed_button].to_json %>" data-go-to-last="<%= submitter.opened_at? %>" data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(Struct.new(:id).new(@submitter.submission.template.account_id)) %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-values="<%= submitter.values.to_json %>" data-with-typed-signature="<%= configs[:with_typed_signature] %>"></submission-form>
<submission-form data-is-demo="<%= Docuseal.demo? %>" data-completed-redirect-url="<%= submitter.preferences['completed_redirect_url'] %>" data-completed-button="<%= configs[:completed_button].to_json %>" data-go-to-last="<%= submitter.opened_at? %>" data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(Struct.new(:id).new(@submitter.submission.template.account_id)) %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-values="<%= submitter.values.to_json %>" data-with-typed-signature="<%= configs[:with_typed_signature] %>"></submission-form>
+2 -2
View File
@@ -9,9 +9,9 @@
<div class="mr-3">
<%= svg_icon('writing_sign', class: 'w-10 h-10') %>
</div>
<div>
<div dir="auto">
<p class="text-lg font-bold mb-1"><%= @submitter.submission.template.name %></p>
<p class="text-sm">Form has been deleted by <span class="font-semibold"><%= @submitter.submission.template.account.name %></span>.</p>
<p class="text-sm"><%= t('form_has_been_deleted_by_html', name: @submitter.submission.template.account.name) %></p>
</div>
</div>
</div>
+8 -8
View File
@@ -10,17 +10,17 @@
<%= svg_icon('writing_sign', class: 'w-10 h-10') %>
</div>
<div>
<p class="text-lg font-bold mb-1"><%= @submitter.submission.template.name %></p>
<p class="text-sm">Signed on <%= l(@submitter.completed_at.to_date, format: :long, locale: @submitter.submission.template.account.locale) %></p>
<p dir="auto" class="text-lg font-bold mb-1"><%= @submitter.submission.template.name %></p>
<p dir="auto" class="text-sm"><%= t('signed_on_time', time: l(@submitter.completed_at.to_date, format: :long)) %></p>
</div>
</div>
</div>
</div>
<div>
<% if Accounts.can_send_emails?(@submitter.submission.template.account) && @submitter.email.present? %>
<%= button_to button_title(title: 'Send copy to Email', disabled_with: 'Sending', icon: svg_icon('mail_forward', class: 'w-6 h-6')), send_submission_email_index_path, params: { submitter_slug: @submitter.slug }, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'white-button w-full' %>
<%= button_to button_title(title: t('send_copy_to_email'), disabled_with: t('sending'), icon: svg_icon('mail_forward', class: 'w-6 h-6')), send_submission_email_index_path, params: { submitter_slug: @submitter.slug }, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'white-button w-full' %>
<% if @submitter.submission.template.submitters.size != 1 %>
<div class="divider">OR</div>
<div class="divider uppercase"><%= t('or') %></div>
<% else %>
<div class="py-2"></div>
<% end %>
@@ -28,18 +28,18 @@
<download-button data-src="<%= submitter_download_index_path(@submitter.slug) %>" class="base-button w-full">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6') %>
<span>Download documents</span>
<span><%= t('download_documents') %></span>
</span>
<span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-6 h-6 animate-spin') %>
<span>Downloading</span>
<span><%= t('downloading') %></span>
</span>
</download-button>
</div>
<% if @submitter.submission.template.submitters.size == 1 %>
<div class="divider">OR</div>
<div class="divider uppercase"><%= t('or') %></div>
<div>
<%= button_to button_title(title: 'Resubmit', disabled_with: 'Resubmit', icon: svg_icon('reload', class: 'w-6 h-6')), start_form_path(@submitter.submission.template.slug), params: { submitter: { email: @submitter.email, phone: @submitter.phone, name: @submitter.name }, resubmit: @submitter.slug }, method: :put, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'white-button w-full' %>
<%= button_to button_title(title: t('resubmit'), disabled_with: t('resubmit'), icon: svg_icon('reload', class: 'w-6 h-6')), start_form_path(@submitter.submission.template.slug), params: { submitter: { email: @submitter.email, phone: @submitter.phone, name: @submitter.name }, resubmit: @submitter.slug }, method: :put, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'white-button w-full' %>
</div>
<% end %>
</div>
+17
View File
@@ -0,0 +1,17 @@
<div class="max-w-md mx-auto px-2 mt-12 mb-4">
<div class="space-y-6 mx-auto">
<div class="space-y-6">
<div class="flex items-center p-2 mb-4 justify-center">
<div class="flex items-center">
<div class="mr-3">
<%= svg_icon('circle_check', class: 'w-10 h-10 text-green-600') %>
</div>
<div class="text-2xl">
<%= t('completed_successfully') %>
</div>
</div>
</div>
</div>
</div>
</div>
<%= render 'shared/attribution', link_path: '/start' %>
+2 -1
View File
@@ -1,4 +1,4 @@
<%= form_for '', url: templates_upload_path, method: :post, class: 'inline', html: { enctype: 'multipart/form-data' } do %>
<%= form_for '', url: templates_upload_path, id: form_id = SecureRandom.uuid, method: :post, class: 'inline', html: { enctype: 'multipart/form-data' } do %>
<button type="submit" class="btn btn-ghost text-base" onclick="[event.preventDefault(), window.upload_template.click()]">
<span class="enabled">
<span class="flex items-center justify-center space-x-2">
@@ -13,6 +13,7 @@
</span>
</span>
</button>
<input type="hidden" name="form_id" value="<%= form_id %>">
<input id="upload_template" name="files[]" class="hidden" onchange="this.form.requestSubmit()" type="file" accept="image/*, application/pdf<%= ', .docx, .doc, .xlsx, .xls' if Docuseal.multitenant? %>" multiple>
<input hidden name="folder_name" value="<%= local_assigns[:folder_name] %>">
<% end %>
+2 -2
View File
@@ -11,8 +11,8 @@
<%= svg_icon('folder', class: 'w-6 h-6') %>
</a>
<folder-autocomplete class="flex justify-between w-full">
<input id="folder_name" placeholder="Folder Name" type="text" class="w-64 outline-none border-transparent focus:border-transparent focus:ring-0 bg-base-100 px-1 peer" name="folder_name" value="<%= params[:folder_name].presence || @base_template&.folder&.name || TemplateFolder::DEFAULT_NAME %>" onblur="window.folder_name.value = window.folder_name.value || 'Default'" autocomplete="off">
<a href="#" onclick="[event.preventDefault(), window.folder_name.value = '', window.folder_name.focus()]" class="link peer-focus:hidden mr-1.5">
<input id="folder_name" placeholder="Folder Name" type="text" class="w-full outline-none border-transparent focus:border-transparent focus:ring-0 bg-base-100 px-1 peer" name="folder_name" value="<%= params[:folder_name].presence || @base_template&.folder&.name || TemplateFolder::DEFAULT_NAME %>" onblur="window.folder_name.value = window.folder_name.value || 'Default'" autocomplete="off">
<a href="#" onclick="[event.preventDefault(), window.folder_name.value = '', window.folder_name.focus()]" class="shrink-0 link peer-focus:hidden mr-1.5">
Change Folder
</a>
</folder-autocomplete>
+1 -1
View File
@@ -78,7 +78,7 @@
<p>Send an invitation to fill and complete the form</p>
<div class="space-y-2 flex flex-col">
<% if can?(:create, Submission) %>
<%= link_to new_template_submission_path(@template), class: 'base-button mt-6', data: { turbo_frame: 'modal' } do %>
<%= link_to new_template_submission_path(@template, with_link: true), class: 'base-button mt-6', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %>
<span class="mr-1">Send to Recipients</span>
<% end %>
@@ -0,0 +1,18 @@
<%= render 'shared/turbo_modal', title: 'Testing Environment' do %>
<div>
<label class="text-sm font-semibold" for="api_key">
x-Auth-Token
</label>
<div class="flex gap-2 mb-4 mt-2">
<input id="api_key" type="text" value="<%= current_user.access_token.token %>" class="base-input 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>
<%= form_for @webhook_config, url: settings_webhooks_path, method: :post, html: { autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
<%= f.label :value, 'Webhook URL', class: 'text-sm font-semibold' %>
<div class="space-y-2 md:flex-nowrap mt-2">
<%= f.url_field :value, class: 'base-input w-full', placeholder: 'https://example.com/hook' %>
<%= f.button button_title(title: 'Save', disabled_with: 'Saving'), class: 'base-button w-full' %>
</div>
<% end %>
<% end %>
+1 -1
View File
@@ -5,7 +5,7 @@
<% options.each_with_index do |(label, value), index| %>
<span>
<%= radio_button_tag 'option', value, value == 'draw', class: 'peer hidden', data: { action: 'change:toggle-visible#trigger' } %>
<label for="option_<%= value %>" class="<%= '!rounded-s-full' if index.zero? %> btn btn-focus btn-sm join-item w-28 peer-checked:btn-active normal-case">
<label for="option_<%= value %>" class="<%= '!rounded-s-full' if index.zero? %> btn btn-focus btn-sm join-item md:w-28 peer-checked:btn-active normal-case">
<%= label %>
</label>
</span>
+4 -1
View File
@@ -1,7 +1,10 @@
<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">Webhooks</h1>
<div class="flex justify-between">
<h1 class="text-4xl font-bold mb-4">Webhooks</h1>
<%= render 'shared/test_mode_toggle' %>
</div>
<div class="card bg-base-200">
<div class="card-body p-6">
<%= form_for @encrypted_config, url: settings_webhooks_path, method: :post, html: { autocomplete: 'off' } do |f| %>
+1 -1
View File
@@ -22,7 +22,7 @@ module DocuSeal
config.active_storage.routes_prefix = ''
config.i18n.available_locales = %i[en en-US en-GB es-ES fr-FR pt-PT de-DE pt]
config.i18n.available_locales = %i[en en-US en-GB es-ES fr-FR pt-PT de-DE es it de fr pl uk cs pt he]
config.i18n.fallbacks = [:en]
config.exceptions_app = ->(env) { ErrorsController.action(:show).call(env) }
+1 -1
View File
@@ -82,7 +82,7 @@ Rails.application.configure do
ActiveRecord::Encryption.configure(**config.active_record.encryption)
# Raises error for missing translations.
# config.i18n.raise_on_missing_translations = true
config.i18n.raise_on_missing_translations = true
# Annotate rendered view with file names.
# config.action_view.annotate_rendered_view_with_filenames = true
+1 -1
View File
@@ -85,7 +85,7 @@ Rails.application.configure do
user_name: ENV.fetch('SMTP_USERNAME', nil),
password: ENV.fetch('SMTP_PASSWORD', nil),
authentication: ENV.fetch('SMTP_AUTHENTICATION', 'plain'),
enable_starttls_auto: ENV['SMTP_ENABLE_STARTTLS_AUTO'] == 'true'
enable_starttls_auto: ENV['SMTP_ENABLE_STARTTLS_AUTO'] != 'false'
}.compact
end
@@ -1,4 +1,4 @@
# frozen_string_literal: true
Rails.application.config.filter_parameters += %i[password token otp_attempt passw secret token _key crypt salt
certificate otp ssn]
certificate otp ssn file]
+19
View File
@@ -0,0 +1,19 @@
# frozen_string_literal: true
require 'twitter_cldr/shared/bidi'
require 'twitter_cldr/shared/code_point'
require 'twitter_cldr/resources/loader'
module TwitterCldr
RESOURCES_DIR = File.join(Gem::Specification.find_by_name('twitter_cldr').gem_dir, 'resources')
module_function
def resources
@resources ||= TwitterCldr::Resources::Loader.new
end
def get_resource(...)
resources.get_resource(...)
end
end
+214 -11
View File
@@ -1,29 +1,232 @@
en-US:
en: &en
email: Email
digitally_signed_by: Digitally signed by
role: Role
provide_your_email_to_start: Provide your email to start
start: Start
starting: Starting
form_has_been_deleted_by_html: 'Form has been deleted by <span class="font-semibold">%{name}</span>.'
invited_by_html: 'Invited by <span class="font-semibold">%{name}</span>'
you_have_been_invited_to_submit_a_form: You have been invited to submit a form
signed_on_time: 'Signed on %{time}'
form_has_been_submitted_already: Form has been submitted already
send_copy_to_email: Send copy to Email
sending: Sending
resubmit: Resubmit
form_has_been_deleted_by_html: 'Form has been deleted by <span class="font-semibold">%{name}</span>.'
or: or
download_documents: Download documents
downloading: Downloading
completed_successfully: Completed Successfully
es: &es
digitally_signed_by: Firmado digitalmente por
role: Rol
email: Correo electrónico
digitally_signed_by: Firmado digitalmente por
role: Rol
provide_your_email_to_start: Proporciona tu correo electrónico para comenzar
start: Comenzar
starting: Comenzando
form_has_been_deleted_by_html: 'El formulario ha sido eliminado por <span class="font-semibold">%{name}</span>.'
invited_by_html: 'Invitado por <span class="font-semibold">%{name}</span>'
you_have_been_invited_to_submit_a_form: Has sido invitado/a a enviar un formulario
signed_on_time: 'Firmado en %{time}'
form_has_been_submitted_already: El formulario ya ha sido enviado
send_copy_to_email: Enviar copia a Correo electrónico
sending: Enviando
resubmit: Reenviar
or: o
download_documents: Descargar documentos
downloading: Descargando
completed_successfully: Completado exitosamente
it:
digitally_signed_by: Signé numériquement par
role: Rôle
email: Email
digitally_signed_by: Firmato digitalmente da
role: Ruolo
provide_your_email_to_start: Fornisci la tua email per iniziare
start: Inizia
starting: Iniziando
form_has_been_deleted_by_html: 'Il modulo è stato eliminato da <span class="font-semibold">%{name}</span>.'
invited_by_html: 'Invitato da <span class="font-semibold">%{name}</span>'
you_have_been_invited_to_submit_a_form: Sei stato invitato a inviare un modulo
signed_on_time: 'Firmato alle %{time}'
form_has_been_submitted_already: Il modulo è già stato inviato
send_copy_to_email: Invia copia via email
sending: Inviando
resubmit: Reinvia
or: o
download_documents: Scarica documenti
downloading: Scaricamento
completed_successfully: Completato con successo
fr: &fr
email: Email
digitally_signed_by: Signé numériquement par
role: Rôle
provide_your_email_to_start: Entrez votre email pour commencer
start: Démarrer
starting: Démarrage
form_has_been_deleted_by_html: 'Le formulaire a été supprimé par <span class="font-semibold">%{name}</span>.'
invited_by_html: 'Invité par <span class="font-semibold">%{name}</span>'
you_have_been_invited_to_submit_a_form: Vous avez été invité à soumettre un formulaire
signed_on_time: 'Signé à %{time}'
form_has_been_submitted_already: Le formulaire a déjà été soumis
send_copy_to_email: Envoyer une copie par email
sending: Envoi
resubmit: Soumettre à nouveau
or: ou
download_documents: Télécharger des documents
downloading: Téléchargement
completed_successfully: Terminé avec succès
pt: &pt
digitally_signed_by: Assinado digitalmente por
role: Função
email: Email
digitally_signed_by: Assinado digitalmente por
role: Função
provide_your_email_to_start: Forneça o seu email para começar
start: Iniciar
starting: Iniciando
form_has_been_deleted_by_html: 'O formulário foi eliminado por <span class="font-semibold">%{name}</span>.'
invited_by_html: 'Convidado por <span class="font-semibold">%{name}</span>'
you_have_been_invited_to_submit_a_form: Foi convidado a submeter um formulário
signed_on_time: 'Assinado em %{time}'
form_has_been_submitted_already: O formulário já foi submetido
send_copy_to_email: Enviar cópia para Email
sending: Enviando
resubmit: Reenviar
or: ou
download_documents: Baixar documentos
downloading: Baixando
completed_successfully: Concluído com sucesso
de: &de
digitally_signed_by: Digital signiert von
role: Rolle
email: E-Mail
digitally_signed_by: Digital unterzeichnet von
role: Rolle
provide_your_email_to_start: Gib deine E-Mail-Adresse ein, um zu starten
start: Starten
starting: Starten
form_has_been_deleted_by_html: 'Das Formular wurde von <span class="font-semibold">%{name}</span> gelöscht.'
invited_by_html: 'Eingeladen von <span class="font-semibold">%{name}</span>'
you_have_been_invited_to_submit_a_form: Du wurdest eingeladen, ein Formular einzureichen
signed_on_time: 'Unterzeichnet um %{time}'
form_has_been_submitted_already: Formular wurde bereits eingereicht
send_copy_to_email: Kopie per E-Mail senden
sending: Senden
resubmit: Erneut einreichen
or: oder
download_documents: Dokumente herunterladen
downloading: Herunterladen
completed_successfully: Erfolgreich abgeschlossen
pl:
email: Email
digitally_signed_by: Podpis cyfrowy przez
role: Rola
provide_your_email_to_start: Podaj swój adres email, aby rozpocząć
start: Rozpocznij
starting: Rozpoczynanie
form_has_been_deleted_by_html: 'Formularz został usunięty przez <span class="font-semibold">%{name}</span>.'
invited_by_html: 'Zaproszony przez <span class="font-semibold">%{name}</span>'
you_have_been_invited_to_submit_a_form: Zostałeś zaproszony do przesłania formularza
signed_on_time: 'Podpisano %{time}'
form_has_been_submitted_already: Formularz został już przesłany
send_copy_to_email: Wyślij kopię na Email
sending: Wysyłanie
resubmit: Prześlij ponownie
or: lub
download_documents: Pobierz dokumenty
downloading: Pobieranie
completed_successfully: Zakończono pomyślnie
uk:
email: Email
digitally_signed_by: Цифровий підпис від
role: Роль
provide_your_email_to_start: Введіть свій email, щоб почати
start: Почати
starting: Початок
form_has_been_deleted_by_html: 'Форму було видалено користувачем <span class="font-semibold">%{name}</span>.'
invited_by_html: 'Запрошений користувачем <span class="font-semibold">%{name}</span>'
you_have_been_invited_to_submit_a_form: Вас запросили подати форму
signed_on_time: 'Підписаний %{time}'
form_has_been_submitted_already: Форма вже була подана
send_copy_to_email: Надіслати копію на Email
sending: Надсилання
resubmit: Подати знову
or: або
download_documents: Завантажити документи
downloading: Завантаження
completed_successfully: Успішно завершено
cs:
email: Email
digitally_signed_by: Digitálně podepsáno uživatelem
role: Role
provide_your_email_to_start: Zadejte svůj email pro zahájení
start: Zahájit
starting: Zahajování
form_has_been_deleted_by_html: 'Formulář byl smazán uživatelem <span class="font-semibold">%{name}</span>.'
invited_by_html: 'Pozván uživatelem <span class="font-semibold">%{name}</span>'
you_have_been_invited_to_submit_a_form: Byli jste pozváni k odeslání formuláře
signed_on_time: 'Podepsáno %{time}'
form_has_been_submitted_already: Formulář již byl odeslán
send_copy_to_email: Odeslat kopii na Email
sending: Odesílání
resubmit: Odeslat znovu
or: nebo
download_documents: Stáhnout dokumenty
downloading: Stahování
completed_successfully: Úspěšně dokončeno
he:
email: דוא"ל
digitally_signed_by: חתום דיגיטלית על ידי
role: תפקיד
provide_your_email_to_start: ספק את כתובת הדוא"ל שלך כדי להתחיל
start: התחל
starting: מתחיל
form_has_been_deleted_by_html: 'הטופס נמחק על ידי <span class="font-semibold">%{name}</span>.'
invited_by_html: 'הוזמן על ידי <span class="font-semibold">%{name}</span>'
you_have_been_invited_to_submit_a_form: הוזמנת להגיש טופס
signed_on_time: 'חתום ב-%{time}'
form_has_been_submitted_already: הטופס כבר נשלח
send_copy_to_email: שלח עותק לדוא"ל
sending: שולח
resubmit: שלח מחדש
or: או
download_documents: הורד מסמכים
downloading: מוריד
completed_successfully: הושלם בהצלחה
en-US:
<<: *en
date:
formats:
default: "%m/%d/%Y"
en-GB:
digitally_signed_by: Digitally signed by
role: Role
<<: *en
date:
formats:
default: "%d/%m/%Y"
es-ES:
digitally_signed_by: Firmado digitalmente por
role: Rol
<<: *es
fr-FR:
digitally_signed_by: Signé numériquement par
role: Rôle
<<: *fr
pt-PT:
digitally_signed_by: Assinado digitalmente por
role: Função
<<: *pt
de-DE:
digitally_signed_by: Digital signiert von
role: Rolle
<<: *de
+8 -4
View File
@@ -62,6 +62,8 @@ Rails.application.routes.draw do
resource :user_signature, only: %i[edit update destroy]
resources :submissions, only: %i[show destroy]
resources :console_redirect, only: %i[index]
resource :testing_account, only: %i[show destroy]
resources :testing_api_settings, only: %i[index]
resource :templates_upload, only: %i[create]
authenticated do
resource :templates_upload, only: %i[show], path: 'new'
@@ -81,6 +83,10 @@ Rails.application.routes.draw do
get :completed
end
resources :submit_form, only: %i[], path: '' do
get :success, on: :collection
end
resources :submit_form, only: %i[show update], path: 's', param: 'slug' do
get :completed
end
@@ -108,10 +114,8 @@ Rails.application.routes.draw do
resource :esign, only: %i[show create new update destroy], controller: 'esign_settings'
resources :users, only: %i[index]
resource :personalization, only: %i[show create], controller: 'personalization_settings'
if !Docuseal.multitenant? || Docuseal.demo?
resources :api, only: %i[index create], controller: 'api_settings'
resource :webhooks, only: %i[show create update], controller: 'webhook_settings'
end
resources :api, only: %i[index create], controller: 'api_settings'
resource :webhooks, only: %i[show create update], controller: 'webhook_settings'
resource :account, only: %i[show update]
resources :profile, only: %i[index] do
collection do
@@ -0,0 +1,15 @@
# frozen_string_literal: true
class CreateAccountLinkedAccounts < ActiveRecord::Migration[7.1]
def change
create_table :account_linked_accounts do |t|
t.references :account, null: false, foreign_key: true
t.references :linked_account, null: false, foreign_key: { to_table: :accounts }
t.text :account_type, null: false
t.index %i[account_id linked_account_id], unique: true
t.timestamps
end
end
end
+14 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2023_12_29_220819) do
ActiveRecord::Schema[7.1].define(version: 2024_01_20_192055) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -34,6 +34,17 @@ ActiveRecord::Schema[7.1].define(version: 2023_12_29_220819) do
t.index ["account_id"], name: "index_account_configs_on_account_id"
end
create_table "account_linked_accounts", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "linked_account_id", null: false
t.text "account_type", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "linked_account_id"], name: "idx_on_account_id_linked_account_id_48ab9f79d2", unique: true
t.index ["account_id"], name: "index_account_linked_accounts_on_account_id"
t.index ["linked_account_id"], name: "index_account_linked_accounts_on_linked_account_id"
end
create_table "accounts", force: :cascade do |t|
t.string "name", null: false
t.string "timezone", null: false
@@ -242,6 +253,8 @@ ActiveRecord::Schema[7.1].define(version: 2023_12_29_220819) do
add_foreign_key "access_tokens", "users"
add_foreign_key "account_configs", "accounts"
add_foreign_key "account_linked_accounts", "accounts"
add_foreign_key "account_linked_accounts", "accounts", column: "linked_account_id"
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "document_generation_events", "submitters"
+22 -1
View File
@@ -31,6 +31,26 @@ module Accounts
new_account
end
def find_or_create_testing_user(account)
user = User.find_by(account: account.testing_accounts)
return user if user
testing_account = account.dup.tap { |a| a.name = "Testing - #{a.name}" }
ApplicationRecord.transaction do
account.testing_accounts << testing_account
testing_account.users.create!(
email: account.users.order(:id).first.email.sub('@', '+test@'),
first_name: 'Testing',
last_name: 'Environment',
password: SecureRandom.hex,
role: :admin
)
end
end
def create_default_template(account)
template = Template.find(1)
@@ -61,7 +81,8 @@ module Accounts
if Docuseal.multitenant?
EncryptedConfig.find_by(account:, key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || Docuseal::CERTS
else
EncryptedConfig.find_by(key: EncryptedConfig::ESIGN_CERTS_KEY).value
EncryptedConfig.find_by(account:, key: EncryptedConfig::ESIGN_CERTS_KEY)&.value ||
EncryptedConfig.find_by(key: EncryptedConfig::ESIGN_CERTS_KEY).value
end
if (default_cert = cert_data['custom']&.find { |e| e['status'] == 'default' })
+1 -1
View File
@@ -7,7 +7,7 @@ class ApiPathConsiderJsonMiddleware
def call(env)
if env['PATH_INFO'].starts_with?('/api') &&
!env['PATH_INFO'].ends_with?('/documents') &&
(!env['PATH_INFO'].ends_with?('/documents') || env['REQUEST_METHOD'] != 'POST') &&
!env['PATH_INFO'].ends_with?('/attachments')
env['CONTENT_TYPE'] = 'application/json'
end
+106
View File
@@ -0,0 +1,106 @@
# frozen_string_literal: true
module Params
class BaseValidator
InvalidParameterError = Class.new(StandardError)
def self.call(...)
validator = new(...)
validator.call
rescue InvalidParameterError => e
Rollbar.error(e) if defined?(Rollbar)
raise e unless validator.dry_run?
rescue StandardError => e
Rollbar.error(e) if defined?(Rollbar)
raise e unless Rails.env.production?
end
attr_reader :params, :dry_run
alias dry_run? dry_run
def initialize(params, dry_run: false)
@params = params
@dry_run = dry_run
@current_path = ''
end
def call
raise NotImplementedError
end
def raise_error(message)
message += " in `#{@current_path}`." if @current_path.present?
raise InvalidParameterError, message unless dry_run?
Rollbar.error(message) if defined?(Rollbar)
end
def required(params, keys, message: nil)
keys = Array.wrap(keys)
return if keys.any? { |key| params&.dig(key).present? }
raise_error(message || "#{keys.join(' or ')} is required")
end
def type(params, key, type, message: nil)
return if params.blank?
return if params[key].blank?
return if params[key].is_a?(type) || (type == Hash && params[key].is_a?(ActionController::Parameters))
type = 'Object' if type == Hash
raise_error(message || "#{key} must be a #{type}")
end
def in_path(params, path = [])
old_path = @current_path
@current_path = [old_path, *path].compact_blank.map(&:to_s).join('.')
param = params.dig(*path)
yield params.dig(*path) if param
@current_path = old_path
end
def in_path_each(params, path = [])
old_path = @current_path
params.dig(*path)&.each_with_index do |item, index|
@current_path = old_path + [*path].map(&:to_s).join('.') + "[#{index}]"
yield item if item
end
@current_path = old_path
end
def boolean(params, key, message: nil)
return if params.blank?
return unless params.key?(key)
return if params[key].nil?
value = ActiveModel::Type::Boolean.new.cast(params[key])
return if value.is_a?(TrueClass) || value.is_a?(FalseClass)
raise_error(message || "#{key} must be true or false")
end
def value_in(params, key, values, allow_nil: false, message: nil)
return if params.blank?
return if allow_nil && params[key].nil?
return if values.include?(params[key])
raise_error(message || "#{key} must be one of #{values.join(', ')}")
end
end
end
+85
View File
@@ -0,0 +1,85 @@
# frozen_string_literal: true
module Params
class SubmissionCreateValidator < BaseValidator
# rubocop:disable Metrics
def call
if params[:submission].blank? && (params[:emails].present? || params[:email].present?)
validate_creation_from_emails(params)
else
validate_creation_from_submission(params)
end
end
def validate_creation_from_emails(params)
required(params, :template_id)
required(params, %i[emails email])
type(params, :emails, String)
boolean(params, :send_email)
type(params, :message, Hash)
in_path(params, :message) do |message_params|
type(message_params, :subject, String)
type(message_params, :body, String)
required(message_params, :body)
end
true
end
def validate_creation_from_submission(params)
required(params, :template_id)
required(params, %i[submission submissions])
boolean(params, :send_email)
boolean(params, :send_sms)
type(params, :order, String)
type(params, :completed_redirect_url, String)
type(params, :bcc_completed, String)
type(params, :message, Hash)
in_path(params, :message) do |message_params|
type(message_params, :subject, String)
type(message_params, :body, String)
required(message_params, :body)
end
value_in(params, :order, %w[preserved random], allow_nil: true)
return true if params[:submission].is_a?(Array)
in_path(params, :submission) do |submission_params|
required(submission_params, :submitters) if params[:submission]
end
in_path_each(params, %i[submission submitters]) do |submitter_params|
required(submitter_params, %i[email phone name])
type(submitter_params, :name, String)
type(submitter_params, :email, String)
type(submitter_params, :phone, String)
type(submitter_params, :values, Hash)
boolean(submitter_params, :send_email)
boolean(submitter_params, :send_sms)
type(submitter_params, :completed_redirect_url, String)
type(submitter_params, :fields, Array)
in_path_each(submitter_params, %i[fields]) do |field_params|
required(field_params, :name)
type(field_params, :name, String)
type(field_params, :default_value, String)
type(field_params, :validation_pattern, String)
type(field_params, :invalid_message, String)
boolean(field_params, :readonly)
end
end
true
end
# rubocop:enable Metrics
end
end
+45 -13
View File
@@ -1,39 +1,71 @@
# frozen_string_literal: true
class PdfProcessor < HexaPDF::Content::Processor
attr_reader :result, :contents
attr_accessor :handler
attr_accessor :handler, :serializer
class ParseTextHandler
attr_accessor :pos, :num, :search_chars, :handler
def initialize(handler)
@num = 0
@pos = 0
@handler = handler
@search_chars = handler.search_chars
end
TJS = %i[TJ Tj].freeze
def call(processor, operator, operands)
return unless TJS.include?(operator)
processor.send(:decode_text, *operands).chars.each do |char|
handler.tokens << [char, [@num, @pos]] if search_chars.include?(char)
@pos += 1
end
@pos = 0
@num += 1
end
end
def initialize(page)
super
@contents = ''.b
@result = []
@serializer = HexaPDF::Serializer.new
end
def serialize(operator, operands)
operators[operator].serialize(serializer, *operands)
end
def process(operator, operands = [])
super
contents << @operators[operator].serialize(
@serializer,
*handler.call(self, operator, operands)
)
handler.call(self, operator, operands)
end
def self.call(data, pdf_handler, result_handler, acc = {})
def self.call(data, process_handler, result_handler, acc = {})
doc = HexaPDF::Document.new(io: StringIO.new(data))
doc.pages.each do |page|
processor = PdfProcessor.new(page)
processor.handler = pdf_handler
process_handler_instance = process_handler.new
processor.handler = ParseTextHandler.new(process_handler_instance)
page.process_contents(processor)
page.contents = processor.contents
if process_handler_instance.tokens?
processor = PdfProcessor.new(page)
processor.handler = process_handler_instance
processor.result.each do |item|
page.process_contents(processor)
page.contents = process_handler_instance.contents
end
process_handler_instance.result.each do |item|
result_handler.call(item, page, acc)
end
end
+29
View File
@@ -0,0 +1,29 @@
# frozen_string_literal: true
module PdfUtils
module_function
def encrypted?(data, password: nil)
HexaPDF::Document.new(io: StringIO.new(data), decryption_opts: { password: })
false
rescue HexaPDF::EncryptionError
true
end
def decrypt(data, password)
encrypted_doc = HexaPDF::Document.new(io: StringIO.new(data), decryption_opts: { password: })
decrypted_doc = HexaPDF::Document.new
encrypted_doc.pages.each do |page|
decrypted_doc.pages << decrypted_doc.import(page)
end
decrypted_io = StringIO.new
decrypted_doc.write(decrypted_io, validate: false)
decrypted_io.tap(&:rewind).read
end
end
+2
View File
@@ -8,6 +8,7 @@ module ReplaceEmailVariables
SUBMITTER_EMAIL = '{{submitter.email}}'
SUBMITTER_NAME = '{{submitter.name}}'
SUBMITTER_ID = '{{submitter.id}}'
SUBMITTER_SLUG = '{{submitter.slug}}'
SUBMISSION_LINK = '{{submission.link}}'
SUBMISSION_ID = '{{submission.id}}'
SUBMISSION_SUBMITTERS = '{{submission.submitters}}'
@@ -25,6 +26,7 @@ module ReplaceEmailVariables
text = text.gsub(TEMPLATE_NAME, submitter.template.name) if submitter.template
text = text.gsub(TEMPLATE_ID, submitter.template.id.to_s) if submitter.template
text = text.gsub(SUBMITTER_ID, submitter.id.to_s)
text = text.gsub(SUBMITTER_SLUG, submitter.slug.to_s)
text = text.gsub(SUBMISSION_ID, submitter.submission.id.to_s) if submitter.submission
text = text.gsub(SUBMITTER_EMAIL, submitter.email) if submitter.email
text = text.gsub(SUBMITTER_NAME, submitter.name || submitter.email || submitter.phone)
+22 -12
View File
@@ -11,7 +11,7 @@ module Submissions
submission_preferences = Submitters.normalize_preferences(template.account, user, attrs)
submission_preferences = preferences.merge(submission_preferences)
set_submission_preferences = submission_preferences.slice('send_email')
set_submission_preferences = submission_preferences.slice('send_email', 'bcc_completed')
set_submission_preferences['send_email'] = true if params['send_completed_email']
submission = template.submissions.new(created_by_user: user, source:,
@@ -98,19 +98,28 @@ module Submissions
next if field_configs.blank?
f['readonly'] = field_configs['readonly'] if field_configs['readonly'].present?
f['default_value'] = field_configs['default_value'] if field_configs['default_value'].present? &&
!f['type'].in?(%w[signature image initials file])
next if field_configs['validation_pattern'].blank?
f['validation'] = {
'pattern' => field_configs['validation_pattern'],
'message' => field_configs['invalid_message']
}.compact_blank
assign_field_attrs(f, field_configs)
end
end
def assign_field_attrs(field, attrs)
field['title'] = attrs['title'] if attrs['title'].present?
field['description'] = attrs['description'] if attrs['description'].present?
field['readonly'] = attrs['readonly'] if attrs.key?('readonly')
field['required'] = attrs['required'] if attrs.key?('required')
field['default_value'] = attrs['default_value'] if attrs['default_value'].present? &&
!field['type'].in?(%w[signature image initials file])
return field if attrs['validation_pattern'].blank?
field['validation'] = {
'pattern' => attrs['validation_pattern'],
'message' => attrs['invalid_message']
}.compact_blank
field
end
def find_submitter_uuid(template, attrs, index)
attrs[:uuid].presence ||
template.submitters.find { |e| e['name'] == attrs[:role] }&.dig('uuid') ||
@@ -130,7 +139,8 @@ module Submissions
sent_at: mark_as_sent && email.present? && is_order_sent ? Time.current : nil,
values: attrs[:values] || {},
preferences: preferences.merge(submitter_preferences)
.merge({ default_values: attrs[:values] }.compact_blank),
.merge({ default_values: attrs[:values] }.compact_blank)
.except('bcc_completed'),
uuid:
)
end
+4 -12
View File
@@ -27,7 +27,7 @@ module Submissions
'GBP' => '£'
}.freeze
RTL_REGEXP = Submissions::GenerateResultAttachments::RTL_REGEXP
RTL_REGEXP = TextUtils::RTL_REGEXP
module_function
@@ -149,7 +149,7 @@ module Submissions
[
submission.template_submitters.size > 1 && { text: "#{item['name']}\n" },
submitter.email && { text: "#{submitter.email}\n", font: [FONT_BOLD_NAME, { variant: :bold }] },
submitter.name && { text: "#{maybe_rtl_reverse(submitter.name)}\n" },
submitter.name && { text: "#{TextUtils.maybe_rtl_reverse(submitter.name)}\n" },
submitter.phone && { text: "#{submitter.phone}\n" }
].compact_blank, line_spacing: 1.8, padding: [0, 20, 0, 0]
)
@@ -187,7 +187,7 @@ module Submissions
composer.formatted_text_box(
[
{
text: maybe_rtl_reverse(field['name'].to_s).upcase.presence ||
text: TextUtils.maybe_rtl_reverse(field['name'].to_s).upcase.presence ||
"#{field['type']} Field #{submitter_field_counters[field['type']]}\n".upcase,
font_size: 6
}
@@ -237,7 +237,7 @@ module Submissions
value = value.join(', ') if value.is_a?(Array)
composer.formatted_text_box([{ text: maybe_rtl_reverse(value.to_s.presence || 'n/a') }],
composer.formatted_text_box([{ text: TextUtils.maybe_rtl_reverse(value.to_s.presence || 'n/a') }],
align: value.to_s.match?(RTL_REGEXP) ? :right : :left,
padding: [0, 0, 10, 0])
end
@@ -298,14 +298,6 @@ module Submissions
)
end
def maybe_rtl_reverse(text)
if text.match?(RTL_REGEXP)
text.reverse
else
text
end
end
def add_logo(column, _submission = nil)
column.image(PdfIcons.logo_io, width: 40, height: 40, position: :float)
+3 -11
View File
@@ -13,7 +13,7 @@ module Submissions
SIGN_REASON = 'Signed by %<name>s with DocuSeal.co'
SIGN_SIGNLE_REASON = 'Digitally signed with DocuSeal.co'
RTL_REGEXP = /\A[\p{Hebrew}\p{Arabic}].*[\p{Hebrew}\p{Arabic}]\z/
RTL_REGEXP = TextUtils::RTL_REGEXP
TEXT_LEFT_MARGIN = 1
TEXT_TOP_MARGIN = 1
@@ -159,7 +159,7 @@ module Submissions
when 'cells'
cell_width = area['cell_w'] * width
maybe_rtl_reverse(value).chars.each_with_index do |char, index|
TextUtils.maybe_rtl_reverse(value).chars.each_with_index do |char, index|
text = HexaPDF::Layout::TextFragment.create(char, font: pdf.fonts.add(FONT_NAME),
font_size:)
@@ -172,7 +172,7 @@ module Submissions
value = TimeUtils.format_date_string(value, field.dig('preferences', 'format'), account.locale)
end
value = maybe_rtl_reverse(Array.wrap(value).join(', '))
value = TextUtils.maybe_rtl_reverse(Array.wrap(value).join(', '))
text = HexaPDF::Layout::TextFragment.create(value, font: pdf.fonts.add(FONT_NAME),
font_size:)
@@ -318,14 +318,6 @@ module Submissions
pdf
end
def maybe_rtl_reverse(text)
if text.match?(RTL_REGEXP)
text.reverse
else
text
end
end
def sign_reason(name)
format(SIGN_REASON, name:)
end
+2
View File
@@ -59,6 +59,8 @@ module Submitters
preferences['email_message_uuid'] = email_message.uuid if email_message
preferences['send_email'] = params['send_email'].in?(TRUE_VALUES) if params.key?('send_email')
preferences['send_sms'] = params['send_sms'].in?(TRUE_VALUES) if params.key?('send_sms')
preferences['bcc_completed'] = params['bcc_completed'] if params.key?('bcc_completed')
preferences['completed_redirect_url'] = params['completed_redirect_url'] if params.key?('completed_redirect_url')
preferences
end
+15
View File
@@ -0,0 +1,15 @@
# frozen_string_literal: true
module Submitters
module ValidateSending
InvalidEmail = Class.new(StandardError)
module_function
def call(submitter, _mail)
raise InvalidEmail unless submitter.email.to_s.include?('@')
true
end
end
end
+4 -4
View File
@@ -29,10 +29,10 @@ module Templates
{
'type' => 'external_link',
'value' => annot[:A][:URI],
'x' => left / page.width,
'y' => (page.height - top) / page.height,
'w' => (right - left) / page.width,
'h' => (top - bottom) / page.height
'x' => left / page.width.to_f,
'y' => (page.height - top) / page.height.to_f,
'w' => (right - left) / page.width.to_f,
'h' => (top - bottom) / page.height.to_f
}
end
end
+22
View File
@@ -5,6 +5,7 @@ module Templates
PDF_CONTENT_TYPE = 'application/pdf'
ANNOTATIONS_SIZE_LIMIT = 6.megabytes
InvalidFileType = Class.new(StandardError)
PdfEncrypted = Class.new(StandardError)
module_function
@@ -19,6 +20,15 @@ module Templates
document = template.documents.create!(blob:)
if blob.content_type == PDF_CONTENT_TYPE && blob.metadata['pdf'].nil?
data = maybe_decrypt_pdf_or_raise(document_data, params)
if data != document_data
blob = ActiveStorage::Blob.create_and_upload!(
io: StringIO.new(new_data), filename: blob.filename
)
document_data = data
end
annotations =
document_data.size > ANNOTATIONS_SIZE_LIMIT ? [] : Templates::BuildAnnotations.call(document_data)
@@ -41,6 +51,8 @@ module Templates
data = file.read
if file.content_type == PDF_CONTENT_TYPE
data = maybe_decrypt_pdf_or_raise(data, params)
annotations = data.size > ANNOTATIONS_SIZE_LIMIT ? [] : Templates::BuildAnnotations.call(data)
metadata = { 'identified' => true, 'analyzed' => true,
'sha256' => Base64.urlsafe_encode64(Digest::SHA256.digest(data)),
@@ -56,6 +68,16 @@ module Templates
end
end
def maybe_decrypt_pdf_or_raise(data, params)
if data.size < ANNOTATIONS_SIZE_LIMIT && PdfUtils.encrypted?(data)
PdfUtils.decrypt(data, params[:password])
else
data
end
rescue HexaPDF::EncryptionError
raise PdfEncrypted
end
def handle_file_types(_document_data, blob)
raise InvalidFileType, blob.content_type
end
+17
View File
@@ -0,0 +1,17 @@
# frozen_string_literal: true
module TextUtils
RTL_REGEXP = /[\p{Hebrew}\p{Arabic}]/
module_function
def maybe_rtl_reverse(text)
if text.match?(RTL_REGEXP)
TwitterCldr::Shared::Bidi
.from_string(ArabicLetterConnector.transform(text), direction: :RTL)
.reorder_visually!.to_s
else
text
end
end
end
+1 -3
View File
@@ -14,9 +14,7 @@ RSpec.describe 'Dashboard Page' do
it 'shows empty state' do
visit root_path
expect(page).to have_content('Welcome to DocuSeal')
expect(page).to have_content('Streamline document workflows, from creating customizable')
expect(page).to have_link('Create Template', href: new_template_path)
expect(page).to have_link('Create', href: new_template_path)
end
end

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