Compare commits
149 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9011e3f4b4 | |||
| 0b45ce5c0c | |||
| a8ab93a4cc | |||
| d1768be636 | |||
| b2cd7c82a7 | |||
| b88d99afe0 | |||
| 4523d38551 | |||
| c4ba892559 | |||
| 40f8093e00 | |||
| 4375459856 | |||
| b3eb8a70bf | |||
| 6245a487b6 | |||
| a89f4c1b10 | |||
| 7df729b8b5 | |||
| f1db7c7e82 | |||
| 00af69cbf2 | |||
| c6819a19bd | |||
| f5cbcb3d24 | |||
| 6ca75c53ef | |||
| e0ec443e10 | |||
| 9eed2fc2ef | |||
| b9c6d3ff9f | |||
| 03c25a89b2 | |||
| f5b4a0c5ab | |||
| b10ed46ccc | |||
| c55036a1b1 | |||
| 962a5dade5 | |||
| 369a09a6df | |||
| e9a39e2571 | |||
| 1cf9cc18f1 | |||
| 1984d4ec8d | |||
| fa99b4ebdc | |||
| 83c793a2a5 | |||
| d078727070 | |||
| 0d8e2c5ff0 | |||
| ea062d7494 | |||
| d525e0597a | |||
| 964b5d4e74 | |||
| abf17eb09f | |||
| 178809dad7 | |||
| 881d189715 | |||
| 48a8973910 | |||
| ae891932c5 | |||
| a8b5e00814 | |||
| e98432d7e4 | |||
| 29ff58ed98 | |||
| a621575ffc | |||
| d7c7efc203 | |||
| 4f013ca927 | |||
| d9823feef7 | |||
| 676d775544 | |||
| 840ad17258 | |||
| ce0cd09b70 | |||
| bb752fbc00 | |||
| c71fb04404 | |||
| e856cf8787 | |||
| 71aae65bc6 | |||
| 7a33444f9b | |||
| e12ec9d986 | |||
| 94d1fb7dcb | |||
| 6e43a42274 | |||
| 0e0aa0259e | |||
| 75ab6d39ef | |||
| 6f84d84f96 | |||
| cf46593e9b | |||
| 1808346348 | |||
| ea59a68499 | |||
| cba2d02990 | |||
| e92fc0d3e1 | |||
| f1eaa77092 | |||
| 5934bdec55 | |||
| 7a7d3c6ee3 | |||
| cb2f58c04c | |||
| a1c117f1ca | |||
| 1aacd98460 | |||
| 840162c9ca | |||
| 3519b0e009 | |||
| 53fae56c51 | |||
| f4552dbaa8 | |||
| dfab4c331c | |||
| d2a0937038 | |||
| fa1ef44b22 | |||
| c1a5c91299 | |||
| e6280e8f5a | |||
| fd2ba57325 | |||
| dc178ef03f | |||
| 68936e10c5 | |||
| 41f16e7d39 | |||
| 453ebbf90a | |||
| 7ca290c080 | |||
| dd5b6a6aa6 | |||
| bf2ebb995b | |||
| 3e929758fb | |||
| 4a53116586 | |||
| 2d1981acfa | |||
| c610df269f | |||
| 96dac635f5 | |||
| abd579103d | |||
| 7ed27bb413 | |||
| 180c542a9f | |||
| 89613113de | |||
| 3102900776 | |||
| 1a68053f4e | |||
| 5d4a032763 | |||
| 7481d27822 | |||
| 22afd01fd2 | |||
| fd9eb09dd6 | |||
| c53ed7f984 | |||
| 1d87e210be | |||
| 5fec952b8c | |||
| c4fbcc103a | |||
| 9333a1ba45 | |||
| de5cd92dba | |||
| 83e32bec9f | |||
| 4603efc04e | |||
| 6c73c82a27 | |||
| 8e71d9f9e9 | |||
| 80f60a5d88 | |||
| 2c33ec382d | |||
| 884865cffc | |||
| 9537fe8c91 | |||
| c8fe36a2b6 | |||
| 7ec3282b56 | |||
| ab8c41a520 | |||
| 4c1c70ff04 | |||
| d7a6e80bb1 | |||
| f87ef670d1 | |||
| daba2505bc | |||
| 71528eda45 | |||
| 269e2beeaa | |||
| 72dd2c6bc5 | |||
| 9758f7a15f | |||
| 2521eb5a4a | |||
| bf6afc5e61 | |||
| 291d869843 | |||
| cdfecb2a8f | |||
| c0df8e0ec3 | |||
| a21f7d1653 | |||
| 79e2514416 | |||
| 80a7385183 | |||
| 1c8a7b6a7c | |||
| b46ced2b6f | |||
| 5ae479644e | |||
| cb66eb1293 | |||
| b4d5a2cad4 | |||
| 32691b0c0d | |||
| 9163f744a3 | |||
| 23ecce51a3 | |||
| 747ffa8cf1 |
@@ -1,27 +1,14 @@
|
||||
name: Build Docker Images
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Version
|
||||
type: string
|
||||
required: true
|
||||
image:
|
||||
description: QEMU image
|
||||
type: string
|
||||
required: false
|
||||
default: tonistiigi/binfmt:latest
|
||||
os:
|
||||
description: OS
|
||||
type: string
|
||||
required: false
|
||||
default: ubuntu-24.04-arm
|
||||
push:
|
||||
tags:
|
||||
- "*.*.*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ inputs.os }}
|
||||
timeout-minutes: 20
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -34,18 +21,16 @@ jobs:
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: docuseal/docuseal
|
||||
tags: latest,${{ inputs.version }}
|
||||
tags: type=semver,pattern={{version}}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: ${{ inputs.image }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Create .version file
|
||||
run: echo ${{ inputs.version }} > .version
|
||||
run: echo ${{ github.ref_name }} > .version
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
|
||||
@@ -37,3 +37,4 @@ yarn-debug.log*
|
||||
/docuseal
|
||||
/ee
|
||||
dump.rdb
|
||||
*.onnx
|
||||
|
||||
@@ -45,6 +45,12 @@ Metrics/PerceivedComplexity:
|
||||
Style/MultipleComparison:
|
||||
Enabled: false
|
||||
|
||||
Style/NumericPredicate:
|
||||
Enabled: false
|
||||
|
||||
Style/MinMaxComparison:
|
||||
Enabled: false
|
||||
|
||||
Naming/PredicateMethod:
|
||||
Enabled: false
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ RUN apk --no-cache add fontforge wget && \
|
||||
wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && \
|
||||
wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && \
|
||||
wget https://github.com/impallari/DancingScript/raw/master/OFL.txt && \
|
||||
wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/2.1.0/model_704_int8.onnx" && \
|
||||
wget -O pdfium-linux.tgz "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-linux-$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tgz" && \
|
||||
mkdir -p /pdfium-linux && \
|
||||
tar -xzf pdfium-linux.tgz -C /pdfium-linux
|
||||
@@ -50,7 +51,9 @@ ENV OPENSSL_CONF=/app/openssl_legacy.cnf
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN echo '@edge https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev@edge yaml-dev redis libheif@edge vips-heif@edge gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
|
||||
RUN apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev yaml-dev redis libheif vips-heif gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
|
||||
|
||||
RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal
|
||||
|
||||
RUN echo $'.include = /etc/ssl/openssl.cnf\n\
|
||||
\n\
|
||||
@@ -66,7 +69,9 @@ activate = 1' >> /app/openssl_legacy.cnf
|
||||
|
||||
COPY ./Gemfile ./Gemfile.lock ./
|
||||
|
||||
RUN apk add --no-cache build-base && bundle install && apk del --no-cache build-base && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf
|
||||
RUN apk add --no-cache build-base && bundle install && apk del --no-cache build-base && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf && ln -sf /usr/lib/libonnxruntime.so.1 $(ruby -e "print Dir[Gem::Specification.find_by_name('onnxruntime').gem_dir + '/vendor/*.so'].first")
|
||||
|
||||
RUN echo 'https://dl-cdn.alpinelinux.org/alpine/edge/main' >> /etc/apk/repositories && echo 'https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache onnxruntime
|
||||
|
||||
COPY ./bin ./bin
|
||||
COPY ./app ./app
|
||||
@@ -83,12 +88,16 @@ COPY --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.
|
||||
COPY --from=download /fonts/FreeSans.ttf /usr/share/fonts/freefont
|
||||
COPY --from=download /pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so
|
||||
COPY --from=download /pdfium-linux/licenses/pdfium.txt /usr/lib/libpdfium-LICENSE.txt
|
||||
COPY --from=download /model.onnx /app/tmp/model.onnx
|
||||
COPY --from=webpack /app/public/packs ./public/packs
|
||||
|
||||
RUN ln -s /fonts /app/public/fonts
|
||||
RUN bundle exec bootsnap precompile --gemfile app/ lib/
|
||||
RUN bundle exec bootsnap precompile -j 1 --gemfile app/ lib/
|
||||
|
||||
RUN chown -R docuseal:docuseal /app
|
||||
|
||||
WORKDIR /data/docuseal
|
||||
ENV HOME=/home/docuseal
|
||||
ENV WORKDIR=/data/docuseal
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -24,7 +24,9 @@ gem 'image_processing'
|
||||
gem 'jwt'
|
||||
gem 'lograge'
|
||||
gem 'mysql2', require: false
|
||||
gem 'numo-narray'
|
||||
gem 'oj'
|
||||
gem 'onnxruntime'
|
||||
gem 'pagy'
|
||||
gem 'pg', require: false
|
||||
gem 'premailer-rails'
|
||||
|
||||
@@ -77,24 +77,27 @@ GEM
|
||||
annotaterb (4.14.0)
|
||||
arabic-letter-connector (0.1.1)
|
||||
ast (2.4.3)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.1027.0)
|
||||
aws-sdk-core (3.214.0)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1197.0)
|
||||
aws-sdk-core (3.240.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.96.0)
|
||||
aws-sdk-core (~> 3, >= 3.210.0)
|
||||
logger
|
||||
aws-sdk-kms (1.118.0)
|
||||
aws-sdk-core (~> 3, >= 3.239.1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.176.1)
|
||||
aws-sdk-core (~> 3, >= 3.210.0)
|
||||
aws-sdk-s3 (1.208.0)
|
||||
aws-sdk-core (~> 3, >= 3.234.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-secretsmanager (1.110.0)
|
||||
aws-sdk-core (~> 3, >= 3.210.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.10.1)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
azure-storage-blob (2.0.3)
|
||||
azure-storage-common (~> 2.0)
|
||||
@@ -357,9 +360,18 @@ GEM
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.9-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
numo-narray (0.9.2.1)
|
||||
oj (3.16.11)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
onnxruntime (0.10.1)
|
||||
ffi
|
||||
onnxruntime (0.10.1-aarch64-linux)
|
||||
ffi
|
||||
onnxruntime (0.10.1-arm64-darwin)
|
||||
ffi
|
||||
onnxruntime (0.10.1-x86_64-linux)
|
||||
ffi
|
||||
openssl (3.3.0)
|
||||
orm_adapter (0.5.0)
|
||||
os (1.1.4)
|
||||
@@ -575,7 +587,7 @@ GEM
|
||||
unicode-emoji (~> 4.1)
|
||||
unicode-emoji (4.1.0)
|
||||
uniform_notifier (1.16.0)
|
||||
uri (1.0.3)
|
||||
uri (1.1.1)
|
||||
useragent (0.16.11)
|
||||
warden (1.2.9)
|
||||
rack (>= 2.0.9)
|
||||
@@ -638,7 +650,9 @@ DEPENDENCIES
|
||||
letter_opener_web
|
||||
lograge
|
||||
mysql2
|
||||
numo-narray
|
||||
oj
|
||||
onnxruntime
|
||||
pagy
|
||||
pg
|
||||
premailer-rails
|
||||
|
||||
@@ -46,7 +46,9 @@ class AccountsController < ApplicationController
|
||||
def destroy
|
||||
authorize!(:manage, current_account)
|
||||
|
||||
true_user.skip_reconfirmation!
|
||||
true_user.update!(locked_at: Time.current, email: true_user.email.sub('@', '+removed@'))
|
||||
true_user.account.update!(archived_at: Time.current)
|
||||
|
||||
# rubocop:disable Layout/LineLength
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
|
||||
@@ -34,6 +34,10 @@ module Api
|
||||
end
|
||||
|
||||
render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
|
||||
rescue Submitters::MaliciousFileExtension => e
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def build_new_cookie_signatures_json(submitter, attachment)
|
||||
|
||||
@@ -5,34 +5,17 @@ module Api
|
||||
load_and_authorize_resource :submission
|
||||
|
||||
def index
|
||||
is_merge = params[:merge] == 'true' &&
|
||||
(@submission.schema_documents || @submission.template.schema_documents).size > 1
|
||||
|
||||
documents =
|
||||
if @submission.submitters.all?(&:completed_at?)
|
||||
last_submitter = @submission.submitters.max_by(&:completed_at)
|
||||
|
||||
if last_submitter.documents_attachments.blank?
|
||||
last_submitter.documents_attachments = Submissions::EnsureResultGenerated.call(last_submitter)
|
||||
end
|
||||
|
||||
last_submitter.documents_attachments
|
||||
build_completed_documents(@submission, merge: is_merge)
|
||||
else
|
||||
values_hash = Submissions::GeneratePreviewAttachments.build_values_hash(@submission)
|
||||
|
||||
if @submission.preview_documents.present? &&
|
||||
@submission.preview_documents.all? { |s| s.metadata['values_hash'] == values_hash }
|
||||
@submission.preview_documents
|
||||
else
|
||||
ApplicationRecord.no_touching do
|
||||
@submission.preview_documents.each(&:destroy)
|
||||
end
|
||||
|
||||
Submissions::GeneratePreviewAttachments.call(@submission, values_hash:)
|
||||
end
|
||||
build_preview_documents(@submission, merge: is_merge)
|
||||
end
|
||||
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: documents,
|
||||
associations: [:blob]
|
||||
).call
|
||||
ActiveRecord::Associations::Preloader.new(records: documents, associations: [:blob]).call
|
||||
|
||||
expires_at = Accounts.link_expires_at(current_account)
|
||||
|
||||
@@ -43,5 +26,50 @@ module Api
|
||||
end
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_completed_documents(submission, merge: false)
|
||||
last_submitter = submission.submitters.max_by(&:completed_at)
|
||||
|
||||
if merge
|
||||
if submission.merged_document_attachment.blank?
|
||||
submission.merged_document_attachment =
|
||||
Submissions::GenerateCombinedAttachment.call(last_submitter, with_audit: false)
|
||||
end
|
||||
|
||||
[submission.merged_document_attachment]
|
||||
else
|
||||
if last_submitter.documents_attachments.blank?
|
||||
last_submitter.documents_attachments = Submissions::EnsureResultGenerated.call(last_submitter)
|
||||
end
|
||||
|
||||
last_submitter.documents_attachments
|
||||
end
|
||||
end
|
||||
|
||||
def build_preview_documents(submission, merge: false)
|
||||
values_hash = Submissions::GeneratePreviewAttachments.build_values_hash(submission)
|
||||
|
||||
if merge
|
||||
if submission.preview_merged_document_attachment.present? &&
|
||||
submission.preview_merged_document_attachment.metadata['values_hash'] == values_hash
|
||||
[submission.preview_merged_document_attachment]
|
||||
else
|
||||
ApplicationRecord.no_touching { submission.preview_merged_document_attachment&.destroy }
|
||||
|
||||
Submissions::GeneratePreviewAttachments.call(submission, values_hash:, merge: true)
|
||||
end
|
||||
elsif submission.preview_documents.present? &&
|
||||
submission.preview_documents.all? { |s| s.metadata['values_hash'] == values_hash }
|
||||
submission.preview_documents
|
||||
else
|
||||
ApplicationRecord.no_touching do
|
||||
submission.preview_documents.each(&:destroy)
|
||||
end
|
||||
|
||||
Submissions::GeneratePreviewAttachments.call(submission, values_hash:)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -182,13 +182,13 @@ module Api
|
||||
def submissions_params
|
||||
permitted_attrs = [
|
||||
:send_email, :send_sms, :bcc_completed, :completed_redirect_url, :reply_to, :go_to_last,
|
||||
:require_phone_2fa, :expire_at, :name,
|
||||
:require_phone_2fa, :require_email_2fa, :expire_at, :name,
|
||||
{
|
||||
variables: {},
|
||||
message: %i[subject body],
|
||||
submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role,
|
||||
:completed, :phone, :application_key, :external_id, :reply_to, :go_to_last,
|
||||
:require_phone_2fa, :order,
|
||||
:require_phone_2fa, :require_email_2fa, :order, :invite_by,
|
||||
{ metadata: {}, values: {}, roles: [], readonly_fields: [], message: %i[subject body],
|
||||
fields: [:name, :uuid, :default_value, :value, :title, :description,
|
||||
:readonly, :required, :validation_pattern, :invalid_message,
|
||||
|
||||
@@ -84,7 +84,7 @@ module Api
|
||||
|
||||
submitter_params.permit(
|
||||
:send_email, :send_sms, :reply_to, :completed_redirect_url, :uuid, :name, :email, :role,
|
||||
:completed, :phone, :application_key, :external_id, :go_to_last, :require_phone_2fa,
|
||||
:completed, :phone, :application_key, :external_id, :go_to_last, :require_phone_2fa, :require_email_2fa,
|
||||
{ metadata: {}, values: {}, readonly_fields: [], message: %i[subject body],
|
||||
fields: [[:name, :uuid, :default_value, :value, :required,
|
||||
:readonly, :validation_pattern, :invalid_message,
|
||||
|
||||
@@ -17,6 +17,7 @@ class ApplicationController < ActionController::Base
|
||||
|
||||
helper_method :button_title,
|
||||
:current_account,
|
||||
:true_ability,
|
||||
:form_link_host,
|
||||
:svg_icon
|
||||
|
||||
@@ -102,6 +103,10 @@ class ApplicationController < ActionController::Base
|
||||
current_user&.account
|
||||
end
|
||||
|
||||
def true_ability
|
||||
@true_ability ||= Ability.new(true_user)
|
||||
end
|
||||
|
||||
def maybe_redirect_to_setup
|
||||
redirect_to setup_index_path unless User.exists?
|
||||
end
|
||||
|
||||
@@ -17,8 +17,10 @@ class ConsoleRedirectController < ApplicationController
|
||||
scope: :console,
|
||||
exp: 1.minute.from_now.to_i)
|
||||
|
||||
path = Addressable::URI.parse(params[:redir]).path if params[:redir].to_s.starts_with?(Docuseal::CONSOLE_URL)
|
||||
redir_uri = Addressable::URI.parse(params[:redir])
|
||||
path = redir_uri.path if params[:redir].to_s.starts_with?(Docuseal::CONSOLE_URL)
|
||||
|
||||
redirect_to("#{Docuseal::CONSOLE_URL}#{path}?#{{ auth: }.to_query}", allow_other_host: true)
|
||||
redirect_to "#{Docuseal::CONSOLE_URL}#{path}?#{{ **redir_uri&.query_values, 'auth' => auth }.to_query}",
|
||||
allow_other_host: true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class InvitationsController < Devise::PasswordsController
|
||||
def update
|
||||
super do |resource|
|
||||
resource.confirmed_at ||= Time.current if resource.errors.empty?
|
||||
|
||||
PasswordsController::Current.user = resource
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,6 +5,8 @@ class PasswordsController < Devise::PasswordsController
|
||||
skip_before_action :require_no_authentication, only: %i[edit update]
|
||||
# rubocop:enable Rails/LexicallyScopedActionFilter
|
||||
|
||||
around_action :with_browser_locale
|
||||
|
||||
class Current < ActiveSupport::CurrentAttributes
|
||||
attribute :user
|
||||
end
|
||||
|
||||
@@ -4,6 +4,7 @@ class PersonalizationSettingsController < ApplicationController
|
||||
ALLOWED_KEYS = [
|
||||
AccountConfig::FORM_COMPLETED_BUTTON_KEY,
|
||||
AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY,
|
||||
AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY,
|
||||
AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY,
|
||||
AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY,
|
||||
AccountConfig::FORM_COMPLETED_MESSAGE_KEY,
|
||||
|
||||
@@ -9,7 +9,14 @@ class ProfileController < ApplicationController
|
||||
|
||||
def update_contact
|
||||
if current_user.update(contact_params)
|
||||
redirect_to settings_profile_index_path, notice: I18n.t('contact_information_has_been_update')
|
||||
if current_user.try(:pending_reconfirmation?) && current_user.previous_changes.key?(:unconfirmed_email)
|
||||
SendConfirmationInstructionsJob.perform_async('user_id' => current_user.id)
|
||||
|
||||
redirect_to settings_profile_index_path,
|
||||
notice: I18n.t('a_confirmation_email_has_been_sent_to_the_new_email_address')
|
||||
else
|
||||
redirect_to settings_profile_index_path, notice: I18n.t('contact_information_has_been_update')
|
||||
end
|
||||
else
|
||||
render :index, status: :unprocessable_content
|
||||
end
|
||||
|
||||
@@ -29,7 +29,7 @@ class SendSubmissionEmailController < ApplicationController
|
||||
|
||||
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
|
||||
|
||||
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! unless already_sent?(@submitter)
|
||||
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter)
|
||||
|
||||
respond_to do |f|
|
||||
f.html { render :success }
|
||||
@@ -39,8 +39,11 @@ class SendSubmissionEmailController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def already_sent?(submitter)
|
||||
EmailEvent.exists?(tag: :submitter_documents_copy, email: submitter.email, emailable: submitter,
|
||||
event_type: :send, created_at: SEND_DURATION.ago..Time.current)
|
||||
def can_send?(submitter)
|
||||
return false if submitter.account.archived_at?
|
||||
return false if EmailEvent.exists?(tag: :submitter_documents_copy, email: submitter.email, emailable: submitter,
|
||||
event_type: :send, created_at: SEND_DURATION.ago..Time.current)
|
||||
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,7 +16,9 @@ class StartFormController < ApplicationController
|
||||
COOKIES_DEFAULTS = { httponly: true, secure: Rails.env.production? }.freeze
|
||||
|
||||
def show
|
||||
raise ActionController::RoutingError, I18n.t('not_found') if @template.preferences['require_phone_2fa']
|
||||
if @template.preferences['require_phone_2fa'] || @template.preferences['require_email_2fa']
|
||||
raise ActionController::RoutingError, I18n.t('not_found')
|
||||
end
|
||||
|
||||
if @template.shared_link?
|
||||
@submitter = @template.submissions.new(account_id: @template.account_id)
|
||||
@@ -81,7 +83,7 @@ class StartFormController < ApplicationController
|
||||
|
||||
@submitter = Submitter.where(submission: @template.submissions)
|
||||
.where.not(completed_at: nil)
|
||||
.find_by!(required_params)
|
||||
.find_by!(required_params.except('name'))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -10,6 +10,7 @@ class SubmissionEventsController < ApplicationController
|
||||
'api_complete_form' => 'check',
|
||||
'send_reminder_email' => 'mail_forward',
|
||||
'send_2fa_sms' => '2fa',
|
||||
'send_2fa_email' => '2fa',
|
||||
'send_sms' => 'send',
|
||||
'phone_verified' => 'phone_check',
|
||||
'email_verified' => 'email_check',
|
||||
|
||||
@@ -12,6 +12,14 @@ class SubmissionsController < ApplicationController
|
||||
authorize!(:create, Submission)
|
||||
end
|
||||
|
||||
FIELD_ICONS = {
|
||||
'text' => 'text_size', 'signature' => 'writing_sign', 'date' => 'calendar_event',
|
||||
'number' => 'square_number_1', 'image' => 'photo', 'initials' => 'letter_case_upper',
|
||||
'file' => 'paperclip', 'select' => 'select', 'checkbox' => 'checkbox', 'radio' => 'circle_dot',
|
||||
'stamp' => 'rubber_stamp', 'cells' => 'columns_3', 'multiple' => 'checks', 'phone' => 'phone_check',
|
||||
'payment' => 'credit_card', 'verification' => 'id', 'kba' => 'user_scan'
|
||||
}.freeze
|
||||
|
||||
def show
|
||||
@submission = Submissions.preload_with_pages(@submission)
|
||||
|
||||
@@ -45,14 +53,15 @@ class SubmissionsController < ApplicationController
|
||||
else
|
||||
submissions_attrs = submissions_params[:submission].to_h.values
|
||||
|
||||
submissions_attrs, =
|
||||
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_attrs, @template)
|
||||
submissions_attrs, _, new_fields =
|
||||
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_attrs, @template, add_fields: true)
|
||||
|
||||
Submissions.create_from_submitters(template: @template,
|
||||
user: current_user,
|
||||
source: :invite,
|
||||
submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random',
|
||||
submissions_attrs:,
|
||||
new_fields:,
|
||||
params: params.merge('send_completed_email' => true))
|
||||
end
|
||||
|
||||
|
||||
@@ -41,6 +41,9 @@ class SubmissionsPreviewController < ApplicationController
|
||||
|
||||
def completed
|
||||
@submission = Submission.find_by!(slug: params[:submissions_preview_slug])
|
||||
|
||||
raise ActionController::RoutingError, I18n.t('not_found') if @submission.account.archived_at?
|
||||
|
||||
@template = @submission.template
|
||||
|
||||
render :completed, layout: 'form'
|
||||
@@ -50,8 +53,11 @@ class SubmissionsPreviewController < ApplicationController
|
||||
|
||||
def use_signature?(submission)
|
||||
return false if current_user && can?(:read, submission)
|
||||
return true if submission.submitters.any? { |e| e.preferences['require_phone_2fa'] }
|
||||
return true if submission.submitters.any? do |e|
|
||||
e.preferences['require_phone_2fa'] || e.preferences['require_email_2fa']
|
||||
end
|
||||
return true if submission.template&.preferences&.dig('require_phone_2fa')
|
||||
return true if submission.template&.preferences&.dig('require_email_2fa')
|
||||
|
||||
!submission_valid_ttl?(submission)
|
||||
end
|
||||
|
||||
@@ -17,6 +17,7 @@ class SubmitFormController < ApplicationController
|
||||
submission = @submitter.submission
|
||||
|
||||
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
|
||||
return render :email_2fa if require_email_2fa?(@submitter)
|
||||
|
||||
@form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS)
|
||||
|
||||
@@ -47,6 +48,11 @@ class SubmitFormController < ApplicationController
|
||||
end
|
||||
|
||||
def update
|
||||
if require_email_2fa?(@submitter)
|
||||
return render json: { error: I18n.t('verification_required_refresh_the_page_and_pass_2fa') },
|
||||
status: :unprocessable_content
|
||||
end
|
||||
|
||||
if @submitter.completed_at?
|
||||
return render json: { error: I18n.t('form_has_been_completed_already') }, status: :unprocessable_content
|
||||
end
|
||||
@@ -75,7 +81,11 @@ class SubmitFormController < ApplicationController
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def completed; end
|
||||
def completed
|
||||
raise ActionController::RoutingError, I18n.t('not_found') if @submitter.account.archived_at?
|
||||
|
||||
redirect_to submit_form_path(params[:submit_form_slug]) if require_email_2fa?(@submitter)
|
||||
end
|
||||
|
||||
def success; end
|
||||
|
||||
@@ -107,4 +117,12 @@ class SubmitFormController < ApplicationController
|
||||
ActiveStorage::Attachment.where(record: submission.submitters, name: :attachments)
|
||||
.preload(:blob).index_by(&:uuid)
|
||||
end
|
||||
|
||||
def require_email_2fa?(submitter)
|
||||
return false if submitter.submission.template&.preferences&.dig('require_email_2fa') != true &&
|
||||
submitter.preferences['require_email_2fa'] != true
|
||||
return false if cookies.encrypted[:email_2fa_slug] == submitter.slug
|
||||
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmitFormEmail2fasController < ApplicationController
|
||||
around_action :with_browser_locale
|
||||
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
before_action :load_submitter
|
||||
|
||||
COOKIES_TTL = 12.hours
|
||||
COOKIES_DEFAULTS = { httponly: true, secure: Rails.env.production? }.freeze
|
||||
|
||||
def create
|
||||
RateLimit.call("verify-2fa-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true)
|
||||
|
||||
value = [@submitter.email.downcase.strip, @submitter.slug].join(':')
|
||||
|
||||
if EmailVerificationCodes.verify(params[:one_time_code].to_s.gsub(/\D/, ''), value)
|
||||
SubmissionEvents.create_with_tracking_data(@submitter, 'email_verified', request, { email: @submitter.email })
|
||||
|
||||
cookies.encrypted[:email_2fa_slug] =
|
||||
{ value: @submitter.slug, expires: COOKIES_TTL.from_now, **COOKIES_DEFAULTS }
|
||||
|
||||
redirect_to submit_form_path(@submitter.slug)
|
||||
else
|
||||
redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:invalid_code)
|
||||
end
|
||||
rescue RateLimit::LimitApproached
|
||||
redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:too_many_attempts)
|
||||
end
|
||||
|
||||
def update
|
||||
if @submitter.submission_events.where(event_type: 'send_2fa_email').exists?(created_at: 15.seconds.ago..)
|
||||
return redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:rate_limit_exceeded)
|
||||
end
|
||||
|
||||
RateLimit.call("send-email-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true)
|
||||
|
||||
SendSubmitterVerificationEmailJob.perform_async('submitter_id' => @submitter.id, 'locale' => I18n.locale.to_s)
|
||||
|
||||
redir_params = params[:resend] ? { alert: I18n.t(:code_has_been_resent) } : {}
|
||||
|
||||
redirect_to submit_form_path(@submitter.slug, status: :sent), **redir_params
|
||||
rescue RateLimit::LimitApproached
|
||||
redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:too_many_attempts)
|
||||
end
|
||||
|
||||
def load_submitter
|
||||
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||
end
|
||||
end
|
||||
@@ -3,6 +3,12 @@
|
||||
class TemplateDocumentsController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
FILES_TTL = 5.minutes
|
||||
|
||||
def index
|
||||
render json: @template.schema_documents.map { |d| ActiveStorage::Blob.proxy_url(d.blob, expires_at: FILES_TTL.from_now.to_i) }
|
||||
end
|
||||
|
||||
def create
|
||||
if params[:blobs].blank? && params[:files].blank?
|
||||
return render json: { error: I18n.t('file_is_missing') }, status: :unprocessable_content
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplatesCloneController < ApplicationController
|
||||
load_and_authorize_resource :template, instance_name: :base_template
|
||||
|
||||
def new
|
||||
authorize!(:create, Template)
|
||||
|
||||
@template = Template.new(name: "#{@base_template.name} (#{I18n.t('clone')})")
|
||||
end
|
||||
|
||||
def create
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@base_template],
|
||||
associations: [schema_documents: :preview_images_attachments]
|
||||
).call
|
||||
|
||||
@template = Templates::Clone.call(@base_template, author: current_user,
|
||||
name: params.dig(:template, :name),
|
||||
folder_name: params[:folder_name])
|
||||
|
||||
authorize!(:create, @template)
|
||||
|
||||
if params[:account_id].present? && true_ability.authorize!(:manage, Account.find(params[:account_id]))
|
||||
@template.account_id = params[:account_id]
|
||||
@template.author = true_user if true_user.account_id == @template.account_id
|
||||
@template.folder = @template.account.default_template_folder if @template.account_id != current_account.id
|
||||
else
|
||||
@template.account = current_account
|
||||
end
|
||||
|
||||
Templates.maybe_assign_access(@template)
|
||||
|
||||
if @template.save
|
||||
Templates::CloneAttachments.call(template: @template, original_template: @base_template)
|
||||
|
||||
SearchEntries.enqueue_reindex(@template)
|
||||
|
||||
WebhookUrls.enqueue_events(@template, 'template.created')
|
||||
|
||||
maybe_redirect_to_template(@template)
|
||||
else
|
||||
render turbo_stream: turbo_stream.replace(:modal, partial: 'templates_clone/form'), status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def maybe_redirect_to_template(template)
|
||||
if template.account == current_account
|
||||
redirect_to(edit_template_path(template))
|
||||
else
|
||||
redirect_back(fallback_location: root_path, notice: I18n.t('template_has_been_cloned'))
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,8 +3,6 @@
|
||||
class TemplatesController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
before_action :load_base_template, only: %i[new create]
|
||||
|
||||
def show
|
||||
submissions = @template.submissions.accessible_by(current_ability)
|
||||
submissions = submissions.active if @template.archived_at.blank?
|
||||
@@ -26,9 +24,7 @@ class TemplatesController < ApplicationController
|
||||
redirect_to root_path
|
||||
end
|
||||
|
||||
def new
|
||||
@template.name = "#{@base_template.name} (#{I18n.t('clone')})" if @base_template
|
||||
end
|
||||
def new; end
|
||||
|
||||
def edit
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
@@ -48,37 +44,18 @@ class TemplatesController < ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
if @base_template
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@base_template],
|
||||
associations: [schema_documents: :preview_images_attachments]
|
||||
).call
|
||||
|
||||
@template = Templates::Clone.call(@base_template, author: current_user,
|
||||
name: params.dig(:template, :name),
|
||||
folder_name: params[:folder_name])
|
||||
else
|
||||
@template.author = current_user
|
||||
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
|
||||
end
|
||||
|
||||
if params[:account_id].present? && authorized_clone_account_id?(params[:account_id])
|
||||
@template.account_id = params[:account_id]
|
||||
@template.folder = @template.account.default_template_folder if @template.account_id != current_account.id
|
||||
else
|
||||
@template.account = current_account
|
||||
end
|
||||
@template.author = current_user
|
||||
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
|
||||
@template.account = current_account
|
||||
|
||||
Templates.maybe_assign_access(@template)
|
||||
|
||||
if @template.save
|
||||
Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template
|
||||
|
||||
SearchEntries.enqueue_reindex(@template)
|
||||
|
||||
WebhookUrls.enqueue_events(@template, 'template.created')
|
||||
|
||||
maybe_redirect_to_template(@template)
|
||||
redirect_to(edit_template_path(@template))
|
||||
else
|
||||
render turbo_stream: turbo_stream.replace(:modal, template: 'templates/new'), status: :unprocessable_content
|
||||
end
|
||||
@@ -132,23 +109,4 @@ class TemplatesController < ApplicationController
|
||||
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] }
|
||||
)
|
||||
end
|
||||
|
||||
def authorized_clone_account_id?(account_id)
|
||||
true_user.account_id.to_s == account_id.to_s ||
|
||||
true_user.account.linked_accounts.accessible_by(current_ability).exists?(id: account_id)
|
||||
end
|
||||
|
||||
def maybe_redirect_to_template(template)
|
||||
if template.account == current_account
|
||||
redirect_to(edit_template_path(@template))
|
||||
else
|
||||
redirect_back(fallback_location: root_path, notice: I18n.t('template_has_been_cloned'))
|
||||
end
|
||||
end
|
||||
|
||||
def load_base_template
|
||||
return if params[:base_template_id].blank?
|
||||
|
||||
@base_template = Template.accessible_by(current_ability).find_by(id: params[:base_template_id])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,12 +6,18 @@ class TemplatesDebugController < ApplicationController
|
||||
DEBUG_FILE = ''
|
||||
|
||||
def show
|
||||
attachment = @template.documents.first
|
||||
schema_uuids = @template.schema.index_by { |e| e['attachment_uuid'] }
|
||||
attachment = @template.documents.find { |a| schema_uuids[a.uuid] }
|
||||
|
||||
data = attachment.download
|
||||
pdf = HexaPDF::Document.new(io: StringIO.new(data))
|
||||
|
||||
fields = Templates::FindAcroFields.call(pdf, attachment, data)
|
||||
unless attachment.image?
|
||||
pdf = HexaPDF::Document.new(io: StringIO.new(data))
|
||||
|
||||
fields = Templates::FindAcroFields.call(pdf, attachment, data)
|
||||
end
|
||||
|
||||
fields, = Templates::DetectFields.call(StringIO.new(data), attachment:) if fields.blank?
|
||||
|
||||
attachment.metadata['pdf'] ||= {}
|
||||
attachment.metadata['pdf']['fields'] = fields
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplatesDetectFieldsController < ApplicationController
|
||||
include ActionController::Live
|
||||
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def create
|
||||
response.headers['Content-Type'] = 'text/event-stream'
|
||||
|
||||
sse = SSE.new(response.stream)
|
||||
|
||||
documents = @template.schema_documents.preload(:blob)
|
||||
|
||||
documents.each do |document|
|
||||
io = StringIO.new(document.download)
|
||||
|
||||
Templates::DetectFields.call(io, attachment: document) do |(attachment_uuid, page, fields)|
|
||||
sse.write({ attachment_uuid:, page:, fields: })
|
||||
end
|
||||
end
|
||||
|
||||
sse.write({ completed: true })
|
||||
ensure
|
||||
response.stream.close
|
||||
end
|
||||
end
|
||||
@@ -3,6 +3,15 @@
|
||||
class TemplatesPreferencesController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
RESETTABLE_PREFERENCE_KEYS = {
|
||||
AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY => %w[request_email_subject request_email_body submitters],
|
||||
AccountConfig::SUBMITTER_INVITATION_REMINDER_EMAIL_KEY => %w[invitation_reminder_email_subject
|
||||
invitation_reminder_email_body],
|
||||
AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => %w[documents_copy_email_subject documents_copy_email_body],
|
||||
AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY => %w[completed_notification_email_subject
|
||||
completed_notification_email_body]
|
||||
}.freeze
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@@ -15,19 +24,38 @@ class TemplatesPreferencesController < ApplicationController
|
||||
head :ok
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize!(:update, @template)
|
||||
|
||||
config_key = params[:config_key]
|
||||
preferences_to_delete = RESETTABLE_PREFERENCE_KEYS[config_key]
|
||||
|
||||
return head :ok if preferences_to_delete.blank?
|
||||
|
||||
preferences_to_delete.each do |key|
|
||||
@template.preferences.delete(key)
|
||||
end
|
||||
|
||||
@template.save!
|
||||
|
||||
render turbo_stream: turbo_stream.replace("#{config_key}_form",
|
||||
partial: "templates_preferences/#{config_key}_form"),
|
||||
status: :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def template_params
|
||||
params.require(:template).permit(
|
||||
preferences: %i[bcc_completed request_email_subject request_email_body
|
||||
invitation_reminder_email_subject invitation_reminder_email_body
|
||||
documents_copy_email_subject documents_copy_email_body
|
||||
documents_copy_email_enabled documents_copy_email_attach_audit
|
||||
documents_copy_email_attach_documents documents_copy_email_reply_to
|
||||
completed_notification_email_attach_documents
|
||||
completed_redirect_url validate_unique_submitters
|
||||
require_all_submitters submitters_order require_phone_2fa
|
||||
default_expire_at_duration shared_link_2fa
|
||||
default_expire_at request_email_enabled
|
||||
require_all_submitters submitters_order require_phone_2fa require_email_2fa
|
||||
default_expire_at_duration shared_link_2fa default_expire_at request_email_enabled
|
||||
completed_notification_email_subject completed_notification_email_body
|
||||
completed_notification_email_enabled completed_notification_email_attach_audit] +
|
||||
[completed_message: %i[title body],
|
||||
|
||||
@@ -24,10 +24,19 @@ class UsersController < ApplicationController
|
||||
def edit; end
|
||||
|
||||
def create
|
||||
if User.accessible_by(current_ability).exists?(email: @user.email)
|
||||
@user.errors.add(:email, I18n.t('already_exists'))
|
||||
existing_user = User.accessible_by(current_ability).find_by(email: @user.email)
|
||||
|
||||
return render turbo_stream: turbo_stream.replace(:modal, template: 'users/new'), status: :unprocessable_content
|
||||
if existing_user
|
||||
if existing_user.archived_at? &&
|
||||
current_ability.can?(:manage, existing_user) && current_ability.can?(:manage, @user.account)
|
||||
existing_user.assign_attributes(@user.slice(:first_name, :last_name, :role, :account_id))
|
||||
existing_user.archived_at = nil
|
||||
@user = existing_user
|
||||
else
|
||||
@user.errors.add(:email, I18n.t('already_exists'))
|
||||
|
||||
return render turbo_stream: turbo_stream.replace(:modal, template: 'users/new'), status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
@user.password = SecureRandom.hex if @user.password.blank?
|
||||
@@ -45,7 +54,8 @@ class UsersController < ApplicationController
|
||||
def update
|
||||
return redirect_to settings_users_path, notice: I18n.t('unable_to_update_user') if Docuseal.demo?
|
||||
|
||||
attrs = user_params.compact_blank.merge(user_params.slice(:archived_at))
|
||||
attrs = user_params.compact_blank
|
||||
attrs = attrs.merge(user_params.slice(:archived_at)) if current_ability.can?(:create, @user)
|
||||
|
||||
if params.dig(:user, :account_id).present?
|
||||
account = Account.accessible_by(current_ability).find(params.dig(:user, :account_id))
|
||||
@@ -56,7 +66,14 @@ class UsersController < ApplicationController
|
||||
end
|
||||
|
||||
if @user.update(attrs.except(*(current_user == @user ? %i[password otp_required_for_login role] : %i[password])))
|
||||
redirect_back fallback_location: settings_users_path, notice: I18n.t('user_has_been_updated')
|
||||
if @user.try(:pending_reconfirmation?) && @user.previous_changes.key?(:unconfirmed_email)
|
||||
SendConfirmationInstructionsJob.perform_async('user_id' => @user.id)
|
||||
|
||||
redirect_back fallback_location: settings_users_path,
|
||||
notice: I18n.t('a_confirmation_email_has_been_sent_to_the_new_email_address')
|
||||
else
|
||||
redirect_back fallback_location: settings_users_path, notice: I18n.t('user_has_been_updated')
|
||||
end
|
||||
else
|
||||
render turbo_stream: turbo_stream.replace(:modal, template: 'users/edit'), status: :unprocessable_content
|
||||
end
|
||||
|
||||
@@ -51,6 +51,8 @@ import ToggleClasses from './elements/toggle_classes'
|
||||
import AutosizeField from './elements/autosize_field'
|
||||
import GoogleDriveFilePicker from './elements/google_drive_file_picker'
|
||||
import OpenModal from './elements/open_modal'
|
||||
import BarChart from './elements/bar_chart'
|
||||
import FieldCondition from './elements/field_condition'
|
||||
|
||||
import * as TurboInstantClick from './lib/turbo_instant_click'
|
||||
|
||||
@@ -140,6 +142,8 @@ safeRegisterElement('toggle-classes', ToggleClasses)
|
||||
safeRegisterElement('autosize-field', AutosizeField)
|
||||
safeRegisterElement('google-drive-file-picker', GoogleDriveFilePicker)
|
||||
safeRegisterElement('open-modal', OpenModal)
|
||||
safeRegisterElement('bar-chart', BarChart)
|
||||
safeRegisterElement('field-condition', FieldCondition)
|
||||
|
||||
safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
@@ -155,7 +159,9 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
locale: this.dataset.locale,
|
||||
withPhone: this.dataset.withPhone === 'true',
|
||||
withVerification: ['true', 'false'].includes(this.dataset.withVerification) ? this.dataset.withVerification === 'true' : null,
|
||||
withKba: ['true', 'false'].includes(this.dataset.withKba) ? this.dataset.withKba === 'true' : null,
|
||||
withLogo: this.dataset.withLogo !== 'false',
|
||||
withFieldsDetection: this.dataset.withFieldsDetection === 'true',
|
||||
editable: this.dataset.editable !== 'false',
|
||||
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
|
||||
withPayment: this.dataset.withPayment === 'true',
|
||||
@@ -166,6 +172,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
withConditions: this.dataset.withConditions === 'true',
|
||||
withGoogleDrive: this.dataset.withGoogleDrive === 'true',
|
||||
withReplaceAndCloneUpload: true,
|
||||
withDownload: true,
|
||||
currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
|
||||
acceptFileTypes: this.dataset.acceptFileTypes,
|
||||
showTourStartForm: this.dataset.showTourStartForm === 'true'
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.chartLabels = JSON.parse(this.dataset.labels || '[]')
|
||||
this.chartDatasets = JSON.parse(this.dataset.datasets || '[]')
|
||||
|
||||
this.initChart()
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
if (this.chartInstance) {
|
||||
this.chartInstance.destroy()
|
||||
this.chartInstance = null
|
||||
}
|
||||
}
|
||||
|
||||
async initChart () {
|
||||
const { default: Chart } = await import(/* webpackChunkName: "chartjs" */ 'chart.js/auto')
|
||||
|
||||
const canvas = this.querySelector('canvas')
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
this.chartInstance = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: this.chartLabels,
|
||||
datasets: this.chartDatasets
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
animation: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grace: '20%',
|
||||
ticks: {
|
||||
precision: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.targetId = this.dataset.targetId
|
||||
this.fieldId = this.dataset.fieldId
|
||||
this.action = (this.dataset.action || '').trim()
|
||||
this.expectedValue = this.dataset.value
|
||||
|
||||
this.targetEl = document.getElementById(this.targetId)
|
||||
this.sourceEl = document.getElementById(this.fieldId)
|
||||
|
||||
this.bindListeners()
|
||||
|
||||
this.evaluateAndApply()
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.unbindListeners()
|
||||
}
|
||||
|
||||
bindListeners () {
|
||||
this.eventsFor(this.sourceEl).forEach((ev) => {
|
||||
this.sourceEl.addEventListener(ev, this.evaluateAndApply)
|
||||
})
|
||||
}
|
||||
|
||||
unbindListeners () {
|
||||
this.eventsFor(this.sourceEl).forEach((ev) => {
|
||||
this.sourceEl.removeEventListener(ev, this.evaluateAndApply)
|
||||
})
|
||||
}
|
||||
|
||||
eventsFor (el) {
|
||||
if (!el) return []
|
||||
|
||||
const tag = el.tagName.toLowerCase()
|
||||
|
||||
if (tag === 'textarea') return ['input']
|
||||
if (tag === 'input') return ['input', 'change']
|
||||
|
||||
return ['change']
|
||||
}
|
||||
|
||||
evaluateAndApply = () => {
|
||||
const fieldConditions = document.querySelectorAll(`field-condition[data-target-id="${this.targetId}"]`)
|
||||
|
||||
const result = [...fieldConditions].reduce((acc, cond) => {
|
||||
if (cond.dataset.operation === 'or') {
|
||||
acc.push(acc.pop() || cond.checkCondition())
|
||||
} else {
|
||||
acc.push(cond.checkCondition())
|
||||
}
|
||||
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
this.apply(!result.includes(false))
|
||||
}
|
||||
|
||||
checkCondition () {
|
||||
const action = this.action
|
||||
const actual = this.getSourceValue()
|
||||
const expected = this.expectedValue
|
||||
|
||||
if (action === 'empty' || action === 'unchecked') return this.isEmpty(actual)
|
||||
if (action === 'not_empty' || action === 'checked') return !this.isEmpty(actual)
|
||||
|
||||
if (action === 'equal') {
|
||||
const list = Array.isArray(actual) ? actual : [actual]
|
||||
return list.filter((v) => v !== null && v !== undefined).map(String).includes(String(expected))
|
||||
}
|
||||
|
||||
if (action === 'contains') return this.contains(actual, expected)
|
||||
|
||||
if (action === 'not_equal') {
|
||||
const list = Array.isArray(actual) ? actual : [actual]
|
||||
return !list.filter((v) => v !== null && v !== undefined).map(String).includes(String(expected))
|
||||
}
|
||||
|
||||
if (action === 'does_not_contain') return !this.contains(actual, expected)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
getSourceValue () {
|
||||
const el = this.sourceEl
|
||||
|
||||
if (!el) return
|
||||
|
||||
const tag = el.tagName.toLowerCase()
|
||||
const type = (el.getAttribute('type') || '').toLowerCase()
|
||||
|
||||
if (tag === 'select') return el.value
|
||||
if (tag === 'textarea') return el.value
|
||||
if (tag === 'input' && type === 'checkbox') return el.checked ? (el.value || '1') : null
|
||||
if (tag === 'input') return el.value
|
||||
|
||||
return el.value ?? null
|
||||
}
|
||||
|
||||
isEmpty (obj) {
|
||||
if (obj == null) return true
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.length === 0
|
||||
}
|
||||
|
||||
if (typeof obj === 'string') {
|
||||
return obj.trim().length === 0
|
||||
}
|
||||
|
||||
if (typeof obj === 'object') {
|
||||
return Object.keys(obj).length === 0
|
||||
}
|
||||
|
||||
if (obj === false) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
contains (actual, expected) {
|
||||
if (expected === null || expected === undefined) return false
|
||||
|
||||
const exp = String(expected)
|
||||
|
||||
if (Array.isArray(actual)) return actual.filter((v) => v !== null && v !== undefined).map(String).includes(exp)
|
||||
|
||||
if (typeof actual === 'string') return actual.includes(exp)
|
||||
|
||||
return actual !== null && actual !== undefined && String(actual) === exp
|
||||
}
|
||||
|
||||
apply (passed) {
|
||||
const controls = this.targetEl.matches('input, select, textarea, button')
|
||||
? [this.targetEl]
|
||||
: Array.from(this.targetEl.querySelectorAll('input, select, textarea, button'))
|
||||
|
||||
if (passed) {
|
||||
this.targetEl.style.display = ''
|
||||
this.targetEl.labels.forEach((label) => { label.style.display = '' })
|
||||
|
||||
controls.forEach((c) => (c.disabled = false))
|
||||
} else {
|
||||
this.targetEl.style.display = 'none'
|
||||
this.targetEl.labels.forEach((label) => { label.style.display = 'none' })
|
||||
|
||||
controls.forEach((c) => (c.disabled = true))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ safeRegisterElement('submission-form', class extends HTMLElement {
|
||||
withSignatureId: this.dataset.withSignatureId === 'true',
|
||||
requireSigningReason: this.dataset.requireSigningReason === 'true',
|
||||
withConfetti: this.dataset.withConfetti !== 'false',
|
||||
withFieldLabels: this.dataset.withFieldLabels !== 'false',
|
||||
withDisclosure: this.dataset.withDisclosure === 'true',
|
||||
reuseSignature: this.dataset.reuseSignature !== 'false',
|
||||
withTypedSignature: this.dataset.withTypedSignature !== 'false',
|
||||
|
||||
@@ -60,6 +60,11 @@
|
||||
class="object-contain mx-auto"
|
||||
:src="stamp.url"
|
||||
>
|
||||
<img
|
||||
v-else-if="field.type === 'kba' && kba"
|
||||
class="object-contain mx-auto"
|
||||
:src="kba.url"
|
||||
>
|
||||
<div
|
||||
v-else-if="field.type === 'signature' && signature"
|
||||
class="flex justify-between h-full gap-1 overflow-hidden w-full"
|
||||
@@ -272,7 +277,7 @@
|
||||
|
||||
<script>
|
||||
import MarkdownContent from './markdown_content'
|
||||
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp, IconSquareNumber1, IconId } from '@tabler/icons-vue'
|
||||
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp, IconSquareNumber1, IconId, IconUserScan } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'FieldArea',
|
||||
@@ -391,7 +396,8 @@ export default {
|
||||
stamp: this.t('stamp'),
|
||||
payment: this.t('payment'),
|
||||
phone: this.t('phone'),
|
||||
verification: this.t('verify_id')
|
||||
verification: this.t('verify_id'),
|
||||
kba: this.t('kba')
|
||||
}
|
||||
},
|
||||
strikethroughWidth () {
|
||||
@@ -454,7 +460,8 @@ export default {
|
||||
multiple: IconChecks,
|
||||
phone: IconPhoneCheck,
|
||||
payment: IconCreditCard,
|
||||
verification: IconId
|
||||
verification: IconId,
|
||||
kba: IconUserScan
|
||||
}
|
||||
},
|
||||
image () {
|
||||
@@ -471,6 +478,13 @@ export default {
|
||||
return null
|
||||
}
|
||||
},
|
||||
kba () {
|
||||
if (this.field.type === 'kba') {
|
||||
return this.attachmentsIndex[this.modelValue]
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
},
|
||||
signature () {
|
||||
if (this.field.type === 'signature') {
|
||||
return this.attachmentsIndex[this.modelValue]
|
||||
@@ -553,10 +567,14 @@ export default {
|
||||
style.color = this.field.preferences.color
|
||||
}
|
||||
|
||||
if (this.field.preferences?.background) {
|
||||
style.background = this.field.preferences.background
|
||||
}
|
||||
|
||||
return style
|
||||
},
|
||||
isNarrow () {
|
||||
return this.area.h > 0 && (this.area.w / this.area.h) > 6
|
||||
return this.area.h > 0 && ((this.area.w * this.pageWidth) / (this.area.h * this.pageHeight)) > 4.5
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
||||
@@ -163,12 +163,20 @@ export default {
|
||||
return fetch(this.baseUrl + '/api/attachments', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}).then(resp => resp.json()).then((data) => {
|
||||
return data
|
||||
}).then(async (resp) => {
|
||||
const data = await resp.json()
|
||||
|
||||
if (resp.status === 422) {
|
||||
alert(data.error)
|
||||
} else {
|
||||
return data
|
||||
}
|
||||
})
|
||||
}
|
||||
})).then((result) => {
|
||||
this.$emit('upload', result)
|
||||
if (result && result[0]) {
|
||||
this.$emit('upload', result)
|
||||
}
|
||||
}).finally(() => {
|
||||
this.isLoading = false
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
:scroll-el="scrollEl"
|
||||
:with-signature-id="withSignatureId"
|
||||
:attachments-index="attachmentsIndex"
|
||||
:with-label="!isAnonymousChecboxes && showFieldNames"
|
||||
:with-label="withFieldLabels && !isAnonymousChecboxes && showFieldNames"
|
||||
:current-step="currentStepFields"
|
||||
:scroll-padding="scrollPadding"
|
||||
@focus-step="[saveStep(), currentField.type !== 'checkbox' ? isFormVisible = true : '', goToStep($event, false, true)]"
|
||||
@@ -133,6 +133,12 @@
|
||||
name="_method"
|
||||
type="hidden"
|
||||
>
|
||||
<input
|
||||
v-if="validate === false"
|
||||
value="false"
|
||||
name="validate"
|
||||
type="hidden"
|
||||
>
|
||||
<div class="md:mt-4">
|
||||
<div v-if="['cells', 'text'].includes(currentField.type)">
|
||||
<TextStep
|
||||
@@ -351,8 +357,6 @@
|
||||
class="base-checkbox !h-7 !w-7"
|
||||
:required="field.required"
|
||||
:checked="!!values[field.uuid]"
|
||||
@invalid="$event.target.setCustomValidity(t('please_check_the_box_to_continue'))"
|
||||
@change="$event.target.setCustomValidity($event.target.validity.valueMissing ? t('please_check_the_box_to_continue') : '')"
|
||||
@click="[scrollIntoField(field), values[field.uuid] = !values[field.uuid]]"
|
||||
>
|
||||
<span
|
||||
@@ -461,6 +465,17 @@
|
||||
@focus="scrollIntoField(currentField)"
|
||||
@submit="!isSubmitting && submitStep()"
|
||||
/>
|
||||
<KbaStep
|
||||
v-else-if="currentField.type === 'kba'"
|
||||
ref="currentStep"
|
||||
:key="currentField.uuid"
|
||||
:submitter="submitter"
|
||||
:empty-value-required-step="emptyValueRequiredStep"
|
||||
:field="currentField"
|
||||
:submitter-slug="submitterSlug"
|
||||
:values="values"
|
||||
@submit="!isSubmitting && submitStep()"
|
||||
/>
|
||||
<VerificationStep
|
||||
v-else-if="currentField.type === 'verification'"
|
||||
ref="currentStep"
|
||||
@@ -476,7 +491,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="(currentField.type !== 'payment' && currentField.type !== 'verification') || submittedValues[currentField.uuid]"
|
||||
v-if="(currentField.type !== 'payment' && currentField.type !== 'verification' && currentField.type !== 'kba') || submittedValues[currentField.uuid]"
|
||||
:class="currentField.type === 'signature' ? 'mt-2' : 'mt-4 md:mt-6'"
|
||||
>
|
||||
<button
|
||||
@@ -533,7 +548,7 @@
|
||||
/>
|
||||
<div
|
||||
v-if="stepFields.length < 80"
|
||||
class="flex justify-center mt-3 sm:mt-4 mb-0 sm:mb-1"
|
||||
class="flex justify-center mt-3 sm:mt-4 mb-0 sm:mb-1 select-none"
|
||||
>
|
||||
<div class="flex items-center flex-wrap steps-progress">
|
||||
<a
|
||||
@@ -565,6 +580,7 @@ import MultiSelectStep from './multi_select_step'
|
||||
import PhoneStep from './phone_step'
|
||||
import PaymentStep from './payment_step'
|
||||
import VerificationStep from './verification_step'
|
||||
import KbaStep from './kba_step'
|
||||
import TextStep from './text_step'
|
||||
import NumberStep from './number_step'
|
||||
import DateStep from './date_step'
|
||||
@@ -609,6 +625,7 @@ export default {
|
||||
AttachmentStep,
|
||||
InitialsStep,
|
||||
VerificationStep,
|
||||
KbaStep,
|
||||
InviteForm,
|
||||
MultiSelectStep,
|
||||
IconInnerShadowTop,
|
||||
@@ -718,6 +735,11 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
validate: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
withDisclosure: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -728,6 +750,11 @@ export default {
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
withFieldLabels: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
withConfetti: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -990,7 +1017,7 @@ export default {
|
||||
const verificationFields = []
|
||||
|
||||
const sortedFields = this.fields.reduce((acc, f) => {
|
||||
if (f.type === 'verification') {
|
||||
if (f.type === 'verification' || f.type === 'kba') {
|
||||
verificationFields.push(f)
|
||||
} else if (!f.readonly) {
|
||||
acc.push(f)
|
||||
@@ -1387,7 +1414,7 @@ export default {
|
||||
|
||||
const submitStep = this.currentStep
|
||||
|
||||
const stepPromise = ['signature', 'phone', 'initials', 'payment', 'verification'].includes(this.currentField.type)
|
||||
const stepPromise = ['signature', 'phone', 'initials', 'payment', 'verification', 'kba'].includes(this.currentField.type)
|
||||
? this.$refs.currentStep.submit
|
||||
: () => Promise.resolve({})
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const en = {
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Please upload an image file',
|
||||
must_be_characters_length: 'Must be {number} characters length',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete all required fields to proceed with identity verification.',
|
||||
@@ -13,7 +14,7 @@ const en = {
|
||||
esignature_disclosure: 'eSignature Disclosure',
|
||||
signature: 'Signature',
|
||||
initials: 'Initials',
|
||||
drawn_signature_on_a_touchscreen_device: 'Drawn signature on a touchscreen device',
|
||||
sign_on_the_touchscreen: 'Sign on the touchscreen',
|
||||
approved: 'Approved',
|
||||
reviewed: 'Reviewed',
|
||||
other: 'Other',
|
||||
@@ -100,6 +101,7 @@ const en = {
|
||||
}
|
||||
|
||||
const es = {
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Por favor, sube un archivo de imagen',
|
||||
must_be_characters_length: 'Debe tener {number} caracteres de longitud',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete todos los campos requeridos para continuar con la verificación de identidad.',
|
||||
@@ -120,7 +122,7 @@ const es = {
|
||||
select_a_reason: 'Selecciona una razón',
|
||||
value_is_invalid: 'El valor no es válido',
|
||||
verification_code_is_invalid: 'El código de verificación no es válido',
|
||||
drawn_signature_on_a_touchscreen_device: 'Firma dibujada en un dispositivo con pantalla táctil',
|
||||
sign_on_the_touchscreen: 'Firmar en pantalla táctil',
|
||||
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Escanea el código QR con la aplicación de la cámara para abrir el formulario en el móvil y dibujar tu firma',
|
||||
by_clicking_you_agree_to_the: 'Al hacer clic en "{button}", usted acepta el',
|
||||
electronic_signature_disclosure: 'Divulgación de Firma Electrónica',
|
||||
@@ -201,6 +203,7 @@ const es = {
|
||||
}
|
||||
|
||||
const it = {
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Per favore carica un file immagine',
|
||||
must_be_characters_length: 'Deve essere lungo {number} caratteri',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: "Compila tutti i campi obbligatori per procedere con la verifica dell'identità.",
|
||||
@@ -221,7 +224,7 @@ const it = {
|
||||
select_a_reason: 'Seleziona una ragione',
|
||||
value_is_invalid: 'Il valore non è valido',
|
||||
verification_code_is_invalid: 'Il codice di verifica non è valido',
|
||||
drawn_signature_on_a_touchscreen_device: 'Firma disegnata su un dispositivo con schermo tattile',
|
||||
sign_on_the_touchscreen: 'Firma su schermo tattile',
|
||||
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: "Scansiona il codice QR con l'app della fotocamera per aprire il modulo sul cellulare e disegnare la tua firma",
|
||||
by_clicking_you_agree_to_the: 'Cliccando su "{button}", accetti il',
|
||||
electronic_signature_disclosure: 'Divulgazione della Firma Elettronica',
|
||||
@@ -302,6 +305,7 @@ const it = {
|
||||
}
|
||||
|
||||
const de = {
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Bitte laden Sie eine Bilddatei hoch',
|
||||
must_be_characters_length: 'Muss {number} Zeichen lang sein',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Füllen Sie alle Pflichtfelder aus, um mit der Identitätsprüfung fortzufahren.',
|
||||
@@ -316,7 +320,7 @@ const de = {
|
||||
esignature_disclosure: 'Hinweis zur eSignatur',
|
||||
signature: 'Unterschrift',
|
||||
initials: 'Initialen',
|
||||
drawn_signature_on_a_touchscreen_device: 'Auf einem Touchscreen-Gerät gezeichnete Unterschrift',
|
||||
sign_on_the_touchscreen: 'Auf Touchscreen signieren',
|
||||
approved: 'Genehmigt',
|
||||
reviewed: 'Geprüft',
|
||||
other: 'Sonstiges',
|
||||
@@ -403,6 +407,7 @@ const de = {
|
||||
}
|
||||
|
||||
const fr = {
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Veuillez téléverser un fichier image',
|
||||
must_be_characters_length: 'Doit comporter {number} caractères',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour poursuivre la vérification d'identité.",
|
||||
@@ -417,7 +422,7 @@ const fr = {
|
||||
esignature_disclosure: 'Déclaration eSignature',
|
||||
signature: 'Signature',
|
||||
initials: 'Initiales',
|
||||
drawn_signature_on_a_touchscreen_device: 'Signature dessinée sur un appareil à écran tactile',
|
||||
sign_on_the_touchscreen: 'Signer sur écran tactile',
|
||||
approved: 'Approuvé',
|
||||
reviewed: 'Révisé',
|
||||
other: 'Autre',
|
||||
@@ -504,6 +509,7 @@ const fr = {
|
||||
}
|
||||
|
||||
const pl = {
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Proszę przesłać plik obrazu',
|
||||
must_be_characters_length: 'Musi mieć długość {number} znaków',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Uzupełnij wszystkie wymagane pola, aby kontynuować weryfikację tożsamości.',
|
||||
@@ -524,7 +530,7 @@ const pl = {
|
||||
select_a_reason: 'Wybierz powód',
|
||||
value_is_invalid: 'Wartość jest nieprawidłowa',
|
||||
verification_code_is_invalid: 'Kod weryfikacyjny jest nieprawidłowy',
|
||||
drawn_signature_on_a_touchscreen_device: 'Podpis odręczny na urządzeniu z ekranem dotykowym',
|
||||
sign_on_the_touchscreen: 'Podpisz na ekranie dotykowym',
|
||||
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Zeskanuj kod QR za pomocą aplikacji aparatu, aby otworzyć formularz na telefonie i narysować swój podpis',
|
||||
by_clicking_you_agree_to_the: 'Klikając na "{button}", zgadzasz się na',
|
||||
electronic_signature_disclosure: 'Ujawnienie Elektronicznej Sygnatury',
|
||||
@@ -605,6 +611,7 @@ const pl = {
|
||||
}
|
||||
|
||||
const uk = {
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Будь ласка, завантажте файл зображення',
|
||||
must_be_characters_length: 'Має містити {number} символів',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: "Заповніть всі обов'язкові поля, щоб продовжити перевірку особи.",
|
||||
@@ -625,7 +632,7 @@ const uk = {
|
||||
select_a_reason: 'Виберіть причину',
|
||||
value_is_invalid: 'Значення є неправильним',
|
||||
verification_code_is_invalid: 'Код підтвердження є неправильним',
|
||||
drawn_signature_on_a_touchscreen_device: 'Підпис на сенсорному пристрої',
|
||||
sign_on_the_touchscreen: 'Підписати на сенсорному екрані',
|
||||
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Скануйте QR-код за допомогою програми камери, щоб відкрити форму на мобільному пристрої та намалювати свій підпис',
|
||||
by_clicking_you_agree_to_the: 'Натиснувши на "{button}", ви погоджуєтеся з',
|
||||
electronic_signature_disclosure: 'Розголошення Електронного Підпису',
|
||||
@@ -706,6 +713,7 @@ const uk = {
|
||||
}
|
||||
|
||||
const cs = {
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Nahrajte prosím obrázkový soubor',
|
||||
must_be_characters_length: 'Musí mít délku {number} znaků',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Vyplňte všechna povinná pole, abyste mohli pokračovat v ověření identity.',
|
||||
@@ -726,7 +734,7 @@ const cs = {
|
||||
select_a_reason: 'Vyberte důvod',
|
||||
value_is_invalid: 'Hodnota je neplatná',
|
||||
verification_code_is_invalid: 'Ověřovací kód je neplatný',
|
||||
drawn_signature_on_a_touchscreen_device: 'Namalovaný podpis na dotykovém zařízení',
|
||||
sign_on_the_touchscreen: 'Podepsat na dotykové obrazovce',
|
||||
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Naskenujte QR kód pomocí aplikace fotoaparátu, abyste otevřeli formulář na mobilním zařízení a nakreslili svůj podpis',
|
||||
by_clicking_you_agree_to_the: 'Kliknutím na "{button}" souhlasíte s',
|
||||
electronic_signature_disclosure: 'Zveřejněním Elektronického Podpisu',
|
||||
@@ -807,6 +815,7 @@ const cs = {
|
||||
}
|
||||
|
||||
const pt = {
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Por favor, envie um arquivo de imagem',
|
||||
must_be_characters_length: 'Deve ter {number} caracteres',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Preencha todos os campos obrigatórios para prosseguir com a verificação de identidade.',
|
||||
@@ -827,7 +836,7 @@ const pt = {
|
||||
select_a_reason: 'Selecione um motivo',
|
||||
value_is_invalid: 'Valor é inválido',
|
||||
verification_code_is_invalid: 'Código de verificação é inválido',
|
||||
drawn_signature_on_a_touchscreen_device: 'Assinatura desenhada em um dispositivo com tela sensível ao toque',
|
||||
sign_on_the_touchscreen: 'Assinar na tela sensível',
|
||||
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Escaneie o código QR com o aplicativo da câmera para abrir o formulário no celular e desenhar sua assinatura',
|
||||
by_clicking_you_agree_to_the: 'Ao clicar em "{button}", você concorda com o',
|
||||
electronic_signature_disclosure: 'Divulgação de Assinatura Eletrônica',
|
||||
@@ -908,6 +917,7 @@ const pt = {
|
||||
}
|
||||
|
||||
const he = {
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'אנא העלה קובץ תמונה',
|
||||
must_be_characters_length: 'חייב להיות באורך של {number} תווים',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'מלא את כל השדות הנדרשים כדי להמשיך עם אימות זהות.',
|
||||
@@ -928,7 +938,7 @@ const he = {
|
||||
select_a_reason: 'בחר סיבה',
|
||||
value_is_invalid: 'ערך לא תקין',
|
||||
verification_code_is_invalid: 'קוד האימות אינו תקין',
|
||||
drawn_signature_on_a_touchscreen_device: 'חתימה שנוצרה במכשיר עם מסך מגע',
|
||||
sign_on_the_touchscreen: 'חתום על מסך המגע',
|
||||
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'סרוק את קוד ה-QR באמצעות אפליקציית המצלמה כדי לפתוח את הטופס במובייל ולצייר את החתימה שלך',
|
||||
by_clicking_you_agree_to_the: 'על ידי לחיצה על "{button}", אתה מסכים ל',
|
||||
electronic_signature_disclosure: 'חשיפת חתימה אלקטרונית',
|
||||
@@ -1009,6 +1019,7 @@ const he = {
|
||||
}
|
||||
|
||||
const nl = {
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'Upload alstublieft een afbeeldingsbestand',
|
||||
must_be_characters_length: 'Moet {number} tekens lang zijn',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Vul alle verplichte velden in om door te gaan met de identiteitsverificatie.',
|
||||
@@ -1029,7 +1040,7 @@ const nl = {
|
||||
select_a_reason: 'Selecteer een reden',
|
||||
value_is_invalid: 'Waarde is ongeldig',
|
||||
verification_code_is_invalid: 'Verificatiecode is ongeldig',
|
||||
drawn_signature_on_a_touchscreen_device: 'Getekende handtekening op een apparaat met een touchscreen',
|
||||
sign_on_the_touchscreen: 'Onderteken op touchscreen',
|
||||
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Scan de QR-code met de camera-app om het formulier op mobiel te openen en uw handtekening te tekenen',
|
||||
by_clicking_you_agree_to_the: 'Door op "{button}" te klikken, gaat u akkoord met de',
|
||||
electronic_signature_disclosure: 'Openbaarmaking van Elektronische Handtekening',
|
||||
@@ -1110,6 +1121,7 @@ const nl = {
|
||||
}
|
||||
|
||||
const ar = {
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: 'يرجى تحميل ملف صورة',
|
||||
must_be_characters_length: 'يجب أن يكون الطول {number} حرفًا',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'أكمل جميع الحقول المطلوبة للمتابعة في التحقق من الهوية.',
|
||||
@@ -1131,7 +1143,7 @@ const ar = {
|
||||
value_is_invalid: 'القيمة غير صالحة',
|
||||
verification_code_is_invalid: 'رمز التحقق غير صالح',
|
||||
already_paid: 'تم الدفع بالفعل',
|
||||
drawn_signature_on_a_touchscreen_device: 'توقيع مرسوم على جهاز بشاشة تعمل باللمس',
|
||||
sign_on_the_touchscreen: 'وقع على شاشة اللمس',
|
||||
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'امسح رمز الاستجابة السريعة باستخدام تطبيق الكاميرا لفتح النموذج على الهاتف المحمول ورسم توقيعك',
|
||||
by_clicking_you_agree_to_the: 'بالنقر فوق "{button}"، أنت توافق على',
|
||||
electronic_signature_disclosure: 'كشف التوقيع الإلكتروني',
|
||||
@@ -1211,6 +1223,7 @@ const ar = {
|
||||
}
|
||||
|
||||
const ko = {
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: '이미지 파일을 업로드해 주세요',
|
||||
must_be_characters_length: '{number}자여야 합니다',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: '신원 확인을 진행하려면 모든 필수 필드를 작성하십시오.',
|
||||
@@ -1229,7 +1242,7 @@ const ko = {
|
||||
reviewed_by: '검토자',
|
||||
authored_by: '작성자',
|
||||
select_a_reason: '이유 선택',
|
||||
drawn_signature_on_a_touchscreen_device: '터치스크린 장치에서 그린 서명',
|
||||
sign_on_the_touchscreen: '터치스크린에서 서명',
|
||||
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: '카메라 앱으로 QR 코드를 스캔하여 모바일에서 양식을 열고 서명을 그리세요',
|
||||
by_clicking_you_agree_to_the: '"{button}"를 클릭함으로써, 다음에 동의하게 됩니다',
|
||||
electronic_signature_disclosure: '전자 서명 공개',
|
||||
@@ -1312,6 +1325,7 @@ const ko = {
|
||||
}
|
||||
|
||||
const ja = {
|
||||
kba: 'KBA',
|
||||
please_upload_an_image_file: '画像ファイルをアップロードしてください',
|
||||
must_be_characters_length: '{number}文字でなければなりません',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: '本人確認を進めるには、すべての必須項目を入力してください。',
|
||||
@@ -1326,7 +1340,7 @@ const ja = {
|
||||
esignature_disclosure: '電子署名開示',
|
||||
signature: '署名',
|
||||
initials: 'イニシャル',
|
||||
drawn_signature_on_a_touchscreen_device: 'タッチスクリーンデバイスで描かれた署名',
|
||||
sign_on_the_touchscreen: 'タッチスクリーンで署名',
|
||||
approved: '承認済み',
|
||||
reviewed: '確認済み',
|
||||
other: 'その他',
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<div class="space-x-2 flex flex-none">
|
||||
<span
|
||||
v-if="isDrawInitials"
|
||||
class="tooltip"
|
||||
class="md:tooltip"
|
||||
:data-tip="t('type_initial')"
|
||||
>
|
||||
<a
|
||||
@@ -36,7 +36,7 @@
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="tooltip ml-2"
|
||||
class="md:tooltip ml-2"
|
||||
:data-tip="t('draw_initials')"
|
||||
>
|
||||
<a
|
||||
@@ -52,7 +52,7 @@
|
||||
</a>
|
||||
</span>
|
||||
<span
|
||||
class="tooltip"
|
||||
class="md:tooltip"
|
||||
:data-tip="t('click_to_upload')"
|
||||
>
|
||||
<label class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap upload-image-button">
|
||||
@@ -123,6 +123,11 @@
|
||||
v-if="!isDrawInitials"
|
||||
class="absolute top-0 right-0 left-0 bottom-0"
|
||||
/>
|
||||
<label
|
||||
v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
|
||||
for="initials_text_input"
|
||||
class="absolute top-0 right-0 left-0 bottom-0"
|
||||
/>
|
||||
<canvas
|
||||
v-show="!modelValue && !computedPreviousValue"
|
||||
ref="canvas"
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
<template>
|
||||
<label
|
||||
v-if="!error"
|
||||
class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5 field-name-label"
|
||||
>
|
||||
<MarkdownContent
|
||||
v-if="field.title"
|
||||
:string="field.title"
|
||||
/>
|
||||
<template v-else>{{ field.name || 'Knowledge Based Authentication' }}</template>
|
||||
<span
|
||||
v-if="questions"
|
||||
class="float-right font-normal text-neutral-500 mt-1 whitespace-nowrap text-right text-sm"
|
||||
>
|
||||
<span>Question {{ currentQuestionIndex + 1 }} / {{ questions.length }}</span>
|
||||
<span
|
||||
v-if="timeLeftSeconds !== null"
|
||||
class="block"
|
||||
>
|
||||
Time left: {{ formattedTimeLeft }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<div
|
||||
v-if="field.description"
|
||||
dir="auto"
|
||||
class="mb-4 px-1 field-description-text"
|
||||
>
|
||||
<MarkdownContent :string="field.description" />
|
||||
</div>
|
||||
<div
|
||||
v-if="isRequiredFieldEmpty"
|
||||
class="px-1 field-description-text"
|
||||
>
|
||||
{{ t('complete_all_required_fields_to_proceed_with_identity_verification') }}
|
||||
</div>
|
||||
<div
|
||||
v-if="error"
|
||||
class="mb-4 text-center"
|
||||
>
|
||||
<div class="text-xl mb-4">
|
||||
{{ error }}
|
||||
</div>
|
||||
<button
|
||||
class="base-button w-full flex justify-center submit-form-button"
|
||||
@click="restartKba"
|
||||
>
|
||||
{{ questions ? 'Restart KBA' : 'Retry' }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="w-full flex space-x-2 justify-center mb-2"
|
||||
>
|
||||
<IconInnerShadowTop
|
||||
width="40"
|
||||
class="animate-spin h-10"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="questions && !error">
|
||||
<form @submit.prevent="nextQuestion">
|
||||
<div class="mb-6 px-1">
|
||||
<p class="font-semibold mb-4 text-lg">
|
||||
{{ currentQuestion.prompt }}
|
||||
</p>
|
||||
<div class="space-y-3.5 mx-auto">
|
||||
<div
|
||||
v-for="(answer, index) in currentQuestion.answers"
|
||||
:key="`${currentQuestion.id}_${index}`"
|
||||
>
|
||||
<label
|
||||
:for="`${currentQuestion.id}_${index}`"
|
||||
class="flex items-center space-x-3 radio-label"
|
||||
>
|
||||
<input
|
||||
:id="`${currentQuestion.id}_${index}`"
|
||||
v-model="answers[currentQuestion.id]"
|
||||
type="radio"
|
||||
:name="currentQuestion.id"
|
||||
:value="answer.text"
|
||||
class="base-radio !h-7 !w-7"
|
||||
required
|
||||
>
|
||||
<span class="text-xl">{{ answer.text }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<button
|
||||
type="submit"
|
||||
class="base-button w-full flex justify-center submit-form-button"
|
||||
:disabled="isSubmitting || !answers[currentQuestion.id]"
|
||||
>
|
||||
<span class="flex">
|
||||
<IconInnerShadowTop
|
||||
v-if="isSubmitting"
|
||||
class="mr-1 animate-spin"
|
||||
/>
|
||||
<span>
|
||||
{{ isLastQuestion ? (isSubmitting ? t('submitting_') : t('complete')) : t('next') }}
|
||||
</span><span
|
||||
v-if="isSubmitting"
|
||||
class="w-6 flex justify-start mr-1"
|
||||
><span>...</span></span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div v-else-if="!error && !isRequiredFieldEmpty">
|
||||
<form @submit.prevent="startKba">
|
||||
<div class="grid grid-cols-6 gap-x-2 md:gap-x-4 md:gap-y-2 mb-4">
|
||||
<div class="col-span-3">
|
||||
<label
|
||||
for="kba_fn"
|
||||
class="label text-sm md:text-base"
|
||||
>First Name</label>
|
||||
<input
|
||||
id="kba_fn"
|
||||
v-model="form.fn"
|
||||
type="text"
|
||||
class="input input-bordered !h-10 w-full bg-white"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div class="col-span-3">
|
||||
<label
|
||||
for="kba_ln"
|
||||
class="label text-sm md:text-base"
|
||||
>Last Name</label>
|
||||
<input
|
||||
id="kba_ln"
|
||||
v-model="form.ln"
|
||||
type="text"
|
||||
class="input input-bordered !h-10 w-full bg-white"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div class="col-span-6">
|
||||
<label
|
||||
for="kba_addr"
|
||||
class="label text-sm md:text-base"
|
||||
>Address</label>
|
||||
<input
|
||||
id="kba_addr"
|
||||
v-model="form.addr"
|
||||
type="text"
|
||||
class="input input-bordered !h-10 w-full bg-white"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label
|
||||
for="kba_city"
|
||||
class="label text-sm md:text-base"
|
||||
>City</label>
|
||||
<input
|
||||
id="kba_city"
|
||||
v-model="form.city"
|
||||
type="text"
|
||||
class="input input-bordered !h-10 w-full bg-white"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label
|
||||
for="kba_state"
|
||||
class="label text-sm md:text-base"
|
||||
>State</label>
|
||||
<select
|
||||
id="kba_state"
|
||||
v-model="form.state"
|
||||
class="select select-bordered !h-10 min-h-[2.5rem] w-full bg-white font-normal !text-base"
|
||||
required
|
||||
>
|
||||
<option
|
||||
value=""
|
||||
disabled
|
||||
>
|
||||
Select State
|
||||
</option>
|
||||
<option
|
||||
v-for="state in states"
|
||||
:key="state.code"
|
||||
:value="state.code"
|
||||
>
|
||||
{{ state.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label
|
||||
for="kba_zip"
|
||||
class="label text-sm md:text-base"
|
||||
>Zip</label>
|
||||
<input
|
||||
id="kba_zip"
|
||||
v-model="form.zip"
|
||||
type="text"
|
||||
class="input input-bordered !h-10 w-full bg-white"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div class="col-span-3">
|
||||
<label
|
||||
for="kba_phone"
|
||||
class="label text-sm md:text-base"
|
||||
>Phone</label>
|
||||
<input
|
||||
id="kba_phone"
|
||||
v-model="form.phone"
|
||||
type="text"
|
||||
class="input input-bordered !h-10 w-full bg-white"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div class="col-span-3">
|
||||
<label
|
||||
for="kba_email"
|
||||
class="label text-sm md:text-base"
|
||||
>Email</label>
|
||||
<input
|
||||
id="kba_email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
class="input input-bordered !h-10 w-full bg-white"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div class="col-span-3">
|
||||
<label
|
||||
for="kba_dob"
|
||||
class="label text-sm md:text-base"
|
||||
>DOB</label>
|
||||
<input
|
||||
id="kba_dob"
|
||||
v-model="form.dob"
|
||||
type="date"
|
||||
class="input input-bordered !h-10 md:w-full bg-white"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div class="col-span-3">
|
||||
<label
|
||||
for="kba_ssn"
|
||||
class="label text-sm md:text-base"
|
||||
>SSN</label>
|
||||
<input
|
||||
id="kba_ssn"
|
||||
v-model="form.ssn"
|
||||
type="text"
|
||||
class="input input-bordered !h-10 w-full bg-white"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<button
|
||||
type="submit"
|
||||
class="base-button w-full flex justify-center submit-form-button"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<span class="flex">
|
||||
<IconInnerShadowTop
|
||||
v-if="isLoading"
|
||||
class="mr-1 animate-spin"
|
||||
/>
|
||||
<span>
|
||||
{{ isLoading ? 'Loading...' : 'Start Verification' }}
|
||||
</span><span
|
||||
v-if="isLoading"
|
||||
class="w-6 flex justify-start mr-1"
|
||||
><span>...</span></span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MarkdownContent from './markdown_content'
|
||||
import { IconInnerShadowTop } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'KbaStep',
|
||||
components: {
|
||||
MarkdownContent,
|
||||
IconInnerShadowTop
|
||||
},
|
||||
inject: ['baseUrl', 't'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
submitter: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
submitterSlug: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
emptyValueRequiredStep: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
values: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['submit'],
|
||||
data () {
|
||||
return {
|
||||
isLoading: false,
|
||||
isSubmitting: false,
|
||||
questions: null,
|
||||
currentQuestionIndex: 0,
|
||||
token: null,
|
||||
reference: null,
|
||||
answers: {},
|
||||
error: null,
|
||||
timeLeftSeconds: null,
|
||||
countdownIntervalId: null,
|
||||
form: {
|
||||
fn: '',
|
||||
ln: '',
|
||||
addr: '',
|
||||
city: '',
|
||||
state: '',
|
||||
zip: '',
|
||||
dob: '',
|
||||
ssn: '',
|
||||
phone: '',
|
||||
email: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentQuestion () {
|
||||
return this.questions ? this.questions[this.currentQuestionIndex] : null
|
||||
},
|
||||
isRequiredFieldEmpty () {
|
||||
return this.emptyValueRequiredStep && this.emptyValueRequiredStep[0] !== this.field
|
||||
},
|
||||
kbaTimeLimitSeconds () {
|
||||
return 90
|
||||
},
|
||||
states () {
|
||||
return [
|
||||
{ code: 'AL', name: 'Alabama' },
|
||||
{ code: 'AK', name: 'Alaska' },
|
||||
{ code: 'AZ', name: 'Arizona' },
|
||||
{ code: 'AR', name: 'Arkansas' },
|
||||
{ code: 'CA', name: 'California' },
|
||||
{ code: 'CO', name: 'Colorado' },
|
||||
{ code: 'CT', name: 'Connecticut' },
|
||||
{ code: 'DE', name: 'Delaware' },
|
||||
{ code: 'DC', name: 'District of Columbia' },
|
||||
{ code: 'FL', name: 'Florida' },
|
||||
{ code: 'GA', name: 'Georgia' },
|
||||
{ code: 'HI', name: 'Hawaii' },
|
||||
{ code: 'ID', name: 'Idaho' },
|
||||
{ code: 'IL', name: 'Illinois' },
|
||||
{ code: 'IN', name: 'Indiana' },
|
||||
{ code: 'IA', name: 'Iowa' },
|
||||
{ code: 'KS', name: 'Kansas' },
|
||||
{ code: 'KY', name: 'Kentucky' },
|
||||
{ code: 'LA', name: 'Louisiana' },
|
||||
{ code: 'ME', name: 'Maine' },
|
||||
{ code: 'MD', name: 'Maryland' },
|
||||
{ code: 'MA', name: 'Massachusetts' },
|
||||
{ code: 'MI', name: 'Michigan' },
|
||||
{ code: 'MN', name: 'Minnesota' },
|
||||
{ code: 'MS', name: 'Mississippi' },
|
||||
{ code: 'MO', name: 'Missouri' },
|
||||
{ code: 'MT', name: 'Montana' },
|
||||
{ code: 'NE', name: 'Nebraska' },
|
||||
{ code: 'NV', name: 'Nevada' },
|
||||
{ code: 'NH', name: 'New Hampshire' },
|
||||
{ code: 'NJ', name: 'New Jersey' },
|
||||
{ code: 'NM', name: 'New Mexico' },
|
||||
{ code: 'NY', name: 'New York' },
|
||||
{ code: 'NC', name: 'North Carolina' },
|
||||
{ code: 'ND', name: 'North Dakota' },
|
||||
{ code: 'OH', name: 'Ohio' },
|
||||
{ code: 'OK', name: 'Oklahoma' },
|
||||
{ code: 'OR', name: 'Oregon' },
|
||||
{ code: 'PA', name: 'Pennsylvania' },
|
||||
{ code: 'RI', name: 'Rhode Island' },
|
||||
{ code: 'SC', name: 'South Carolina' },
|
||||
{ code: 'SD', name: 'South Dakota' },
|
||||
{ code: 'TN', name: 'Tennessee' },
|
||||
{ code: 'TX', name: 'Texas' },
|
||||
{ code: 'UT', name: 'Utah' },
|
||||
{ code: 'VT', name: 'Vermont' },
|
||||
{ code: 'VA', name: 'Virginia' },
|
||||
{ code: 'WA', name: 'Washington' },
|
||||
{ code: 'WV', name: 'West Virginia' },
|
||||
{ code: 'WI', name: 'Wisconsin' },
|
||||
{ code: 'WY', name: 'Wyoming' }
|
||||
]
|
||||
},
|
||||
isLastQuestion () {
|
||||
return this.questions && this.currentQuestionIndex === this.questions.length - 1
|
||||
},
|
||||
formattedTimeLeft () {
|
||||
if (this.timeLeftSeconds === null) return ''
|
||||
|
||||
const minutes = Math.floor(this.timeLeftSeconds / 60)
|
||||
const seconds = this.timeLeftSeconds % 60
|
||||
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`
|
||||
}
|
||||
},
|
||||
beforeUnmount () {
|
||||
this.clearCountdown()
|
||||
},
|
||||
methods: {
|
||||
clearCountdown () {
|
||||
if (this.countdownIntervalId) {
|
||||
clearInterval(this.countdownIntervalId)
|
||||
this.countdownIntervalId = null
|
||||
}
|
||||
|
||||
this.timeLeftSeconds = null
|
||||
},
|
||||
startCountdown () {
|
||||
this.clearCountdown()
|
||||
|
||||
this.timeLeftSeconds = this.kbaTimeLimitSeconds
|
||||
|
||||
this.countdownIntervalId = setInterval(() => {
|
||||
if (this.timeLeftSeconds === null) return
|
||||
|
||||
this.timeLeftSeconds -= 1
|
||||
|
||||
if (this.timeLeftSeconds <= 0) {
|
||||
this.handleTimeout()
|
||||
}
|
||||
}, 1000)
|
||||
},
|
||||
handleTimeout () {
|
||||
this.clearCountdown()
|
||||
|
||||
this.questions = null
|
||||
this.token = null
|
||||
this.reference = null
|
||||
this.answers = {}
|
||||
this.currentQuestionIndex = 0
|
||||
this.error = `Knowledge Based Authentication timed out. You only have ${this.kbaTimeLimitSeconds} seconds to complete the verification. Please retry.`
|
||||
},
|
||||
nextQuestion () {
|
||||
if (this.isLastQuestion) {
|
||||
this.$emit('submit')
|
||||
} else {
|
||||
this.currentQuestionIndex++
|
||||
}
|
||||
},
|
||||
restartKba () {
|
||||
this.clearCountdown()
|
||||
|
||||
this.questions = null
|
||||
this.token = null
|
||||
this.reference = null
|
||||
this.answers = {}
|
||||
this.currentQuestionIndex = 0
|
||||
this.error = null
|
||||
},
|
||||
async startKba () {
|
||||
this.isLoading = true
|
||||
this.error = null
|
||||
this.clearCountdown()
|
||||
|
||||
try {
|
||||
const payload = { ...this.form, submitter_slug: this.submitterSlug }
|
||||
|
||||
if (payload.dob) {
|
||||
payload.dob = payload.dob.replace(/-/g, '')
|
||||
}
|
||||
|
||||
if (payload.ssn) {
|
||||
payload.ssn = payload.ssn.replace(/\D/g, '')
|
||||
}
|
||||
|
||||
if (payload.phone) {
|
||||
payload.phone = payload.phone.replace(/^\+1/, '')
|
||||
}
|
||||
|
||||
const resp = await fetch(this.baseUrl + '/api/kba', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
if (!resp.ok) throw new Error('Failed to start KBA')
|
||||
|
||||
const data = await resp.json()
|
||||
|
||||
if (data.result && data.result.action === 'FAIL') {
|
||||
if (data.result.detail === 'NO MATCH') {
|
||||
throw new Error('Unfortunately, we were unable to start Knowledge Based Authentication with the details provided. Please review and confirm that all your personal details are correct.')
|
||||
}
|
||||
|
||||
throw new Error(data.result.detail || 'KBA Start Failed')
|
||||
}
|
||||
|
||||
if (data.output && data.output.questions && data.output.questions.questions) {
|
||||
this.questions = data.output.questions.questions
|
||||
this.token = data.continuations.questions.template.token
|
||||
this.reference = data.meta.reference
|
||||
|
||||
this.questions.forEach(q => {
|
||||
this.answers[q.id] = null
|
||||
})
|
||||
|
||||
this.startCountdown()
|
||||
} else {
|
||||
throw new Error('Invalid KBA response')
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = e.message
|
||||
} finally {
|
||||
this.isLoading = false
|
||||
}
|
||||
},
|
||||
async submit () {
|
||||
this.clearCountdown()
|
||||
|
||||
this.isSubmitting = true
|
||||
this.error = null
|
||||
|
||||
const formattedAnswers = Object.keys(this.answers).reduce((acc, key) => {
|
||||
acc[key] = [this.answers[key]]
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
try {
|
||||
const resp = await fetch(this.baseUrl + `/api/kba/${this.field.uuid}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
token: this.token,
|
||||
answers: formattedAnswers,
|
||||
reference: this.reference,
|
||||
submitter_slug: this.submitterSlug
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
|
||||
const data = await resp.json()
|
||||
|
||||
if (data.result?.action !== 'PASS') {
|
||||
this.error = 'Knowledge Based Authentication Failed - make sure you provide correct answers for the Knowledge Based authentication.'
|
||||
|
||||
throw new Error('Knowledge Based Authentication Failed')
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
this.error = 'Failed to submit answers'
|
||||
|
||||
throw new Error('Failed to submit answers')
|
||||
}
|
||||
|
||||
return resp
|
||||
} finally {
|
||||
this.isSubmitting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -100,7 +100,7 @@
|
||||
<input
|
||||
:id="field.uuid"
|
||||
ref="phone"
|
||||
:value="phoneValue"
|
||||
:value="defaultValue && detectedPhoneValueDialCode ? phoneValue.split('+' + detectedPhoneValueDialCode).slice(-1).join('') : phoneValue"
|
||||
:readonly="!!defaultValue"
|
||||
class="base-input !text-2xl !rounded-l-none !border-l-0 !outline-none w-full"
|
||||
autocomplete="tel"
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<div class="space-x-2 flex flex-none">
|
||||
<span
|
||||
v-if="isTextSignature && format !== 'typed_or_upload' && format !== 'typed' && format !== 'upload'"
|
||||
class="tooltip"
|
||||
class="md:tooltip"
|
||||
:data-tip="t('draw_signature')"
|
||||
>
|
||||
<a
|
||||
@@ -39,7 +39,7 @@
|
||||
</span>
|
||||
<span
|
||||
v-else-if="withTypedSignature && format !== 'drawn_or_upload' && format !== 'typed_or_upload' && format !== 'typed' && format !== 'drawn' && format !== 'upload'"
|
||||
class="tooltip ml-2"
|
||||
class="md:tooltip ml-2"
|
||||
:class="{ 'hidden sm:inline': modelValue || computedPreviousValue }"
|
||||
:data-tip="t('type_text')"
|
||||
>
|
||||
@@ -57,7 +57,7 @@
|
||||
</span>
|
||||
<span
|
||||
v-if="format !== 'typed' && format !== 'drawn' && format !== 'upload' && format !== 'drawn_or_typed'"
|
||||
class="tooltip"
|
||||
class="md:tooltip"
|
||||
:class="{ 'hidden sm:inline': modelValue || computedPreviousValue }"
|
||||
:data-tip="t('take_photo')"
|
||||
>
|
||||
@@ -86,8 +86,8 @@
|
||||
</a>
|
||||
<span
|
||||
v-if="withQrButton && !modelValue && !computedPreviousValue && format !== 'typed_or_upload' && format !== 'typed' && format !== 'upload'"
|
||||
class="tooltip before:translate-x-[-90%]"
|
||||
:data-tip="t('drawn_signature_on_a_touchscreen_device')"
|
||||
class="md:tooltip before:translate-x-[-90%]"
|
||||
:data-tip="t('sign_on_the_touchscreen')"
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
@@ -142,7 +142,7 @@
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="relative"
|
||||
class="relative select-none"
|
||||
>
|
||||
<div
|
||||
v-if="!modelValue && !computedPreviousValue && !isShowQr && !isTextSignature && isSignatureStarted"
|
||||
@@ -176,7 +176,7 @@
|
||||
class="top-0 bottom-0 right-0 left-0 absolute bg-base-content/10 rounded-2xl"
|
||||
>
|
||||
<div
|
||||
class="absolute top-1.5 right-1.5 tooltip"
|
||||
class="absolute top-1.5 right-1.5 md:tooltip"
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
@@ -211,7 +211,7 @@
|
||||
@input="updateWrittenSignature"
|
||||
>
|
||||
<select
|
||||
v-if="requireSigningReason && !isOtherReason"
|
||||
v-if="withSigningReason && !isOtherReason"
|
||||
class="select base-input !text-2xl w-full mt-6 text-center"
|
||||
:class="{ 'text-gray-300': !reason }"
|
||||
required
|
||||
@@ -226,24 +226,37 @@
|
||||
>
|
||||
{{ t('select_a_reason') }}
|
||||
</option>
|
||||
<option
|
||||
v-for="(label, option) in defaultReasons"
|
||||
:key="option"
|
||||
:value="option"
|
||||
:selected="reason === option"
|
||||
class="text-base-content"
|
||||
>
|
||||
{{ label }}
|
||||
</option>
|
||||
<option
|
||||
value="other"
|
||||
class="text-base-content"
|
||||
>
|
||||
{{ t('other') }}
|
||||
</option>
|
||||
<template v-if="field.preferences?.reasons">
|
||||
<option
|
||||
v-for="option in field.preferences.reasons"
|
||||
:key="option"
|
||||
:value="option"
|
||||
:selected="reason === option"
|
||||
class="text-base-content"
|
||||
>
|
||||
{{ option }}
|
||||
</option>
|
||||
</template>
|
||||
<template v-else>
|
||||
<option
|
||||
v-for="(label, option) in defaultReasons"
|
||||
:key="option"
|
||||
:value="option"
|
||||
:selected="reason === option"
|
||||
class="text-base-content"
|
||||
>
|
||||
{{ label }}
|
||||
</option>
|
||||
<option
|
||||
value="other"
|
||||
class="text-base-content"
|
||||
>
|
||||
{{ t('other') }}
|
||||
</option>
|
||||
</template>
|
||||
</select>
|
||||
<input
|
||||
v-if="requireSigningReason && isOtherReason"
|
||||
v-if="withSigningReason && isOtherReason"
|
||||
class="base-input !text-2xl w-full mt-6"
|
||||
required
|
||||
:name="`values[${field.preferences.reason_field_uuid}]`"
|
||||
@@ -253,7 +266,7 @@
|
||||
@input="$emit('update:reason', $event.target.value)"
|
||||
>
|
||||
<input
|
||||
v-if="requireSigningReason"
|
||||
v-if="withSigningReason"
|
||||
hidden
|
||||
name="with_reason"
|
||||
:value="field.preferences.reason_field_uuid"
|
||||
@@ -268,7 +281,7 @@
|
||||
<div
|
||||
v-else-if="withDisclosure"
|
||||
dir="auto"
|
||||
class="text-base-content/60 text-xs text-center w-full mt-1"
|
||||
class="text-base-content/60 text-xs text-center w-full mt-1 select-none"
|
||||
>
|
||||
{{ t('by_clicking_you_agree_to_the').replace('{button}', buttonText.charAt(0).toUpperCase() + buttonText.slice(1)) }} <a
|
||||
href="https://www.docuseal.com/esign-disclosure"
|
||||
@@ -406,6 +419,9 @@ export default {
|
||||
format () {
|
||||
return this.field.preferences?.format
|
||||
},
|
||||
withSigningReason () {
|
||||
return this.requireSigningReason || this.field.preferences?.reasons?.length
|
||||
},
|
||||
defaultReasons () {
|
||||
return {
|
||||
[this.t('approved_by')]: this.t('approved'),
|
||||
@@ -424,10 +440,11 @@ export default {
|
||||
created () {
|
||||
this.isSignatureStarted = !!this.computedPreviousValue
|
||||
|
||||
if (this.requireSigningReason) {
|
||||
if (this.withSigningReason) {
|
||||
this.field.preferences ||= {}
|
||||
this.field.preferences.reason_field_uuid ||= v4()
|
||||
this.isOtherReason = this.reason && !this.defaultReasons[this.reason]
|
||||
this.isOtherReason = this.reason && !this.defaultReasons[this.reason] &&
|
||||
(!this.field.preferences?.reasons?.length || !this.field.preferences.reasons.includes(this.reason))
|
||||
}
|
||||
},
|
||||
async mounted () {
|
||||
|
||||
@@ -175,7 +175,10 @@
|
||||
{{ t('save') }}
|
||||
</span>
|
||||
</button>
|
||||
<div class="dropdown dropdown-end">
|
||||
<div
|
||||
class="dropdown dropdown-end"
|
||||
:class="{ 'dropdown-open': isDownloading }"
|
||||
>
|
||||
<label
|
||||
tabindex="0"
|
||||
class="base-button !rounded-l-none !pl-1 !pr-2 !border-l-neutral-500"
|
||||
@@ -209,6 +212,30 @@
|
||||
<span class="whitespace-nowrap">{{ t('preferences') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="withDownload">
|
||||
<button
|
||||
class="flex space-x-2"
|
||||
:disabled="isDownloading"
|
||||
@click.stop.prevent="download"
|
||||
>
|
||||
<IconInnerShadowTop
|
||||
v-if="isDownloading"
|
||||
class="animate-spin w-6 h-6 flex-shrink-0"
|
||||
/>
|
||||
<IconDownload
|
||||
v-else
|
||||
class="w-6 h-6 flex-shrink-0"
|
||||
/>
|
||||
<span
|
||||
v-if="isDownloading"
|
||||
class="whitespace-nowrap"
|
||||
>{{ t('downloading_') }}</span>
|
||||
<span
|
||||
v-else
|
||||
class="whitespace-nowrap"
|
||||
>{{ t('download') }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</span>
|
||||
@@ -449,6 +476,7 @@
|
||||
:default-required-fields="defaultRequiredFields"
|
||||
:field-types="fieldTypes"
|
||||
:with-sticky-submitters="withStickySubmitters"
|
||||
:with-fields-detection="withFieldsDetection"
|
||||
:with-signature-id="withSignatureId"
|
||||
:with-prefillable="withPrefillable"
|
||||
:only-defined-fields="onlyDefinedFields"
|
||||
@@ -456,6 +484,7 @@
|
||||
:show-tour-start-form="showTourStartForm"
|
||||
@add-field="addField"
|
||||
@set-draw="[drawField = $event.field, drawOption = $event.option]"
|
||||
@select-submitter="selectedSubmitter = $event"
|
||||
@set-draw-type="[drawFieldType = $event, showDrawField = true]"
|
||||
@set-drag="dragField = $event"
|
||||
@set-drag-placeholder="$refs.dragPlaceholder.dragPlaceholder = $event"
|
||||
@@ -510,7 +539,7 @@ import DocumentPreview from './preview'
|
||||
import DocumentControls from './controls'
|
||||
import MobileFields from './mobile_fields'
|
||||
import FieldSubmitter from './field_submitter'
|
||||
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments } from '@tabler/icons-vue'
|
||||
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload } from '@tabler/icons-vue'
|
||||
import { v4 } from 'uuid'
|
||||
import { ref, computed, toRaw } from 'vue'
|
||||
import * as i18n from './i18n'
|
||||
@@ -536,6 +565,7 @@ export default {
|
||||
Contenteditable,
|
||||
IconUsersPlus,
|
||||
IconChevronDown,
|
||||
IconDownload,
|
||||
IconAdjustments,
|
||||
IconEye,
|
||||
IconDeviceFloppy
|
||||
@@ -553,6 +583,7 @@ export default {
|
||||
backgroundColor: this.backgroundColor,
|
||||
withPhone: this.withPhone,
|
||||
withVerification: this.withVerification,
|
||||
withKba: this.withKba,
|
||||
withPayment: this.withPayment,
|
||||
isPaymentConnected: this.isPaymentConnected,
|
||||
withFormula: this.withFormula,
|
||||
@@ -583,6 +614,11 @@ export default {
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
withDownload: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
backgroundColor: {
|
||||
type: String,
|
||||
required: false,
|
||||
@@ -618,6 +654,11 @@ export default {
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
withFieldsDetection: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withAddPageButton: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -754,6 +795,11 @@ export default {
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
withKba: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
withPayment: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -799,6 +845,7 @@ export default {
|
||||
return {
|
||||
documentRefs: [],
|
||||
isBreakpointLg: false,
|
||||
isDownloading: false,
|
||||
isLoadingBlankPage: false,
|
||||
isSaving: false,
|
||||
selectedSubmitter: null,
|
||||
@@ -957,6 +1004,75 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
toRaw,
|
||||
download () {
|
||||
this.isDownloading = true
|
||||
|
||||
this.baseFetch(`/templates/${this.template.id}/documents`).then(async (response) => {
|
||||
if (response.ok) {
|
||||
const urls = await response.json()
|
||||
const isMobileSafariIos = 'ontouchstart' in window && navigator.maxTouchPoints > 0 && /AppleWebKit/i.test(navigator.userAgent)
|
||||
const isSafariIos = isMobileSafariIos || /iPhone|iPad|iPod/i.test(navigator.userAgent)
|
||||
|
||||
if (isSafariIos && urls.length > 1) {
|
||||
this.downloadSafariIos(urls)
|
||||
} else {
|
||||
this.downloadUrls(urls)
|
||||
}
|
||||
} else {
|
||||
alert(this.t('failed_to_download_files'))
|
||||
}
|
||||
})
|
||||
},
|
||||
downloadUrls (urls) {
|
||||
const fileRequests = urls.map((url) => {
|
||||
return () => {
|
||||
return fetch(url).then(async (resp) => {
|
||||
const blobUrl = URL.createObjectURL(await resp.blob())
|
||||
const link = document.createElement('a')
|
||||
|
||||
link.href = blobUrl
|
||||
link.setAttribute('download', decodeURI(url.split('/').pop()))
|
||||
|
||||
link.click()
|
||||
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
fileRequests.reduce(
|
||||
(prevPromise, request) => prevPromise.then(() => request()),
|
||||
Promise.resolve()
|
||||
).finally(() => {
|
||||
this.isDownloading = false
|
||||
})
|
||||
},
|
||||
downloadSafariIos (urls) {
|
||||
const fileRequests = urls.map((url) => {
|
||||
return fetch(url).then(async (resp) => {
|
||||
const blob = await resp.blob()
|
||||
const blobUrl = URL.createObjectURL(blob.slice(0, blob.size, 'application/octet-stream'))
|
||||
const link = document.createElement('a')
|
||||
|
||||
link.href = blobUrl
|
||||
link.setAttribute('download', decodeURI(url.split('/').pop()))
|
||||
|
||||
return link
|
||||
})
|
||||
})
|
||||
|
||||
Promise.all(fileRequests).then((links) => {
|
||||
links.forEach((link, index) => {
|
||||
setTimeout(() => {
|
||||
link.click()
|
||||
|
||||
URL.revokeObjectURL(link.href)
|
||||
}, index * 50)
|
||||
})
|
||||
}).finally(() => {
|
||||
this.isDownloading = false
|
||||
})
|
||||
},
|
||||
onDragover (e) {
|
||||
if (this.$refs.dragPlaceholder?.dragPlaceholder) {
|
||||
this.$refs.dragPlaceholder.isMask = e.target.id === 'mask'
|
||||
@@ -1000,14 +1116,17 @@ export default {
|
||||
const sortArea = (aArea, bArea) => {
|
||||
if (aArea.attachment_uuid === bArea.attachment_uuid) {
|
||||
if (aArea.page === bArea.page) {
|
||||
if (Math.abs(aArea.y - bArea.y) < 0.01) {
|
||||
const aY = aArea.y + aArea.h
|
||||
const bY = bArea.y + bArea.h
|
||||
|
||||
if (Math.abs(aY - bY) < 0.01 || (aArea.h < bArea.h ? (aArea.y >= bArea.y && aY <= bY) : (bArea.y >= aArea.y && bY <= aY))) {
|
||||
if (aArea.x === bArea.x) {
|
||||
return 0
|
||||
} else {
|
||||
return aArea.x - bArea.x
|
||||
}
|
||||
} else {
|
||||
return aArea.y - bArea.y
|
||||
return (aArea.y + aArea.h) - (bArea.y + bArea.h)
|
||||
}
|
||||
} else {
|
||||
return aArea.page - bArea.page
|
||||
@@ -1057,6 +1176,72 @@ export default {
|
||||
this.save()
|
||||
}
|
||||
},
|
||||
findFieldInsertIndex (field) {
|
||||
if (!field.areas?.length) return -1
|
||||
|
||||
const area = field.areas[0]
|
||||
|
||||
const attachmentUuidsIndex = this.template.schema.reduce((acc, e, index) => {
|
||||
acc[e.attachment_uuid] = index
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const compareAreas = (a, b) => {
|
||||
const aAttIdx = attachmentUuidsIndex[a.attachment_uuid]
|
||||
const bAttIdx = attachmentUuidsIndex[b.attachment_uuid]
|
||||
|
||||
if (aAttIdx !== bAttIdx) return aAttIdx - bAttIdx
|
||||
if (a.page !== b.page) return a.page - b.page
|
||||
|
||||
const aY = a.y + a.h
|
||||
const bY = b.y + b.h
|
||||
|
||||
if (Math.abs(aY - bY) < 0.01) return a.x - b.x
|
||||
if (a.h < b.h ? a.y >= b.y && aY <= bY : b.y >= a.y && bY <= aY) return a.x - b.x
|
||||
|
||||
return aY - bY
|
||||
}
|
||||
|
||||
let closestBeforeIndex = -1
|
||||
let closestBeforeArea = null
|
||||
let closestAfterIndex = -1
|
||||
let closestAfterArea = null
|
||||
|
||||
this.template.fields.forEach((f, index) => {
|
||||
if (f.submitter_uuid === field.submitter_uuid) {
|
||||
(f.areas || []).forEach((a) => {
|
||||
const cmp = compareAreas(a, area)
|
||||
|
||||
if (cmp < 0) {
|
||||
if (!closestBeforeArea || (compareAreas(a, closestBeforeArea) > 0 && closestBeforeIndex < index)) {
|
||||
closestBeforeIndex = index
|
||||
closestBeforeArea = a
|
||||
}
|
||||
} else {
|
||||
if (!closestAfterArea || (compareAreas(a, closestAfterArea) < 0 && closestAfterIndex > index)) {
|
||||
closestAfterIndex = index
|
||||
closestAfterArea = a
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (closestBeforeIndex !== -1) return closestBeforeIndex + 1
|
||||
if (closestAfterIndex !== -1) return closestAfterIndex
|
||||
|
||||
return -1
|
||||
},
|
||||
insertField (field) {
|
||||
const insertIndex = this.findFieldInsertIndex(field)
|
||||
|
||||
if (insertIndex !== -1) {
|
||||
this.template.fields.splice(insertIndex, 0, field)
|
||||
} else {
|
||||
this.template.fields.push(field)
|
||||
}
|
||||
},
|
||||
closeDropdown () {
|
||||
document.activeElement.blur()
|
||||
},
|
||||
@@ -1110,7 +1295,7 @@ export default {
|
||||
field.preferences.with_signature_id = this.withSignatureId
|
||||
}
|
||||
|
||||
this.template.fields.push(field)
|
||||
this.insertField(field)
|
||||
|
||||
this.save()
|
||||
},
|
||||
@@ -1339,11 +1524,13 @@ export default {
|
||||
|
||||
field.areas.push(area)
|
||||
} else {
|
||||
this.template.fields.push({
|
||||
const newField = {
|
||||
...JSON.parse(JSON.stringify(field)),
|
||||
uuid: v4(),
|
||||
areas: [area]
|
||||
})
|
||||
}
|
||||
|
||||
this.insertField(newField)
|
||||
}
|
||||
|
||||
this.selectedAreaRef.value = area
|
||||
@@ -1366,13 +1553,13 @@ export default {
|
||||
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
|
||||
const pageMask = documentRef.pageRefs[area.page].$refs.mask
|
||||
|
||||
if (type === 'checkbox') {
|
||||
if (type === 'checkbox' || type === 'radio' || type === 'multiple') {
|
||||
area.w = pageMask.clientWidth / 30 / pageMask.clientWidth
|
||||
area.h = (pageMask.clientWidth / 30 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight)
|
||||
} else if (type === 'image') {
|
||||
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
|
||||
area.h = (pageMask.clientWidth / 5 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight)
|
||||
} else if (type === 'signature' || type === 'stamp' || type === 'verification') {
|
||||
} else if (type === 'signature' || type === 'stamp' || type === 'verification' || type === 'kba') {
|
||||
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
|
||||
area.h = (pageMask.clientWidth / 5 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight) / 2
|
||||
} else if (type === 'initials') {
|
||||
@@ -1428,7 +1615,7 @@ export default {
|
||||
}
|
||||
|
||||
if (this.template.fields.indexOf(this.drawField) === -1) {
|
||||
this.template.fields.push(this.drawField)
|
||||
this.insertField(this.drawField)
|
||||
}
|
||||
|
||||
this.drawField = null
|
||||
@@ -1456,8 +1643,8 @@ export default {
|
||||
const previousArea = previousField?.areas?.[previousField.areas.length - 1]
|
||||
|
||||
if (previousArea || area.w) {
|
||||
const areaW = previousArea?.w || (30 / pageMask.clientWidth)
|
||||
const areaH = previousArea?.h || (30 / pageMask.clientHeight)
|
||||
const areaW = previousArea?.w || area.w || (30 / pageMask.clientWidth)
|
||||
const areaH = previousArea?.h || area.h || (30 / pageMask.clientHeight)
|
||||
|
||||
if ((pageMask.clientWidth * area.w) < 5) {
|
||||
area.x = area.x - (areaW / 2)
|
||||
@@ -1567,7 +1754,7 @@ export default {
|
||||
this.selectedAreaRef.value = fieldArea
|
||||
|
||||
if (this.template.fields.indexOf(field) === -1) {
|
||||
this.template.fields.push(field)
|
||||
this.insertField(field)
|
||||
}
|
||||
|
||||
this.save()
|
||||
@@ -1597,7 +1784,7 @@ export default {
|
||||
} else if (previousField?.areas?.length) {
|
||||
baseArea = previousField.areas[previousField.areas.length - 1]
|
||||
} else {
|
||||
if (['checkbox'].includes(fieldType)) {
|
||||
if (['checkbox', 'radio', 'multiple'].includes(fieldType)) {
|
||||
baseArea = {
|
||||
w: area.maskW / 30 / area.maskW,
|
||||
h: area.maskW / 30 / area.maskW * (area.maskW / area.maskH)
|
||||
@@ -1607,7 +1794,7 @@ export default {
|
||||
w: area.maskW / 5 / area.maskW,
|
||||
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH)
|
||||
}
|
||||
} else if (fieldType === 'signature' || fieldType === 'stamp' || fieldType === 'verification') {
|
||||
} else if (fieldType === 'signature' || fieldType === 'stamp' || fieldType === 'verification' || fieldType === 'kba') {
|
||||
baseArea = {
|
||||
w: area.maskW / 5 / area.maskW,
|
||||
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH) / 2
|
||||
@@ -1729,7 +1916,7 @@ export default {
|
||||
attachment.metadata.pdf.fields.forEach((field) => {
|
||||
field.submitter_uuid = this.selectedSubmitter.uuid
|
||||
|
||||
this.template.fields.push(field)
|
||||
this.insertField(field)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -154,15 +154,22 @@
|
||||
v-if="field.options && withOptions && (isExpandOptions || field.options.length < 5)"
|
||||
ref="options"
|
||||
class="border-t border-base-300 mx-2 pt-2 space-y-1.5"
|
||||
draggable="true"
|
||||
@dragstart.prevent.stop
|
||||
@dragover="onOptionDragover"
|
||||
@drop="reorderOptions"
|
||||
>
|
||||
<div
|
||||
v-for="(option, index) in field.options"
|
||||
:key="option.uuid"
|
||||
class="flex space-x-1.5 items-center"
|
||||
:data-option-uuid="option.uuid"
|
||||
>
|
||||
<span class="text-sm w-3.5">
|
||||
<span
|
||||
class="text-sm w-3.5 cursor-grab select-none"
|
||||
:draggable="editable && !defaultField"
|
||||
@dragstart.stop="onOptionDragstart($event, option)"
|
||||
@dragend.stop="optionDragRef = null"
|
||||
@dragover.prevent.stop="onOptionDragover"
|
||||
>
|
||||
{{ index + 1 }}.
|
||||
</span>
|
||||
<div
|
||||
@@ -176,6 +183,7 @@
|
||||
dir="auto"
|
||||
required
|
||||
:placeholder="`${t('option')} ${index + 1}`"
|
||||
@keydown.enter="option.value ? addOptionAt(index + 1) : null"
|
||||
@blur="save"
|
||||
>
|
||||
<button
|
||||
@@ -198,6 +206,7 @@
|
||||
:readonly="!editable || defaultField"
|
||||
required
|
||||
dir="auto"
|
||||
@keydown.enter="option.value ? addOptionAt(index + 1) : null"
|
||||
@focus="maybeFocusOnOptionArea(option)"
|
||||
@blur="save"
|
||||
>
|
||||
@@ -217,7 +226,7 @@
|
||||
<button
|
||||
v-else-if="field.options && editable && !defaultField"
|
||||
class="field-add-option text-center text-sm w-full pb-1"
|
||||
@click="addOption"
|
||||
@click="addOptionAt(field.options.length)"
|
||||
>
|
||||
+ {{ t('add_option') }}
|
||||
</button>
|
||||
@@ -363,7 +372,8 @@ export default {
|
||||
isShowFontModal: false,
|
||||
isShowConditionsModal: false,
|
||||
isShowDescriptionModal: false,
|
||||
renderDropdown: false
|
||||
renderDropdown: false,
|
||||
optionDragRef: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -450,15 +460,17 @@ export default {
|
||||
closeDropdown () {
|
||||
this.$el.getRootNode().activeElement.blur()
|
||||
},
|
||||
addOption () {
|
||||
addOptionAt (index) {
|
||||
this.isExpandOptions = true
|
||||
|
||||
this.field.options.push({ value: '', uuid: v4() })
|
||||
const insertAt = index ?? this.field.options.length
|
||||
|
||||
this.field.options.splice(insertAt, 0, { value: '', uuid: v4() })
|
||||
|
||||
this.$nextTick(() => {
|
||||
const inputs = this.$refs.options.querySelectorAll('input')
|
||||
|
||||
inputs[inputs.length - 1]?.focus()
|
||||
inputs[insertAt]?.focus()
|
||||
})
|
||||
|
||||
this.save()
|
||||
@@ -515,6 +527,70 @@ export default {
|
||||
this.isNameFocus = false
|
||||
|
||||
this.save()
|
||||
},
|
||||
onOptionDragstart (event, option) {
|
||||
this.optionDragRef = option
|
||||
|
||||
const root = this.$el.getRootNode()
|
||||
const hiddenEl = document.createElement('div')
|
||||
|
||||
hiddenEl.style.width = '1px'
|
||||
hiddenEl.style.height = '1px'
|
||||
hiddenEl.style.opacity = '0'
|
||||
hiddenEl.style.position = 'fixed'
|
||||
|
||||
root.querySelector('#docuseal_modal_container')?.appendChild(hiddenEl)
|
||||
event.dataTransfer?.setDragImage(hiddenEl, 0, 0)
|
||||
|
||||
setTimeout(() => { hiddenEl.remove() }, 1000)
|
||||
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
},
|
||||
onOptionDragover (e) {
|
||||
if (!this.optionDragRef) return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const targetRow = e.target.closest('[data-option-uuid]')
|
||||
|
||||
if (!targetRow) return
|
||||
|
||||
const dragRow = this.$refs.options?.querySelector(`[data-option-uuid="${this.optionDragRef.uuid}"]`)
|
||||
|
||||
if (!dragRow) return
|
||||
if (targetRow === dragRow) return
|
||||
|
||||
const rows = Array.from(this.$refs.options.querySelectorAll('[data-option-uuid]'))
|
||||
|
||||
const currentIndex = rows.indexOf(dragRow)
|
||||
const targetIndex = rows.indexOf(targetRow)
|
||||
|
||||
if (currentIndex < targetIndex) {
|
||||
targetRow.after(dragRow)
|
||||
} else {
|
||||
targetRow.before(dragRow)
|
||||
}
|
||||
},
|
||||
reorderOptions (e) {
|
||||
if (!this.optionDragRef) return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const rows = Array.from(this.$refs.options.querySelectorAll('[data-option-uuid]'))
|
||||
|
||||
const newOrder = rows
|
||||
.map((el) => this.field.options.find((opt) => opt.uuid === el.dataset.optionUuid))
|
||||
.filter(Boolean)
|
||||
|
||||
if (newOrder.length === this.field.options.length) {
|
||||
this.field.options.splice(0, this.field.options.length, ...newOrder)
|
||||
|
||||
this.save()
|
||||
}
|
||||
|
||||
this.optionDragRef = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
v-for="(icon, type) in fieldIconsSorted"
|
||||
:key="type"
|
||||
>
|
||||
<li v-if="fieldTypes.includes(type) || ((withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification'))">
|
||||
<li v-if="fieldTypes.includes(type) || ((withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification') && (withKba || type != 'kba'))">
|
||||
<a
|
||||
href="#"
|
||||
class="text-sm py-1 px-2"
|
||||
@@ -51,11 +51,11 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp, IconSquareNumber1, IconHeading, IconId, IconCalendarCheck, IconStrikethrough } from '@tabler/icons-vue'
|
||||
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp, IconSquareNumber1, IconHeading, IconId, IconCalendarCheck, IconStrikethrough, IconUserScan } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'FiledTypeDropdown',
|
||||
inject: ['withPhone', 'withPayment', 'withVerification', 't', 'fieldTypes'],
|
||||
inject: ['withPhone', 'withPayment', 'withVerification', 'withKba', 't', 'fieldTypes'],
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
@@ -114,7 +114,8 @@ export default {
|
||||
stamp: this.t('stamp'),
|
||||
payment: this.t('payment'),
|
||||
phone: this.t('phone'),
|
||||
verification: this.t('verify_id')
|
||||
verification: this.t('verify_id'),
|
||||
kba: this.t('kba')
|
||||
}
|
||||
},
|
||||
fieldLabels () {
|
||||
@@ -134,7 +135,8 @@ export default {
|
||||
stamp: this.t('stamp_field'),
|
||||
payment: this.t('payment_field'),
|
||||
phone: this.t('phone_field'),
|
||||
verification: this.t('verify_id')
|
||||
verification: this.t('verify_id'),
|
||||
kba: this.t('kba')
|
||||
}
|
||||
},
|
||||
fieldIcons () {
|
||||
@@ -157,7 +159,8 @@ export default {
|
||||
stamp: IconRubberStamp,
|
||||
payment: IconCreditCard,
|
||||
phone: IconPhoneCheck,
|
||||
verification: IconId
|
||||
verification: IconId,
|
||||
kba: IconUserScan
|
||||
}
|
||||
},
|
||||
skipTypes () {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
ref="fields"
|
||||
class="fields mb-1 mt-2"
|
||||
@dragover.prevent="onFieldDragover"
|
||||
@drop="reorderFields"
|
||||
@drop="fieldsDragFieldRef.value ? reorderFields() : null"
|
||||
>
|
||||
<Field
|
||||
v-for="field in submitterFields"
|
||||
@@ -114,7 +114,7 @@
|
||||
:key="type"
|
||||
>
|
||||
<button
|
||||
v-if="fieldTypes.includes(type) || ((withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification'))"
|
||||
v-if="fieldTypes.includes(type) || ((withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification') && (withKba || type != 'kba'))"
|
||||
:id="`${type}_type_field_button`"
|
||||
draggable="true"
|
||||
class="field-type-button group flex items-center justify-center border border-dashed w-full rounded relative fields-grid-item"
|
||||
@@ -122,7 +122,7 @@
|
||||
:class="drawFieldType === type ? 'border-base-content/40' : 'border-base-300 hover:border-base-content/20'"
|
||||
@dragstart="onDragstart($event, { type: type })"
|
||||
@dragend="$emit('drag-end')"
|
||||
@click="['file', 'payment', 'verification'].includes(type) ? $emit('add-field', type) : $emit('set-draw-type', type)"
|
||||
@click="['file', 'payment', 'verification', 'kba'].includes(type) ? $emit('add-field', type) : $emit('set-draw-type', type)"
|
||||
>
|
||||
<div
|
||||
class="flex items-console transition-all cursor-grab h-full absolute left-0"
|
||||
@@ -208,6 +208,43 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
v-if="withFieldsDetection && editable && fields.length < 2"
|
||||
class="my-2"
|
||||
>
|
||||
<button
|
||||
class="btn w-full"
|
||||
:class="{ 'bg-base-300': fieldPagesLoaded !== null }"
|
||||
@click="fieldPagesLoaded !== null ? null : detectFields()"
|
||||
>
|
||||
<template v-if="fieldPagesLoaded !== null">
|
||||
<IconInnerShadowTop
|
||||
width="22"
|
||||
class="animate-spin"
|
||||
/>
|
||||
<span
|
||||
v-if="analyzingProgress"
|
||||
class="hidden md:inline"
|
||||
>
|
||||
{{ Math.round(analyzingProgress * 100) }}% {{ t('analyzing_') }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="hidden md:inline"
|
||||
>
|
||||
{{ fieldPagesLoaded }} / {{ numberOfPages }} {{ t('processing_') }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<IconSparkles width="22" />
|
||||
<span
|
||||
class="hidden md:inline"
|
||||
>
|
||||
{{ t('autodetect_fields') }}
|
||||
</span>
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-show="fields.length < 4 && editable && withHelp && showTourStartForm"
|
||||
class="rounded py-2 px-4 w-full border border-dashed border-base-300"
|
||||
@@ -231,7 +268,7 @@
|
||||
import Field from './field'
|
||||
import FieldType from './field_type'
|
||||
import FieldSubmitter from './field_submitter'
|
||||
import { IconLock, IconCirclePlus } from '@tabler/icons-vue'
|
||||
import { IconLock, IconCirclePlus, IconInnerShadowTop, IconSparkles } from '@tabler/icons-vue'
|
||||
import IconDrag from './icon_drag'
|
||||
|
||||
export default {
|
||||
@@ -240,11 +277,13 @@ export default {
|
||||
Field,
|
||||
FieldType,
|
||||
IconCirclePlus,
|
||||
IconSparkles,
|
||||
IconInnerShadowTop,
|
||||
FieldSubmitter,
|
||||
IconDrag,
|
||||
IconLock
|
||||
},
|
||||
inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withPayment', 't', 'fieldsDragFieldRef'],
|
||||
inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withKba', 'withPayment', 't', 'fieldsDragFieldRef', 'baseFetch'],
|
||||
props: {
|
||||
fields: {
|
||||
type: Array,
|
||||
@@ -255,6 +294,11 @@ export default {
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
withFieldsDetection: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withSignatureId: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -328,15 +372,22 @@ export default {
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder'],
|
||||
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder', 'select-submitter'],
|
||||
data () {
|
||||
return {
|
||||
fieldPagesLoaded: null,
|
||||
analyzingProgress: 0,
|
||||
defaultFieldsSearch: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
fieldNames: FieldType.computed.fieldNames,
|
||||
fieldIcons: FieldType.computed.fieldIcons,
|
||||
numberOfPages () {
|
||||
return this.template.documents.reduce((acc, doc) => {
|
||||
return acc + doc.metadata?.pdf?.number_of_pages || doc.preview_images.length
|
||||
}, 0)
|
||||
},
|
||||
isShowFieldSearch () {
|
||||
if (this.withFieldsSearch === false) {
|
||||
return false
|
||||
@@ -389,6 +440,84 @@ export default {
|
||||
|
||||
this.$emit('set-drag', field)
|
||||
},
|
||||
detectFields () {
|
||||
const fields = []
|
||||
|
||||
this.fieldPagesLoaded = 0
|
||||
|
||||
this.baseFetch(`/templates/${this.template.id}/detect_fields`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}).then(async (response) => {
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
const lines = buffer.split('\n\n')
|
||||
|
||||
buffer = lines.pop()
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const jsonStr = line.replace(/^data: /, '')
|
||||
const data = JSON.parse(jsonStr)
|
||||
|
||||
if (data.error) {
|
||||
if ((data.fields || fields).length) {
|
||||
this.template.fields = data.fields || fields
|
||||
|
||||
this.save()
|
||||
} else {
|
||||
alert(data.error)
|
||||
}
|
||||
|
||||
break
|
||||
} else if (data.analyzing) {
|
||||
this.analyzingProgress = data.progress
|
||||
} else if (data.completed) {
|
||||
this.fieldPagesLoaded = null
|
||||
|
||||
if (data.submitters) {
|
||||
this.template.submitters = data.submitters
|
||||
this.$emit('select-submitter', this.template.submitters[0])
|
||||
}
|
||||
|
||||
this.template.fields = data.fields || fields
|
||||
|
||||
this.save()
|
||||
|
||||
break
|
||||
} else if (data.fields) {
|
||||
data.fields.forEach((f) => {
|
||||
if (!f.submitter_uuid) {
|
||||
f.submitter_uuid = this.template.submitters[0].uuid
|
||||
}
|
||||
})
|
||||
|
||||
this.fieldPagesLoaded += 1
|
||||
|
||||
fields.push(...data.fields)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (done) break
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error('Error in streaming message: ', error)
|
||||
}).finally(() => {
|
||||
this.fieldPagesLoaded = null
|
||||
this.analyzingProgress = null
|
||||
this.isFieldsLoading = false
|
||||
})
|
||||
},
|
||||
setDragPlaceholder (event) {
|
||||
this.$emit('set-drag-placeholder', {
|
||||
offsetX: event.offsetX,
|
||||
@@ -450,7 +579,7 @@ export default {
|
||||
removeSubmitter (submitter) {
|
||||
[...this.fields].forEach((field) => {
|
||||
if (field.submitter_uuid === submitter.uuid) {
|
||||
this.removeField(field)
|
||||
this.removeField(field, false)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -462,7 +591,7 @@ export default {
|
||||
|
||||
this.save()
|
||||
},
|
||||
removeField (field) {
|
||||
removeField (field, save = true) {
|
||||
this.fields.splice(this.fields.indexOf(field), 1)
|
||||
|
||||
this.fields.forEach((f) => {
|
||||
@@ -481,7 +610,9 @@ export default {
|
||||
})
|
||||
})
|
||||
|
||||
this.save()
|
||||
if (save) {
|
||||
this.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
form="sync_form"
|
||||
:form="`sync_form_${inputId}`"
|
||||
type="submit"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
@@ -40,7 +40,7 @@
|
||||
</li>
|
||||
</ul>
|
||||
<form
|
||||
id="sync_form"
|
||||
:id="`sync_form_${inputId}`"
|
||||
ref="form"
|
||||
class="hidden"
|
||||
@submit.prevent="upload({ path: uploadUrl })"
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
const en = {
|
||||
kba: 'KBA',
|
||||
analyzing_: 'Analyzing...',
|
||||
download: 'Download',
|
||||
downloading_: 'Downloading...',
|
||||
view: 'View',
|
||||
autodetect_fields: 'Autodetect fields',
|
||||
payment_link: 'Payment link',
|
||||
strikeout: 'Strikeout',
|
||||
draw_strikethrough_the_document: 'Draw strikethrough the document',
|
||||
@@ -184,6 +189,11 @@ const en = {
|
||||
}
|
||||
|
||||
const es = {
|
||||
kba: 'KBA',
|
||||
autodetect_fields: 'Autodetectar campos',
|
||||
analyzing_: 'Analizando...',
|
||||
download: 'Descargar',
|
||||
downloading_: 'Descargando...',
|
||||
view: 'Vista',
|
||||
payment_link: 'Enlace de pago',
|
||||
strikeout: 'Tachar',
|
||||
@@ -369,6 +379,11 @@ const es = {
|
||||
}
|
||||
|
||||
const it = {
|
||||
kba: 'KBA',
|
||||
autodetect_fields: 'Rileva campi',
|
||||
analyzing_: 'Analisi...',
|
||||
download: 'Scarica',
|
||||
downloading_: 'Download in corso...',
|
||||
view: 'Vista',
|
||||
payment_link: 'Link di pagamento',
|
||||
strikeout: 'Barrato',
|
||||
@@ -554,6 +569,11 @@ const it = {
|
||||
}
|
||||
|
||||
const pt = {
|
||||
kba: 'KBA',
|
||||
autodetect_fields: 'Detectar campos',
|
||||
analyzing_: 'Analisando...',
|
||||
download: 'Baixar',
|
||||
downloading_: 'Baixando...',
|
||||
view: 'Visualizar',
|
||||
payment_link: 'Link de pagamento',
|
||||
strikeout: 'Tachado',
|
||||
@@ -739,6 +759,11 @@ const pt = {
|
||||
}
|
||||
|
||||
const fr = {
|
||||
kba: 'KBA',
|
||||
autodetect_fields: 'Détecter les champs',
|
||||
analyzing_: 'Analyse...',
|
||||
download: 'Télécharger',
|
||||
downloading_: 'Téléchargement...',
|
||||
view: 'Voir',
|
||||
payment_link: 'Lien de paiement',
|
||||
strikeout: 'Rature',
|
||||
@@ -924,6 +949,11 @@ const fr = {
|
||||
}
|
||||
|
||||
const de = {
|
||||
kba: 'KBA',
|
||||
autodetect_fields: 'Felder erkennen',
|
||||
analyzing_: 'Analysiere...',
|
||||
download: 'Download',
|
||||
downloading_: 'Download...',
|
||||
view: 'Anzeigen',
|
||||
payment_link: 'Zahlungslink',
|
||||
strikeout: 'Durchstreichen',
|
||||
@@ -1109,6 +1139,11 @@ const de = {
|
||||
}
|
||||
|
||||
const nl = {
|
||||
kba: 'KBA',
|
||||
autodetect_fields: 'Velden detecteren',
|
||||
analyzing_: 'Analyseren...',
|
||||
download: 'Downloaden',
|
||||
downloading_: 'Downloaden...',
|
||||
view: 'Bekijken',
|
||||
payment_link: 'Betaallink',
|
||||
strikeout: 'Doorhalen',
|
||||
|
||||
@@ -77,7 +77,7 @@ export default {
|
||||
removeSubmitter (submitter) {
|
||||
[...this.fields].forEach((field) => {
|
||||
if (field.submitter_uuid === submitter.uuid) {
|
||||
this.removeField(field)
|
||||
this.removeField(field, false)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -89,10 +89,12 @@ export default {
|
||||
|
||||
this.save()
|
||||
},
|
||||
removeField (field) {
|
||||
removeField (field, save = true) {
|
||||
this.fields.splice(this.fields.indexOf(field), 1)
|
||||
|
||||
this.save()
|
||||
if (save) {
|
||||
this.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,12 +49,17 @@
|
||||
</li>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="fieldTypes.length && submitterDefaultFields.length"
|
||||
class="bg-base-300"
|
||||
style="height: 1px; margin-top: 1px; margin-bottom: 1px"
|
||||
/>
|
||||
<template v-if="fieldTypes.length || !submitterDefaultFields.length">
|
||||
<template
|
||||
v-for="(icon, type) in fieldIconsSorted"
|
||||
:key="type"
|
||||
>
|
||||
<li v-if="fieldTypes.includes(type) || ((withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification'))">
|
||||
<li v-if="fieldTypes.includes(type) || ((withPhone || type != 'phone') && (withPayment || type != 'payment') && (withVerification || type != 'verification') && (withKba || type != 'kba'))">
|
||||
<a
|
||||
href="#"
|
||||
class="text-sm py-1 px-2"
|
||||
@@ -84,7 +89,7 @@ export default {
|
||||
IconPlus,
|
||||
IconX
|
||||
},
|
||||
inject: ['withPhone', 'withPayment', 'withVerification', 'backgroundColor', 't'],
|
||||
inject: ['withPhone', 'withPayment', 'withVerification', 'withKba', 'backgroundColor', 't'],
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
|
||||
@@ -216,6 +216,9 @@ export default {
|
||||
queryParams () {
|
||||
return new URLSearchParams(window.location.search)
|
||||
},
|
||||
uploadUrl () {
|
||||
return `/templates/${this.templateId}/documents`
|
||||
},
|
||||
googleDriveOauthPath () {
|
||||
const params = {
|
||||
access_type: 'offline',
|
||||
@@ -279,7 +282,7 @@ export default {
|
||||
async upload ({ path } = {}) {
|
||||
this.isLoading = true
|
||||
|
||||
return this.baseFetch(path || `/templates/${this.templateId}/documents`, {
|
||||
return this.baseFetch(path || this.uploadUrl, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json' },
|
||||
body: new FormData(this.$refs.form)
|
||||
|
||||
@@ -4,7 +4,9 @@ class ProcessSubmissionExpiredJob
|
||||
include Sidekiq::Job
|
||||
|
||||
def perform(params = {})
|
||||
submission = Submission.find(params['submission_id'])
|
||||
submission = Submission.find_by(id: params['submission_id'])
|
||||
|
||||
return unless submission
|
||||
|
||||
return if submission.archived_at?
|
||||
return if submission.template&.archived_at?
|
||||
|
||||
@@ -39,18 +39,26 @@ class ProcessSubmitterCompletionJob
|
||||
submission = submitter.submission
|
||||
|
||||
complete_verification_events, sms_events =
|
||||
submitter.submission_events.where(event_type: %i[send_sms send_2fa_sms complete_verification])
|
||||
.partition { |e| e.event_type == 'complete_verification' }
|
||||
submitter.submission_events.where(event_type: %i[send_sms send_2fa_sms complete_verification complete_kba])
|
||||
.partition { |e| e.event_type == 'complete_verification' || e.event_type == 'complete_kba' }
|
||||
|
||||
complete_verification_event = complete_verification_events.first
|
||||
|
||||
verification_method =
|
||||
if complete_verification_event&.event_type == 'complete_kba'
|
||||
'kba'
|
||||
elsif complete_verification_event
|
||||
complete_verification_event.data['method']
|
||||
end
|
||||
|
||||
completed_submitter.assign_attributes(
|
||||
submission_id: submitter.submission_id,
|
||||
account_id: submission.account_id,
|
||||
is_first: !CompletedSubmitter.exists?(submission: submitter.submission_id, is_first: true),
|
||||
template_id: submission.template_id,
|
||||
source: submission.source,
|
||||
sms_count: sms_events.sum { |e| e.data['segments'] || 1 },
|
||||
verification_method: complete_verification_event&.data&.dig('method'),
|
||||
verification_method:,
|
||||
completed_at: submitter.completed_at
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SendConfirmationInstructionsJob
|
||||
include Sidekiq::Job
|
||||
|
||||
def perform(params = {})
|
||||
user = User.find(params['user_id'])
|
||||
|
||||
user.send_confirmation_instructions
|
||||
end
|
||||
end
|
||||
@@ -8,8 +8,13 @@ class SendFormCompletedWebhookRequestJob
|
||||
MAX_ATTEMPTS = 12
|
||||
|
||||
def perform(params = {})
|
||||
submitter = Submitter.find(params['submitter_id'])
|
||||
webhook_url = WebhookUrl.find(params['webhook_url_id'])
|
||||
submitter = Submitter.find_by(id: params['submitter_id'])
|
||||
|
||||
return unless submitter
|
||||
|
||||
webhook_url = WebhookUrl.find_by(id: params['webhook_url_id'])
|
||||
|
||||
return unless webhook_url
|
||||
|
||||
attempt = params['attempt'].to_i
|
||||
|
||||
|
||||
@@ -8,8 +8,13 @@ class SendFormDeclinedWebhookRequestJob
|
||||
MAX_ATTEMPTS = 10
|
||||
|
||||
def perform(params = {})
|
||||
submitter = Submitter.find(params['submitter_id'])
|
||||
webhook_url = WebhookUrl.find(params['webhook_url_id'])
|
||||
submitter = Submitter.find_by(id: params['submitter_id'])
|
||||
|
||||
return unless submitter
|
||||
|
||||
webhook_url = WebhookUrl.find_by(id: params['webhook_url_id'])
|
||||
|
||||
return unless webhook_url
|
||||
|
||||
attempt = params['attempt'].to_i
|
||||
|
||||
|
||||
@@ -8,8 +8,13 @@ class SendFormStartedWebhookRequestJob
|
||||
MAX_ATTEMPTS = 10
|
||||
|
||||
def perform(params = {})
|
||||
submitter = Submitter.find(params['submitter_id'])
|
||||
webhook_url = WebhookUrl.find(params['webhook_url_id'])
|
||||
submitter = Submitter.find_by(id: params['submitter_id'])
|
||||
|
||||
return unless submitter
|
||||
|
||||
webhook_url = WebhookUrl.find_by(id: params['webhook_url_id'])
|
||||
|
||||
return unless webhook_url
|
||||
|
||||
attempt = params['attempt'].to_i
|
||||
|
||||
|
||||
@@ -8,8 +8,13 @@ class SendFormViewedWebhookRequestJob
|
||||
MAX_ATTEMPTS = 10
|
||||
|
||||
def perform(params = {})
|
||||
submitter = Submitter.find(params['submitter_id'])
|
||||
webhook_url = WebhookUrl.find(params['webhook_url_id'])
|
||||
submitter = Submitter.find_by(id: params['submitter_id'])
|
||||
|
||||
return unless submitter
|
||||
|
||||
webhook_url = WebhookUrl.find_by(id: params['webhook_url_id'])
|
||||
|
||||
return unless webhook_url
|
||||
|
||||
attempt = params['attempt'].to_i
|
||||
|
||||
|
||||
@@ -8,8 +8,13 @@ class SendSubmissionArchivedWebhookRequestJob
|
||||
MAX_ATTEMPTS = 10
|
||||
|
||||
def perform(params = {})
|
||||
submission = Submission.find(params['submission_id'])
|
||||
webhook_url = WebhookUrl.find(params['webhook_url_id'])
|
||||
submission = Submission.find_by(id: params['submission_id'])
|
||||
|
||||
return unless submission
|
||||
|
||||
webhook_url = WebhookUrl.find_by(id: params['webhook_url_id'])
|
||||
|
||||
return unless webhook_url
|
||||
|
||||
attempt = params['attempt'].to_i
|
||||
|
||||
|
||||
@@ -8,8 +8,13 @@ class SendSubmissionCompletedWebhookRequestJob
|
||||
MAX_ATTEMPTS = 10
|
||||
|
||||
def perform(params = {})
|
||||
submission = Submission.find(params['submission_id'])
|
||||
webhook_url = WebhookUrl.find(params['webhook_url_id'])
|
||||
submission = Submission.find_by(id: params['submission_id'])
|
||||
|
||||
return unless submission
|
||||
|
||||
webhook_url = WebhookUrl.find_by(id: params['webhook_url_id'])
|
||||
|
||||
return unless webhook_url
|
||||
|
||||
attempt = params['attempt'].to_i
|
||||
|
||||
|
||||
@@ -8,8 +8,13 @@ class SendSubmissionCreatedWebhookRequestJob
|
||||
MAX_ATTEMPTS = 10
|
||||
|
||||
def perform(params = {})
|
||||
submission = Submission.find(params['submission_id'])
|
||||
webhook_url = WebhookUrl.find(params['webhook_url_id'])
|
||||
submission = Submission.find_by(id: params['submission_id'])
|
||||
|
||||
return unless submission
|
||||
|
||||
webhook_url = WebhookUrl.find_by(id: params['webhook_url_id'])
|
||||
|
||||
return unless webhook_url
|
||||
|
||||
attempt = params['attempt'].to_i
|
||||
|
||||
|
||||
@@ -8,8 +8,13 @@ class SendSubmissionExpiredWebhookRequestJob
|
||||
MAX_ATTEMPTS = 10
|
||||
|
||||
def perform(params = {})
|
||||
submission = Submission.find(params['submission_id'])
|
||||
webhook_url = WebhookUrl.find(params['webhook_url_id'])
|
||||
submission = Submission.find_by(id: params['submission_id'])
|
||||
|
||||
return unless submission
|
||||
|
||||
webhook_url = WebhookUrl.find_by(id: params['webhook_url_id'])
|
||||
|
||||
return unless webhook_url
|
||||
|
||||
attempt = params['attempt'].to_i
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ class SendSubmitterInvitationEmailJob
|
||||
submitter = Submitter.find(params['submitter_id'])
|
||||
|
||||
return if submitter.completed_at?
|
||||
return if submitter.submission.archived_at?
|
||||
return if submitter.template&.archived_at?
|
||||
return if submitter.submission.source == 'invite' && !Accounts.can_send_emails?(submitter.account, on_events: true)
|
||||
|
||||
unless Accounts.can_send_invitation_emails?(submitter.account)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SendSubmitterVerificationEmailJob
|
||||
include Sidekiq::Job
|
||||
|
||||
def perform(params = {})
|
||||
submitter = Submitter.find(params['submitter_id'])
|
||||
|
||||
locale = params['locale'].presence || submitter.account.locale
|
||||
|
||||
SubmitterMailer.otp_verification_email(submitter, locale:).deliver_now!
|
||||
|
||||
SubmissionEvent.create!(submitter_id: params['submitter_id'],
|
||||
event_type: 'send_2fa_email',
|
||||
data: { email: submitter.email })
|
||||
end
|
||||
end
|
||||
@@ -8,8 +8,13 @@ class SendTemplateCreatedWebhookRequestJob
|
||||
MAX_ATTEMPTS = 10
|
||||
|
||||
def perform(params = {})
|
||||
template = Template.find(params['template_id'])
|
||||
webhook_url = WebhookUrl.find(params['webhook_url_id'])
|
||||
template = Template.find_by(id: params['template_id'])
|
||||
|
||||
return unless template
|
||||
|
||||
webhook_url = WebhookUrl.find_by(id: params['webhook_url_id'])
|
||||
|
||||
return unless webhook_url
|
||||
|
||||
attempt = params['attempt'].to_i
|
||||
|
||||
|
||||
@@ -8,8 +8,13 @@ class SendTemplateUpdatedWebhookRequestJob
|
||||
MAX_ATTEMPTS = 10
|
||||
|
||||
def perform(params = {})
|
||||
template = Template.find(params['template_id'])
|
||||
webhook_url = WebhookUrl.find(params['webhook_url_id'])
|
||||
template = Template.find_by(id: params['template_id'])
|
||||
|
||||
return unless template
|
||||
|
||||
webhook_url = WebhookUrl.find_by(id: params['webhook_url_id'])
|
||||
|
||||
return unless webhook_url
|
||||
|
||||
attempt = params['attempt'].to_i
|
||||
|
||||
|
||||
@@ -7,11 +7,28 @@ class SendTestWebhookRequestJob
|
||||
|
||||
USER_AGENT = 'DocuSeal.com Webhook'
|
||||
|
||||
def perform(params = {})
|
||||
submitter = Submitter.find(params['submitter_id'])
|
||||
webhook_url = WebhookUrl.find(params['webhook_url_id'])
|
||||
HttpsError = Class.new(StandardError)
|
||||
LocalhostError = Class.new(StandardError)
|
||||
|
||||
return unless webhook_url && submitter
|
||||
def perform(params = {})
|
||||
submitter = Submitter.find_by(id: params['submitter_id'])
|
||||
|
||||
return unless submitter
|
||||
|
||||
webhook_url = WebhookUrl.find_by(id: params['webhook_url_id'])
|
||||
|
||||
return unless webhook_url
|
||||
|
||||
if Docuseal.multitenant?
|
||||
uri = begin
|
||||
URI(webhook_url.url)
|
||||
rescue URI::Error
|
||||
Addressable::URI.parse(webhook_url.url).normalize
|
||||
end
|
||||
|
||||
raise HttpsError, 'Only HTTPS is allowed.' if uri.scheme != 'https'
|
||||
raise LocalhostError, "Can't send to localhost." if uri.host.in?(SendWebhookRequest::LOCALHOSTS)
|
||||
end
|
||||
|
||||
Faraday.post(webhook_url.url,
|
||||
{
|
||||
|
||||
@@ -144,6 +144,17 @@ class SubmitterMailer < ApplicationMailer
|
||||
end
|
||||
end
|
||||
|
||||
def otp_verification_email(submitter, locale: nil)
|
||||
@submitter = submitter
|
||||
@otp_code = EmailVerificationCodes.generate([submitter.email.downcase.strip, submitter.slug].join(':'))
|
||||
|
||||
assign_message_metadata('otp_verification_email', submitter)
|
||||
|
||||
I18n.with_locale(locale || submitter.account.locale) do
|
||||
mail(to: submitter.email, subject: I18n.t('email_verification'))
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_submitter_reply_to(submitter, email_config: nil, documents_copy_email: nil)
|
||||
|
||||
@@ -60,6 +60,10 @@ class Account < ApplicationRecord
|
||||
linked_account_account&.testing?
|
||||
end
|
||||
|
||||
def tz_info
|
||||
@tz_info ||= TZInfo::Timezone.get(ActiveSupport::TimeZone::MAPPING[timezone] || timezone)
|
||||
end
|
||||
|
||||
def default_template_folder
|
||||
super || build_default_template_folder(name: TemplateFolder::DEFAULT_NAME,
|
||||
author_id: users.minimum(:id)).tap(&:save!)
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#
|
||||
class AccountConfig < ApplicationRecord
|
||||
SUBMITTER_INVITATION_EMAIL_KEY = 'submitter_invitation_email'
|
||||
SUBMITTER_INVITATION_REMINDER_EMAIL_KEY = 'submitter_invitation_reminder_email'
|
||||
SUBMITTER_COMPLETED_EMAIL_KEY = 'submitter_completed_email'
|
||||
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY = 'submitter_documents_copy_email'
|
||||
BCC_EMAILS = 'bcc_emails'
|
||||
@@ -44,10 +45,12 @@ class AccountConfig < ApplicationRecord
|
||||
WITH_SIGNATURE_ID = 'with_signature_id'
|
||||
WITH_FILE_LINKS_KEY = 'with_file_links'
|
||||
WITH_SIGNATURE_ID_REASON_KEY = 'with_signature_id_reason'
|
||||
RECIPIENT_FORM_FIELDS_KEY = 'recipient_form_fields'
|
||||
WITH_AUDIT_VALUES_KEY = 'with_audit_values'
|
||||
WITH_SUBMITTER_TIMEZONE_KEY = 'with_submitter_timezone'
|
||||
REQUIRE_SIGNING_REASON_KEY = 'require_signing_reason'
|
||||
REUSE_SIGNATURE_KEY = 'reuse_signature'
|
||||
WITH_FIELD_LABELS_KEY = 'with_field_labels'
|
||||
COMBINE_PDF_RESULT_KEY = 'combine_pdf_result_key'
|
||||
DOCUMENT_FILENAME_FORMAT_KEY = 'document_filename_format'
|
||||
POLICY_LINKS_KEY = 'policy_links'
|
||||
@@ -59,6 +62,12 @@ class AccountConfig < ApplicationRecord
|
||||
'body' => I18n.t(:submitter_invitation_email_sign_body)
|
||||
}
|
||||
},
|
||||
SUBMITTER_INVITATION_REMINDER_EMAIL_KEY => lambda {
|
||||
{
|
||||
'subject' => I18n.t(:you_are_invited_to_sign_a_document),
|
||||
'body' => I18n.t(:submitter_invitation_email_sign_body)
|
||||
}
|
||||
},
|
||||
SUBMITTER_COMPLETED_EMAIL_KEY => lambda {
|
||||
{
|
||||
'subject' => I18n.t(:template_name_has_been_completed_by_submitters),
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# completed_at :datetime not null
|
||||
# is_first :boolean
|
||||
# sms_count :integer not null
|
||||
# source :string not null
|
||||
# verification_method :string
|
||||
@@ -18,8 +19,10 @@
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_completed_submitters_on_account_id (account_id)
|
||||
# index_completed_submitters_on_submitter_id (submitter_id) UNIQUE
|
||||
# index_completed_submitters_account_id_completed_at_is_first (account_id,completed_at) WHERE (is_first = true)
|
||||
# index_completed_submitters_on_account_id_and_completed_at (account_id,completed_at)
|
||||
# index_completed_submitters_on_submission_id (submission_id) UNIQUE WHERE (is_first = true)
|
||||
# index_completed_submitters_on_submitter_id (submitter_id) UNIQUE
|
||||
#
|
||||
class CompletedSubmitter < ApplicationRecord
|
||||
belongs_to :submitter
|
||||
|
||||
@@ -63,6 +63,8 @@ class Submission < ApplicationRecord
|
||||
|
||||
has_one_attached :audit_trail
|
||||
has_one_attached :combined_document
|
||||
has_one_attached :merged_document
|
||||
has_one_attached :preview_merged_document
|
||||
|
||||
has_many_attached :preview_documents
|
||||
has_many_attached :documents
|
||||
|
||||
@@ -10,17 +10,21 @@
|
||||
# event_type :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint
|
||||
# submission_id :bigint not null
|
||||
# submitter_id :bigint
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_submission_events_on_created_at (created_at)
|
||||
# index_submission_events_on_submission_id (submission_id)
|
||||
# index_submission_events_on_submitter_id (submitter_id)
|
||||
# index_submission_events_on_account_id (account_id)
|
||||
# index_submission_events_on_created_at (created_at)
|
||||
# index_submission_events_on_submission_id (submission_id)
|
||||
# index_submission_events_on_submitter_id (submitter_id)
|
||||
# index_submissions_events_on_sms_event_types (account_id,created_at) WHERE ((event_type)::text = ANY ((ARRAY['send_sms'::character varying, 'send_2fa_sms'::character varying])::text[]))
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (account_id => accounts.id)
|
||||
# fk_rails_... (submission_id => submissions.id)
|
||||
# fk_rails_... (submitter_id => submitters.id)
|
||||
#
|
||||
@@ -35,6 +39,7 @@ class SubmissionEvent < ApplicationRecord
|
||||
serialize :data, coder: JSON
|
||||
|
||||
before_validation :set_submission_id, on: :create
|
||||
before_validation :set_account_id, on: :create
|
||||
|
||||
enum :event_type, {
|
||||
send_email: 'send_email',
|
||||
@@ -43,6 +48,7 @@ class SubmissionEvent < ApplicationRecord
|
||||
send_reminder_email: 'send_reminder_email',
|
||||
send_sms: 'send_sms',
|
||||
send_2fa_sms: 'send_2fa_sms',
|
||||
send_2fa_email: 'send_2fa_email',
|
||||
open_email: 'open_email',
|
||||
click_email: 'click_email',
|
||||
click_sms: 'click_sms',
|
||||
@@ -51,6 +57,9 @@ class SubmissionEvent < ApplicationRecord
|
||||
start_form: 'start_form',
|
||||
start_verification: 'start_verification',
|
||||
complete_verification: 'complete_verification',
|
||||
start_kba: 'start_kba',
|
||||
complete_kba: 'complete_kba',
|
||||
fail_kba: 'fail_kba',
|
||||
view_form: 'view_form',
|
||||
invite_party: 'invite_party',
|
||||
complete_form: 'complete_form',
|
||||
@@ -63,4 +72,8 @@ class SubmissionEvent < ApplicationRecord
|
||||
def set_submission_id
|
||||
self.submission_id = submitter&.submission_id
|
||||
end
|
||||
|
||||
def set_account_id
|
||||
self.account_id = submitter&.account_id
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# archived_at :datetime
|
||||
# confirmation_sent_at :datetime
|
||||
# confirmation_token :string
|
||||
# confirmed_at :datetime
|
||||
# consumed_timestep :integer
|
||||
# current_sign_in_at :datetime
|
||||
# current_sign_in_ip :string
|
||||
@@ -24,8 +27,9 @@
|
||||
# reset_password_token :string
|
||||
# role :string not null
|
||||
# sign_in_count :integer default(0), not null
|
||||
# unconfirmed_email :string
|
||||
# unlock_token :string
|
||||
# uuid :text not null
|
||||
# uuid :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
|
||||
@@ -235,7 +235,7 @@
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if can?(:manage, :personalization_advanced) %>
|
||||
<% if !Docuseal.multitenant? || can?(:manage, :personalization_advanced) %>
|
||||
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::WITH_FILE_LINKS_KEY) %>
|
||||
<% if can?(:manage, account_config) %>
|
||||
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<p>Hello <%= @resource.email %>!</p>
|
||||
<p>Someone has requested a link to change your password. You can do this through the link below.</p>
|
||||
<p><%= link_to 'Change my password', edit_password_url(@resource, reset_password_token: @token) %></p>
|
||||
<p>If you didn't request this, please ignore this email.</p>
|
||||
<p>Your password won't change until you access the link above and create a new one.</p>
|
||||
<p><%= @resource.first_name.present? ? t('hello_name', name: @resource.first_name) : t('hi_there') %>,</p>
|
||||
<p><%= t('you_requested_to_reset_your_password_use_the_link_below_to_continue') %>:</p>
|
||||
<p><%= link_to t('change_my_password'), edit_password_url(@resource, reset_password_token: @token) %></p>
|
||||
<p><%= t('your_password_wont_change_until_you_open_the_link_above_and_set_a_new_one') %></p>
|
||||
<p><%= t('if_you_didnt_request_this_you_can_ignore_this_email') %></p>
|
||||
<p>
|
||||
<%= t('thanks') %>,<br>
|
||||
<%= Docuseal.product_name %>
|
||||
</p>
|
||||
<% content_for(:remove_attribution, true) %>
|
||||
|
||||
@@ -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" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 5m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z"></path>
|
||||
<path d="M16 3l0 4"></path>
|
||||
<path d="M8 3l0 4"></path>
|
||||
<path d="M4 11l16 0"></path>
|
||||
<path d="M8 15h2v2h-2z"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 450 B |
@@ -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" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 11l3 3l8 -8"></path>
|
||||
<path d="M20 12v6a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h9"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 346 B |
@@ -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" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M7 12l5 5l10 -10"></path>
|
||||
<path d="M2 12l5 5m5 -5l5 -5"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 303 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 3m0 1a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v16a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1zm6 -1v18m6 -18v18"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 343 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 5m0 3a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3z"></path>
|
||||
<path d="M3 10l18 0"></path>
|
||||
<path d="M7 15l.01 0"></path>
|
||||
<path d="M11 15l2 0"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 419 B |
@@ -0,0 +1,8 @@
|
||||
<svg class="<%= local_assigns[:class] %>" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M3 5a1 1 0 0 1 1 -1h16a1 1 0 0 1 1 1v10a1 1 0 0 1 -1 1h-16a1 1 0 0 1 -1 -1v-10z" />
|
||||
<path d="M7 20h10" /><path d="M9 16v4" />
|
||||
<path d="M15 16v4" /></svg>
|
||||
<path d="M9 16v4" />
|
||||
<path d="M15 16v4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 497 B |
@@ -0,0 +1,6 @@
|
||||
<svg class="<%= local_assigns[:class] %>" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M6 5a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2v-14z" />
|
||||
<path d="M11 4h2" />
|
||||
<path d="M12 17v.01" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 423 B |
@@ -0,0 +1,5 @@
|
||||
<svg class="<%= local_assigns[:class] %>" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M5 4a1 1 0 0 1 1 -1h12a1 1 0 0 1 1 1v16a1 1 0 0 1 -1 1h-12a1 1 0 0 1 -1 -1v-16z" />
|
||||
<path d="M11 17a1 1 0 1 0 2 0a1 1 0 0 0 -2 0" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 427 B |
@@ -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" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 4m0 3a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v10a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3z"></path>
|
||||
<path d="M9 10m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"></path>
|
||||
<path d="M15 8l2 0"></path>
|
||||
<path d="M15 12l2 0"></path>
|
||||
<path d="M7 16l10 0"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 478 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 19v-10.5a3.5 3.5 0 0 1 7 0v10.5"></path>
|
||||
<path d="M3 13h7"></path>
|
||||
<path d="M14 19v-10.5a3.5 3.5 0 0 1 7 0v10.5"></path>
|
||||
<path d="M14 13h7"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 394 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 8h.01"></path>
|
||||
<path d="M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12z"></path>
|
||||
<path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5"></path>
|
||||
<path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 481 B |
@@ -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" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 17.85h-18c0 -4.05 1.421 -4.05 3.79 -4.05c5.21 0 1.21 -4.59 1.21 -6.8a4 4 0 1 1 8 0c0 2.21 -4 6.8 1.21 6.8c2.369 0 3.79 0 3.79 4.05z"></path>
|
||||
<path d="M5 21h14"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 411 B |
@@ -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" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 3m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z"></path>
|
||||
<path d="M9 11l3 3l3 -3"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 361 B |
@@ -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" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 3m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z"></path>
|
||||
<path d="M10 10l2 -2v8"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 360 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 7v-2h13v2"></path>
|
||||
<path d="M10 5v14"></path>
|
||||
<path d="M12 19h-4"></path>
|
||||
<path d="M15 13v-1h6v1"></path>
|
||||
<path d="M18 12v7"></path>
|
||||
<path d="M17 19h2"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 410 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M10 9a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" /><path d="M4 8v-2a2 2 0 0 1 2 -2h2" /><path d="M4 16v2a2 2 0 0 0 2 2h2" /><path d="M16 4h2a2 2 0 0 1 2 2v2" /><path d="M16 20h2a2 2 0 0 0 2 -2v-2" /><path d="M8 16a2 2 0 0 1 2 -2h4a2 2 0 0 1 2 2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 525 B |
@@ -3,7 +3,7 @@
|
||||
<%= svg_icon('waving_hand', class: 'h-10 w-10') %>
|
||||
<span><%= t('welcome_to_product_name', product_name: Docuseal.product_name) %></span>
|
||||
</h1>
|
||||
<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put, class: 'space-y-6' }) do |f| %>
|
||||
<%= form_for(resource, as: resource_name, url: invitation_path, html: { method: :put, class: 'space-y-6' }) do |f| %>
|
||||
<div class="space-y-2">
|
||||
<%= render 'devise/shared/error_messages', resource: %>
|
||||
<%= f.hidden_field :reset_password_token %>
|
||||
|
||||
@@ -18,6 +18,11 @@
|
||||
<div class="form-control">
|
||||
<%= f.label :email, t('email'), class: 'label' %>
|
||||
<%= f.email_field :email, autocomplete: 'off', class: 'base-input' %>
|
||||
<% if current_user.try(:pending_reconfirmation?) %>
|
||||
<label class="label">
|
||||
<span class="label-text-alt"><%= t('email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm', email: f.object.unconfirmed_email) %></span>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="form-control pt-2">
|
||||
<%= f.button button_title(title: t('update'), disabled_with: t('updating')), class: 'base-button' %>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<a target="_blank" href="<%= Docuseal::GITHUB_URL %>" rel="noopener noreferrer nofollow" class="relative flex items-center rounded-full px-2 py-0.5 text-xs leading-4 mt-1 text-base-content border border-base-300 tooltip tooltip-bottom" data-tip="Give a star on GitHub">
|
||||
<span class="flex items-center justify-between space-x-0.5 font-medium">
|
||||
<%= svg_icon('start', class: 'h-3 w-3') %>
|
||||
<span>10k</span>
|
||||
<span>11k</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
<meta property="og:image" content="">
|
||||
<meta name="twitter:image" content="">
|
||||
<% else %>
|
||||
<meta property="og:image" content="<%= root_url %>preview.png">
|
||||
<meta property="og:image" content="<%= content_for(:preview_image_url).presence || "#{root_url}preview.png" %>">
|
||||
<meta property="og:image:width" content="800">
|
||||
<meta property="og:image:height" content="800">
|
||||
<meta name="twitter:image" content="<%= root_url %>preview.png">
|
||||
<meta name="twitter:image" content="<%= content_for(:preview_image_url).presence || "#{root_url}preview.png" %>">
|
||||
<% end %>
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:creator" content="@docusealco">
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
<% end %>
|
||||
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) && (current_user != true_user || !current_account.linked_account_account) %>
|
||||
<li>
|
||||
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premises" }.to_query}", class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
|
||||
<%= content_for(:pro_link) || link_to(Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premises" }.to_query}", class: 'text-base hover:bg-base-300', data: { turbo: false }) do %>
|
||||
<%= t('plans') %>
|
||||
<span class="badge badge-warning"><%= t('pro') %></span>
|
||||
<% end %>
|
||||
@@ -74,13 +74,13 @@
|
||||
<% end %>
|
||||
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) && (current_user == true_user || current_account.testing?) %>
|
||||
<li>
|
||||
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}#{'/test' if current_account.testing?}/api") : "#{Docuseal::CONSOLE_URL}/on_premises", class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
|
||||
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}#{'/test' if current_account.testing?}/api") : "#{Docuseal::CONSOLE_URL}/on_premises", class: 'text-base hover:bg-base-300', data: { turbo: false } do %>
|
||||
<% if Docuseal.multitenant? %> API <% else %> <%= t('console') %> <% end %>
|
||||
<% end %>
|
||||
</li>
|
||||
<% if Docuseal.multitenant? %>
|
||||
<li>
|
||||
<%= link_to console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}#{'/test' if current_account.testing?}/embedding/form"), class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
|
||||
<%= link_to console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}#{'/test' if current_account.testing?}/embedding/form"), class: 'text-base hover:bg-base-300', data: { turbo: false } do %>
|
||||
<%= t('embedding') %>
|
||||
<% end %>
|
||||
</li>
|
||||
|
||||
@@ -28,9 +28,16 @@
|
||||
<span class="absolute flex items-center justify-center w-7 h-7 rounded-full -start-3.5 ring-8 ring-base-100 text-base-content <%= bg_class %>">
|
||||
<%= svg_icon(SubmissionEventsController::SUBMISSION_EVENT_ICONS.fetch(event.event_type, 'circle_dot'), class: 'w-4 h-4') %>
|
||||
</span>
|
||||
<p class="text-sm leading-none text-base-content/60 pt-1.5">
|
||||
<%= l(event.event_timestamp.in_time_zone(current_account.timezone), format: :long, locale: current_account.locale) %>
|
||||
</p>
|
||||
<div class="flex items-center space-x-1 pt-1.5">
|
||||
<span class="text-sm leading-none text-base-content/60">
|
||||
<%= l(event.event_timestamp.in_time_zone(current_account.timezone), format: :long, locale: current_account.locale) %>
|
||||
</span>
|
||||
<% if (device = DetectBrowserDevice.call(event.data['ua'])) %>
|
||||
<span class="tooltip tooltip-top" data-tip="<%= t(device) %>">
|
||||
<%= svg_icon("device_#{device}", class: 'w-4 h-4') %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="text-base-content/80 mt-1">
|
||||
<% if event.event_type == 'complete_verification' %>
|
||||
<%= t('submission_event_names.complete_verification_by_html', provider: event.data['method'], submitter_name:) %>
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
</div>
|
||||
<div class="grid <%= 'md:grid-cols-2' if submitters.size > 1 %> gap-4">
|
||||
<% submitters.each_with_index do |item, index| %>
|
||||
<% prefillable_fields = local_assigns[:prefillable_fields].to_a.select { |f| f['submitter_uuid'] == item['uuid'] } %>
|
||||
<% prefillable_fields = local_assigns[:prefillable_fields].to_a.select { |f| f['submitter_uuid'] == item['uuid'] }.presence %>
|
||||
<% prefillable_fields ||= local_assigns[:recipient_form_fields].presence %>
|
||||
<submitter-item class="form-control">
|
||||
<% if submitters.size > 1 %>
|
||||
<label class="label pt-0 pb-1">
|
||||
@@ -29,14 +30,14 @@
|
||||
<div class="grid <%= 'md:grid-cols-2 gap-1' if submitters.size == 1 %>">
|
||||
<submitters-autocomplete data-field="email">
|
||||
<linked-input data-target-id="<%= "detailed_email_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
|
||||
<input type="email" multiple name="submission[1][submitters][][email]" autocomplete="off" class="base-input !h-10 mt-1.5 w-full" placeholder="<%= "#{t('email')} (#{t('optional')})" %>" value="<%= item['email'].presence || ((params[:selfsign] && index.zero?) || item['is_requester'] ? current_user.email : '') %>" id="detailed_email_<%= item['uuid'] %>">
|
||||
<%= tag.input type: 'email', multiple: true, name: 'submission[1][submitters][][email]', autocomplete: 'off', class: 'base-input !h-10 mt-1.5 w-full', placeholder: (local_assigns[:require_email_2fa] == true || local_assigns[:prefillable_fields].present? ? t(:email) : "#{t('email')} (#{t('optional')})"), value: item['email'].presence || ((params[:selfsign] && index.zero?) || item['is_requester'] ? current_user.email : ''), id: "detailed_email_#{item['uuid']}", required: local_assigns[:require_email_2fa] == true %>
|
||||
</linked-input>
|
||||
</submitters-autocomplete>
|
||||
<% has_phone_field = true %>
|
||||
<custom-validation data-invalid-message="<%= t('use_international_format_1xxx_') %>">
|
||||
<submitters-autocomplete data-field="phone">
|
||||
<linked-input data-target-id="<%= "detailed_phone_#{item['linked_to_uuid']}" if item['linked_to_uuid'].present? %>">
|
||||
<%= tag.input type: 'tel', pattern: '^\+[0-9\s\-]+$', autocomplete: 'off', class: 'base-input !h-10 mt-1.5 w-full', placeholder: local_assigns[:require_phone_2fa] == true ? t(:phone) : "#{t('phone')} (#{t('optional')})", id: "detailed_phone_#{item['uuid']}", required: local_assigns[:require_phone_2fa] == true %>
|
||||
<%= tag.input type: 'tel', pattern: '^\+[0-9\s\-]+$', name: 'submission[1][submitters][][phone]', autocomplete: 'off', class: 'base-input !h-10 mt-1.5 w-full', placeholder: local_assigns[:require_phone_2fa] == true || local_assigns[:prefillable_fields].present? ? t(:phone) : "#{t('phone')} (#{t('optional')})", id: "detailed_phone_#{item['uuid']}", required: local_assigns[:require_phone_2fa] == true %>
|
||||
</linked-input>
|
||||
</submitters-autocomplete>
|
||||
</custom-validation>
|
||||
@@ -59,17 +60,23 @@
|
||||
</custom-validation>
|
||||
<% end %>
|
||||
<% prefillable_fields.each do |field| %>
|
||||
<% field_id = "detailed_field_#{index}_#{field['uuid'] || field['name'].parameterize}" %>
|
||||
<% if field['type'] == 'checkbox' %>
|
||||
<label for="detailed_field_<%= field['uuid'] %>" class="flex items-center justify-between mt-1.5 pl-3 pr-2.5 h-10 border border-base-content/20 rounded-full cursor-pointer transition-colors bg-white">
|
||||
<label for="<%= field_id %>" class="flex items-center justify-between mt-1.5 pl-3 pr-2.5 h-10 border border-base-content/20 rounded-full cursor-pointer transition-colors bg-white">
|
||||
<span class="text-base select-none px-1"> <%= field['title'].presence || field['name'] %></span>
|
||||
<%= tag.input type: 'checkbox', name: "submission[1][submitters][][values][#{field['uuid']}]", id: "detailed_field_#{field['uuid']}", class: 'toggle toggle-sm', style: 'width: 38px; --handleoffset: 17px', checked: field['default_value'].present? && (field['default_value'] == true || field['default_value'].to_s == '1' || field['default_value'].to_s.downcase == 'true'), required: field['required'], value: 'true' %>
|
||||
<%= tag.input type: 'checkbox', name: "submission[1][submitters][][values][#{field['uuid'] || field['name']}]", id: field_id, class: 'toggle toggle-sm', style: 'width: 38px; --handleoffset: 17px', checked: field['default_value'].present? && (field['default_value'] == true || field['default_value'].to_s == '1' || field['default_value'].to_s.downcase == 'true'), required: field['required'], value: 'true' %>
|
||||
</label>
|
||||
<% elsif field['type'] == 'select' || field['type'] == 'radio' %>
|
||||
<%= select_tag "submission[1][submitters][][values][#{field['uuid']}]", options_for_select(field['options'].pluck('value'), field['default_value']), prompt: t(:select), id: "detailed_field_#{field['uuid']}", class: 'select select-sm base-input !h-10 mt-1.5 ', required: field['required'] %>
|
||||
<%= select_tag "submission[1][submitters][][values][#{field['uuid'] || field['name']}]", options_for_select(field['options'].pluck('value'), field['default_value']), prompt: t(:select), id: field_id, class: 'select select-sm base-input !h-10 mt-1.5 ', required: field['required'] %>
|
||||
<% elsif field['type'] == 'date' %>
|
||||
<%= tag.input type: field['type'], name: "submission[1][submitters][][values][#{field['uuid']}]", autocomplete: 'off', class: 'base-input !h-10 mt-1.5 w-full border rounded p-3', placeholder: (field['required'] ? field['title'].presence || field['name'] : "#{field['title'].presence || field['name']} (#{t('optional')})"), value: field['default_value'], id: "detailed_field_#{field['uuid']}", required: field['required'] %>
|
||||
<%= tag.input type: field['type'], name: "submission[1][submitters][][values][#{field['uuid'] || field['name']}]", autocomplete: 'off', class: 'base-input !h-10 mt-1.5 w-full border rounded p-3', placeholder: (field['required'] ? field['title'].presence || field['name'] : "#{field['title'].presence || field['name']} (#{t('optional')})"), value: field['default_value'], id: field_id, required: field['required'] %>
|
||||
<% elsif field['type'] != 'phone' %>
|
||||
<%= tag.input type: field['type'], name: "submission[1][submitters][][values][#{field['uuid']}]", autocomplete: 'off', class: 'base-input !h-10 mt-1.5 w-full border rounded p-3', placeholder: (field['required'] ? field['title'].presence || field['name'] : "#{field['title'].presence || field['name']} (#{t('optional')})"), value: field['default_value'], id: "detailed_field_#{field['uuid']}", required: field['required'] %>
|
||||
<%= tag.input type: field['type'], name: "submission[1][submitters][][values][#{field['uuid'] || field['name']}]", autocomplete: 'off', class: 'base-input !h-10 mt-1.5 w-full border rounded p-3', placeholder: (field['required'] ? field['title'].presence || field['name'] : "#{field['title'].presence || field['name']} (#{t('optional')})"), value: field['default_value'], id: field_id, required: field['required'] %>
|
||||
<% end %>
|
||||
<% field['conditions']&.each do |condition| %>
|
||||
<% if (condition_field = prefillable_fields.find { |f| f['uuid'] == condition['field_uuid'] || f['name'] == condition['field_name'] }) %>
|
||||
<field-condition data-target-id="<%= field_id %>" data-field-id="<%= "detailed_field_#{index}_#{condition['field_uuid'] || condition['field_name'].parameterize}" %>" data-action="<%= condition['action'] %>" data-value="<%= condition_field['options'].present? ? condition_field['options'].find { |o| o['uuid'] == condition['value'] }&.dig('value') || condition['value'] : condition['value'] %>" data-operation="<%= condition['operation'] %>"></field-condition>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -79,7 +86,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% if params[:selfsign].blank? && local_assigns[:prefillable_fields].blank? %>
|
||||
<% if params[:selfsign].blank? && local_assigns[:prefillable_fields].blank? && local_assigns[:recipient_form_fields].blank? %>
|
||||
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
|
||||
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
|
||||
<span><%= t('add_new') %></span>
|
||||
|
||||