Compare commits

..

110 Commits

Author SHA1 Message Date
Alex Turchyn b3e72f0726 Merge from docusealco/wip 2026-01-19 15:40:56 +02:00
Pete Matsyburka 700ebc4727 render reason 2026-01-19 13:56:17 +02:00
Pete Matsyburka 745a18efca fix select mode draw 2026-01-19 11:57:09 +02:00
Pete Matsyburka b4a8147ff0 fix radio group paste 2026-01-19 11:51:00 +02:00
Pete Matsyburka 0543c25054 detect fields in context menu 2026-01-19 09:23:04 +02:00
Pete Matsyburka b1a0afea66 fix menu 2026-01-18 10:29:15 +02:00
Pete Matsyburka 43c41e2557 add select fields mode 2026-01-18 09:52:43 +02:00
Pete Matsyburka bc8eb33f05 Revert "hide area name menu on desktop"
This reverts commit 5212773d0a.
2026-01-17 19:42:27 +02:00
Pete Matsyburka d1cebc92f7 fix eslint 2026-01-16 12:38:29 +02:00
Pete Matsyburka bf02cf4058 fix paste 2026-01-16 12:25:50 +02:00
Pete Matsyburka 5212773d0a hide area name menu on desktop 2026-01-16 12:18:17 +02:00
Alex Turchyn bf53204a41 add context menu to template builder 2026-01-16 12:07:59 +02:00
Pete Matsyburka 092a19966c adjust condition 2026-01-15 17:34:05 +02:00
Pete Matsyburka 6060a2b798 fix icon 2026-01-14 21:58:30 +02:00
Pete Matsyburka ce12af68b2 fix required 2026-01-14 14:32:00 +02:00
Pete Matsyburka c05f8d47aa require 2fa 2026-01-14 12:44:27 +02:00
Pete Matsyburka f8be19c20e pg 18 2026-01-13 20:44:52 +02:00
Pete Matsyburka 2fe32a2145 show version 2026-01-13 12:25:08 +02:00
Pete Matsyburka 794ea4d0e8 remove expired from pending filter 2026-01-13 12:07:39 +02:00
Pete Matsyburka 307b0f5afd fix build 2026-01-13 09:44:58 +02:00
Alex Turchyn 9011e3f4b4 Merge from docusealco/wip 2026-01-12 13:25:54 +02:00
Pete Matsyburka 0b45ce5c0c update model 2026-01-12 12:05:54 +02:00
Pete Matsyburka a8ab93a4cc adjust sort 2026-01-12 11:27:19 +02:00
Pete Matsyburka d1768be636 secure env 2026-01-12 11:27:17 +02:00
Pete Matsyburka b2cd7c82a7 insert new field at page position 2026-01-11 14:45:42 +02:00
Pete Matsyburka b88d99afe0 merge preview docs 2026-01-11 11:09:00 +02:00
Pete Matsyburka 4523d38551 direct links option 2026-01-09 22:05:37 +02:00
Pete Matsyburka c4ba892559 refactor clone 2026-01-08 09:34:45 +02:00
Pete Matsyburka 40f8093e00 adjust onnx 2026-01-07 14:50:27 +02:00
Pete Matsyburka 4375459856 add nmm 2026-01-07 12:19:05 +02:00
Pete Matsyburka b3eb8a70bf fix submitter uuid 2026-01-07 00:01:47 +02:00
Pete Matsyburka 6245a487b6 add kba countdown 2026-01-06 11:24:30 +02:00
Pete Matsyburka a89f4c1b10 adjust trim 2026-01-06 10:22:34 +02:00
Pete Matsyburka 7df729b8b5 containment threshold 2026-01-06 09:04:25 +02:00
Pete Matsyburka f1db7c7e82 adjust trim 2026-01-06 08:54:29 +02:00
Alex Turchyn 00af69cbf2 Merge from docusealco/wip 2026-01-05 21:46:19 +02:00
Pete Matsyburka c6819a19bd adjust inference 2026-01-05 18:36:50 +02:00
Pete Matsyburka f5cbcb3d24 adjust kba 2026-01-05 13:45:41 +02:00
Pete Matsyburka 6ca75c53ef fix condition 2026-01-05 12:46:43 +02:00
Pete Matsyburka e0ec443e10 fix mysql migration 2026-01-05 12:34:50 +02:00
Pete Matsyburka 9eed2fc2ef fix 2026-01-05 12:21:34 +02:00
Pete Matsyburka b9c6d3ff9f model v2 2026-01-05 09:18:38 +02:00
Pete Matsyburka 03c25a89b2 fix save 2026-01-04 13:16:57 +02:00
Pete Matsyburka f5b4a0c5ab inference v2 2026-01-03 16:55:05 +02:00
Pete Matsyburka b10ed46ccc add kba 2026-01-02 17:02:45 +02:00
Pete Matsyburka c55036a1b1 update gem 2025-12-31 23:27:41 +02:00
Pete Matsyburka 962a5dade5 add preview url 2025-12-31 22:32:27 +02:00
Pete Matsyburka 369a09a6df fix upload 2025-12-26 12:13:30 +02:00
Pete Matsyburka e9a39e2571 add s3 endpoint variable 2025-12-25 14:03:57 +02:00
Pete Matsyburka 1cf9cc18f1 do not retry 2025-12-23 10:17:19 +02:00
Pete Matsyburka 1984d4ec8d fix sync 2025-12-22 20:31:00 +02:00
Alex Turchyn fa99b4ebdc Merge from docusealco/wip 2025-12-22 02:27:47 +02:00
Pete Matsyburka 83c793a2a5 reorder field options 2025-12-21 16:12:57 +02:00
Pete Matsyburka d078727070 recipient fields condition 2025-12-21 13:19:16 +02:00
Pete Matsyburka 0d8e2c5ff0 adjust signature size 2025-12-20 17:12:15 +02:00
Pete Matsyburka ea062d7494 update gem 2025-12-19 12:26:01 +02:00
Pete Matsyburka d525e0597a recipient fields config 2025-12-19 12:26:00 +02:00
Pete Matsyburka 964b5d4e74 validate webhook 2025-12-18 12:45:33 +02:00
Pete Matsyburka abf17eb09f localhost list 2025-12-18 12:12:40 +02:00
Pete Matsyburka 178809dad7 fix field render 2025-12-16 21:54:36 +02:00
Pete Matsyburka 881d189715 fix phone 2025-12-16 12:34:18 +02:00
Pete Matsyburka 48a8973910 add with field labels 2025-12-16 11:38:37 +02:00
Alex Turchyn ae891932c5 Merge from docusealco/wip 2025-12-15 13:19:09 +02:00
Alex Turchyn a8b5e00814 show field boxes on the submissions page 2025-12-15 12:05:37 +02:00
Alex Turchyn e98432d7e4 update docs 2025-12-15 11:11:00 +02:00
Pete Matsyburka 29ff58ed98 fix drawing 2025-12-15 10:22:27 +02:00
Pete Matsyburka a621575ffc 11k 2025-12-14 22:24:07 +02:00
Alex Turchyn d7c7efc203 update brakeman ignore rules 2025-12-12 21:44:54 +02:00
Pete Matsyburka 4f013ca927 do not combine with verification 2025-12-12 20:39:18 +02:00
Pete Matsyburka d9823feef7 fix option 2025-12-12 09:22:42 +02:00
Pete Matsyburka 676d775544 fix generation 2025-12-12 09:14:23 +02:00
Pete Matsyburka 840ad17258 fix locale 2025-12-11 11:11:46 +02:00
Alex Turchyn ce0cd09b70 add translations 2025-12-11 10:28:35 +02:00
Alex Turchyn bb752fbc00 update translations 2025-12-11 10:28:07 +02:00
Pete Matsyburka c71fb04404 fix mysql migration 2025-12-10 13:43:34 +02:00
Pete Matsyburka e856cf8787 validate redirect url 2025-12-10 09:13:55 +02:00
Pete Matsyburka 71aae65bc6 add require email 2fa param 2025-12-09 11:46:57 +02:00
Pete Matsyburka 7a33444f9b add invite_by 2025-12-09 11:39:15 +02:00
Pete Matsyburka e12ec9d986 fix build 2025-12-08 16:26:45 +02:00
Alex Turchyn 94d1fb7dcb Merge from docusealco/wip 2025-12-08 15:31:19 +02:00
Alex Turchyn 6e43a42274 add email 2FA 2025-12-08 14:51:49 +02:00
Pete Matsyburka 0e0aa0259e add validate prop 2025-12-07 15:31:20 +02:00
Pete Matsyburka 75ab6d39ef adjust unarchive 2025-12-07 14:57:21 +02:00
Pete Matsyburka 6f84d84f96 use newer model 2025-12-05 15:17:50 +02:00
Pete Matsyburka cf46593e9b fix required checbox 2025-12-04 21:41:36 +02:00
Pete Matsyburka 1808346348 fix build 2025-12-04 15:38:55 +02:00
Pete Matsyburka ea59a68499 adjust active job log 2025-12-04 14:54:55 +02:00
Pete Matsyburka cba2d02990 skip webhook not found 2025-12-03 13:20:52 +02:00
Pete Matsyburka e92fc0d3e1 adjust mobile field types 2025-12-03 10:33:22 +02:00
Pete Matsyburka f1eaa77092 password reset i18n 2025-12-02 14:25:45 +02:00
Alex Turchyn 5934bdec55 use set_reset_password_token 2025-12-02 14:00:51 +02:00
Alex Turchyn 7a7d3c6ee3 add translations for reset password instructions email 2025-12-02 14:00:51 +02:00
Pete Matsyburka cb2f58c04c fix i18n 2025-12-02 13:31:35 +02:00
Pete Matsyburka a1c117f1ca add i18n 2025-12-02 12:50:58 +02:00
Alex Turchyn 1aacd98460 Merge from docusealco/wip 2025-12-01 17:09:47 +02:00
Pete Matsyburka 840162c9ca fix sort 2025-12-01 10:37:58 +02:00
Pete Matsyburka 3519b0e009 fix typo 2025-12-01 10:15:23 +02:00
Pete Matsyburka 53fae56c51 fix regexp field types 2025-12-01 10:09:27 +02:00
Pete Matsyburka f4552dbaa8 skip reconfirmation 2025-11-30 16:30:56 +02:00
Pete Matsyburka dfab4c331c send confirmation 2025-11-30 16:04:30 +02:00
Pete Matsyburka d2a0937038 fix pdf text emoji 2025-11-30 14:37:05 +02:00
Pete Matsyburka fa1ef44b22 add tabs 2025-11-30 12:41:16 +02:00
Pete Matsyburka c1a5c91299 adjust detection 2025-11-30 11:11:08 +02:00
Pete Matsyburka e6280e8f5a optimize text nodes 2025-11-29 18:28:10 +02:00
Pete Matsyburka fd2ba57325 adjust invitation 2025-11-29 10:34:13 +02:00
Pete Matsyburka dc178ef03f adjust router 2025-11-28 16:15:57 +02:00
Alex Turchyn 68936e10c5 add confirmation columns 2025-11-28 15:57:36 +02:00
Pete Matsyburka 41f16e7d39 adjust detection 2025-11-27 17:00:26 +02:00
Pete Matsyburka 453ebbf90a adjust start form 2025-11-27 12:23:45 +02:00
Pete Matsyburka 7ca290c080 adjust detection 2025-11-25 13:43:35 +02:00
169 changed files with 28954 additions and 26813 deletions
+7 -22
View File
@@ -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
+3
View File
@@ -48,6 +48,9 @@ Style/MultipleComparison:
Style/NumericPredicate:
Enabled: false
Style/MinMaxComparison:
Enabled: false
Naming/PredicateMethod:
Enabled: false
+23 -19
View File
@@ -9,7 +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/1.0.0/model_704_int8.onnx" && \
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
@@ -47,12 +47,14 @@ FROM ruby:3.4.2-alpine AS app
ENV RAILS_ENV=production
ENV BUNDLE_WITHOUT="development:test"
ENV LD_PRELOAD=/lib/libgcompat.so.0
ENV OPENSSL_CONF=/app/openssl_legacy.cnf
ENV OPENSSL_CONF=/etc/openssl_legacy.cnf
WORKDIR /app
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\
[provider_sect]\n\
@@ -63,36 +65,38 @@ legacy = legacy_sect\n\
activate = 1\n\
\n\
[legacy_sect]\n\
activate = 1' >> /app/openssl_legacy.cnf
activate = 1' >> /etc/openssl_legacy.cnf
COPY ./Gemfile ./Gemfile.lock ./
COPY --chown=docuseal:docuseal ./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 && 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
COPY ./config ./config
COPY ./db/migrate ./db/migrate
COPY ./log ./log
COPY ./lib ./lib
COPY ./public ./public
COPY ./tmp ./tmp
COPY LICENSE README.md Rakefile config.ru .version ./
COPY .version ./public/version
COPY --chown=docuseal:docuseal ./bin ./bin
COPY --chown=docuseal:docuseal ./app ./app
COPY --chown=docuseal:docuseal ./config ./config
COPY --chown=docuseal:docuseal ./db/migrate ./db/migrate
COPY --chown=docuseal:docuseal ./log ./log
COPY --chown=docuseal:docuseal ./lib ./lib
COPY --chown=docuseal:docuseal ./public ./public
COPY --chown=docuseal:docuseal ./tmp ./tmp
COPY --chown=docuseal:docuseal LICENSE README.md Rakefile config.ru .version ./
COPY --chown=docuseal:docuseal .version ./public/version
COPY --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
COPY --chown=docuseal:docuseal --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
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
COPY --chown=docuseal:docuseal --from=download /model.onnx /app/tmp/model.onnx
COPY --chown=docuseal:docuseal --from=webpack /app/public/packs ./public/packs
RUN ln -s /fonts /app/public/fonts
RUN bundle exec bootsnap precompile --gemfile app/ lib/
RUN ln -s /fonts /app/public/fonts && \
bundle exec bootsnap precompile -j 1 --gemfile app/ lib/ && \
chown -R docuseal:docuseal /app/tmp/cache
WORKDIR /data/docuseal
ENV HOME=/home/docuseal
ENV WORKDIR=/data/docuseal
EXPOSE 3000
+12 -9
View File
@@ -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)
@@ -584,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)
+1
View File
@@ -46,6 +46,7 @@ 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)
@@ -37,7 +37,7 @@ module Api
rescue Submitters::MaliciousFileExtension => e
Rollbar.error(e) if defined?(Rollbar)
render json: { error: e.message }, status: :unprocessable_entity
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,
+1 -1
View File
@@ -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
@@ -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
+2
View File
@@ -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
+8 -1
View File
@@ -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
+4 -2
View File
@@ -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',
+11 -2
View File
@@ -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
@@ -53,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
+16
View File
@@ -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
@@ -77,6 +83,8 @@ class SubmitFormController < ApplicationController
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
@@ -109,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
@@ -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
+5 -47
View File
@@ -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
@@ -11,11 +11,14 @@ class TemplatesDetectFieldsController < ApplicationController
sse = SSE.new(response.stream)
documents = @template.schema_documents.preload(:blob)
documents = documents.where(uuid: params[:attachment_uuid]) if params[:attachment_uuid].present?
page_number = params[:page].present? ? params[:page].to_i : nil
documents.each do |document|
io = StringIO.new(document.download)
Templates::DetectFields.call(io, attachment: document) do |(attachment_uuid, page, fields)|
Templates::DetectFields.call(io, attachment: document, page_number:) do |(attachment_uuid, page, fields)|
sse.write({ attachment_uuid:, page:, fields: })
end
end
@@ -54,7 +54,7 @@ class TemplatesPreferencesController < ApplicationController
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
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] +
+10 -2
View File
@@ -54,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))
@@ -65,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
+3
View File
@@ -52,6 +52,7 @@ 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'
@@ -142,6 +143,7 @@ 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 () {
@@ -157,6 +159,7 @@ 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',
+151
View File
@@ -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))
}
}
}
+1
View File
@@ -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',
+18 -4
View File
@@ -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]
@@ -560,7 +574,7 @@ export default {
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: {
+53 -12
View File
@@ -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)
@@ -1217,17 +1244,31 @@ export default {
} else if (['equal', 'contains'].includes(condition.action) && field) {
if (field.options) {
const option = field.options.find((o) => o.uuid === condition.value)
const values = [this.values[condition.field_uuid] ?? defaultValue].flat()
return values.includes(this.optionValue(option, field.options.indexOf(option)))
if (option) {
const values = [this.values[condition.field_uuid] ?? defaultValue].flat()
return values.includes(this.optionValue(option, field.options.indexOf(option)))
} else {
return false
}
} else {
return [this.values[condition.field_uuid] ?? defaultValue].flat().includes(condition.value)
}
} else if (['not_equal', 'does_not_contain'].includes(condition.action) && field) {
const option = field.options.find((o) => o.uuid === condition.value)
const values = [this.values[condition.field_uuid] ?? defaultValue].flat()
if (field.options) {
const option = field.options.find((o) => o.uuid === condition.value)
return !values.includes(this.optionValue(option, field.options.indexOf(option)))
if (option) {
const values = [this.values[condition.field_uuid] ?? defaultValue].flat()
return !values.includes(this.optionValue(option, field.options.indexOf(option)))
} else {
return false
}
} else {
return false
}
} else {
return true
}
@@ -1387,7 +1428,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({})
+28 -14
View File
@@ -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">
+576
View File
@@ -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="#"
@@ -281,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"
+58 -17
View File
@@ -8,7 +8,7 @@
@touchstart="startTouchDrag"
>
<div
v-if="isSelected || isDraw"
v-if="isSelected || isDraw || isInMultiSelection"
class="top-0 bottom-0 right-0 left-0 absolute border border-1.5 pointer-events-none"
:class="activeBorderClasses"
/>
@@ -24,7 +24,7 @@
:style="{ left: (cellW / area.w * 100) + '%' }"
>
<span
v-if="index === 0 && editable"
v-if="index === 0 && editable && !isInMultiSelection"
class="h-2.5 w-2.5 rounded-full -bottom-1 border-gray-400 bg-white shadow-md border absolute cursor-ew-resize z-10"
style="left: -4px"
@mousedown.stop="startResizeCell"
@@ -32,7 +32,7 @@
</div>
</div>
<div
v-if="field?.type && (isSelected || isNameFocus)"
v-if="field?.type && (isSelected || isNameFocus) && !isInMultiSelection"
class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap flex z-10 field-area-controls"
style="top: -25px; height: 25px"
@mousedown.stop
@@ -48,7 +48,7 @@
:menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px] mt-[1px]'"
:submitters="template.submitters"
@update:model-value="save"
@click="selectedAreaRef.value = area"
@click="selectedAreasRef.value = [area]"
/>
<FieldType
v-model="field.type"
@@ -57,7 +57,7 @@
:button-classes="'px-1'"
:menu-classes="'bg-white rounded-t-none'"
@update:model-value="[maybeUpdateOptions(), save()]"
@click="selectedAreaRef.value = area"
@click="selectedAreasRef.value = [area]"
/>
<span
v-if="field.type !== 'checkbox' || field.name"
@@ -146,7 +146,7 @@
@click-font="isShowFontModal = true"
@click-description="isShowDescriptionModal = true"
@click-condition="isShowConditionsModal = true"
@scroll-to="[selectedAreaRef.value = $event, $emit('scroll-to', $event)]"
@scroll-to="[selectedAreasRef.value = [$event], $emit('scroll-to', $event)]"
/>
</ul>
</span>
@@ -266,7 +266,7 @@
ref="defaultValueSelect"
class="bg-transparent outline-none focus:outline-none w-full"
@change="[field.default_value = $event.target.value, field.readonly = !!field.default_value?.length, save()]"
@focus="selectedAreaRef.value = area"
@focus="selectedAreasRef.value = [area]"
@keydown.enter="onDefaultValueEnter"
>
<option
@@ -293,7 +293,7 @@
:class="{ 'cursor-text': isValueInput }"
:placeholder="withFieldPlaceholder && !isValueInput ? defaultField?.title || field.title || field.name || defaultName : (field.type === 'date' ? field.preferences?.format || t('type_value') : t('type_value'))"
@blur="onDefaultValueBlur"
@focus="selectedAreaRef.value = area"
@focus="selectedAreasRef.value = [area]"
@paste.prevent="onPaste"
@keydown.enter="onDefaultValueEnter"
>{{ field.default_value }}</span>
@@ -319,6 +319,7 @@
<span
v-if="field?.type && editable"
class="h-4 w-4 lg:h-2.5 lg:w-2.5 -right-1 rounded-full -bottom-1 border-gray-400 bg-white shadow-md border absolute cursor-nwse-resize"
:class="{ 'z-30': isInMultiSelection }"
@mousedown.stop="startResize"
@touchstart="startTouchResize"
/>
@@ -398,7 +399,7 @@ export default {
FieldSubmitter,
IconX
},
inject: ['template', 'selectedAreaRef', 'save', 't', 'isInlineSize'],
inject: ['template', 'save', 't', 'isInlineSize', 'selectedAreasRef', 'isCmdKeyRef'],
props: {
area: {
type: Object,
@@ -463,6 +464,11 @@ export default {
type: Object,
required: false,
default: null
},
isSelectMode: {
type: Boolean,
required: false,
default: false
}
},
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to'],
@@ -646,7 +652,10 @@ export default {
]
},
isSelected () {
return this.selectedAreaRef.value === this.area
return this.selectedAreasRef.value.includes(this.area)
},
isInMultiSelection () {
return this.selectedAreasRef.value.length >= 2 && this.isSelected
},
positionStyle () {
const { x, y, w, h } = this.area
@@ -683,10 +692,10 @@ export default {
buildAreaOptionValue (area) {
const option = this.optionsUuidIndex[area.option_uuid]
return option.value || `${this.t('option')} ${this.field.options.indexOf(option) + 1}`
return option?.value || `${this.t('option')} ${this.field.options.indexOf(option) + 1}`
},
maybeToggleDefaultValue () {
if (!this.editable) {
if (!this.editable || this.isCmdKeyRef.value) {
return
}
@@ -770,7 +779,7 @@ export default {
}
},
onNameFocus (e) {
this.selectedAreaRef.value = this.area
this.selectedAreasRef.value = [this.area]
this.isNameFocus = true
this.$refs.name.style.minWidth = this.$refs.name.clientWidth + 'px'
@@ -906,6 +915,15 @@ export default {
if (e.target.id === 'mask') {
this.area.w = e.offsetX / e.target.clientWidth - this.area.x
this.area.h = e.offsetY / e.target.clientHeight - this.area.y
if (this.isInMultiSelection) {
this.selectedAreasRef.value.forEach((area) => {
if (area !== this.area) {
area.w = this.area.w
area.h = this.area.h
}
})
}
}
},
drag (e) {
@@ -931,7 +949,7 @@ export default {
const rect = e.target.getBoundingClientRect()
this.selectedAreaRef.value = this.area
this.selectedAreasRef.value = [this.area]
this.dragFrom = { x: rect.left - e.touches[0].clientX, y: rect.top - e.touches[0].clientY }
@@ -980,13 +998,23 @@ export default {
e.preventDefault()
if (e.metaKey || e.ctrlKey) {
if (!this.selectedAreasRef.value.includes(this.area)) {
this.selectedAreasRef.value.push(this.area)
} else {
this.selectedAreasRef.value.splice(this.selectedAreasRef.value.indexOf(this.area), 1)
}
return
}
if (this.editable) {
this.isDragged = true
}
const rect = e.target.getBoundingClientRect()
this.selectedAreaRef.value = this.area
this.selectedAreasRef.value = [this.area]
this.dragFrom = { x: rect.left - e.clientX, y: rect.top - e.clientY }
@@ -1055,7 +1083,9 @@ export default {
this.$emit('stop-drag')
},
startResize () {
this.selectedAreaRef.value = this.area
if (!this.selectedAreasRef.value.includes(this.area)) {
this.selectedAreasRef.value = [this.area]
}
this.$el.getRootNode().addEventListener('mousemove', this.resize)
this.$el.getRootNode().addEventListener('mouseup', this.stopResize)
@@ -1071,7 +1101,9 @@ export default {
this.save()
},
startTouchResize (e) {
this.selectedAreaRef.value = this.area
if (!this.selectedAreasRef.value.includes(this.area)) {
this.selectedAreasRef.value = [this.area]
}
this.$refs?.name?.blur()
@@ -1088,6 +1120,15 @@ export default {
this.area.w = (e.touches[0].clientX - rect.left) / rect.width - this.area.x
this.area.h = (e.touches[0].clientY - rect.top) / rect.height - this.area.y
if (this.isInMultiSelection) {
this.selectedAreasRef.value.forEach((area) => {
if (area !== this.area) {
area.w = this.area.w
area.h = this.area.h
}
})
}
},
stopTouchResize () {
this.$el.getRootNode().removeEventListener('touchmove', this.touchResize)
File diff suppressed because it is too large Load Diff
@@ -168,9 +168,19 @@ export default {
buildDefaultName: {
type: Function,
required: true
},
withClickSaveEvent: {
type: Boolean,
required: false,
default: false
},
excludeFieldUuids: {
type: Array,
required: false,
default: () => []
}
},
emits: ['close'],
emits: ['close', 'click-save'],
data () {
return {
conditions: this.item.conditions?.[0] ? JSON.parse(JSON.stringify(this.item.conditions)) : [{}]
@@ -183,14 +193,14 @@ export default {
fields () {
if (this.item.submitter_uuid) {
return this.template.fields.reduce((acc, f) => {
if (f !== this.item && !this.excludeTypes.includes(f.type) && (!f.conditions?.length || !f.conditions.find((c) => c.field_uuid === this.item.uuid))) {
if (f !== this.item && !this.excludeTypes.includes(f.type) && !this.excludeFieldUuids.includes(f.uuid) && (!f.conditions?.length || !f.conditions.find((c) => c.field_uuid === this.item.uuid))) {
acc.push(f)
}
return acc
}, [])
} else {
return this.template.fields
return this.template.fields.filter((f) => !this.excludeFieldUuids.includes(f.uuid))
}
}
},
@@ -234,7 +244,12 @@ export default {
delete this.item.conditions
}
this.save()
if (this.withClickSaveEvent) {
this.$emit('click-save')
} else {
this.save()
}
this.$emit('close')
}
}
@@ -0,0 +1,546 @@
<template>
<div>
<div
v-if="!isShowFormulaModal && !isShowFontModal && !isShowConditionsModal && !isShowDescriptionModal"
ref="menu"
class="fixed z-50 p-1 bg-white shadow-lg rounded-lg border border-base-300 min-w-[170px] cursor-default"
:style="menuStyle"
@mousedown.stop
@pointerdown.stop
>
<label
v-if="showRequired"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm cursor-pointer"
@click.stop
>
<input
:checked="isRequired"
type="checkbox"
class="toggle toggle-xs"
@change="handleToggleRequired($event.target.checked)"
@click.stop
>
<span>{{ t('required') }}</span>
</label>
<label
v-if="showReadOnly"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm cursor-pointer"
@click.stop
>
<input
:checked="isReadOnly"
type="checkbox"
class="toggle toggle-xs"
@change="handleToggleReadOnly($event.target.checked)"
@click.stop
>
<span>{{ t('read_only') }}</span>
</label>
<hr
v-if="(showRequired || showReadOnly) && (showFont || showDescription || showCondition || showFormula)"
class="my-1 border-base-300"
>
<button
v-if="showFont && !isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openFontModal"
>
<IconTypography class="w-4 h-4" />
<span>{{ t('font') }}</span>
</button>
<button
v-if="showDescription"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openDescriptionModal"
>
<IconInfoCircle class="w-4 h-4" />
<span>{{ t('description') }}</span>
</button>
<button
v-if="showCondition && !isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openConditionModal"
>
<IconRouteAltLeft class="w-4 h-4" />
<span>{{ t('condition') }}</span>
</button>
<button
v-if="showFormula"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openFormulaModal"
>
<IconMathFunction class="w-4 h-4" />
<span>{{ t('formula') }}</span>
</button>
<hr
v-if="((showFont && !isMultiSelection) || showDescription || (showCondition && !isMultiSelection) || showFormula) && (showCopy || showDelete || showPaste)"
class="my-1 border-base-300"
>
<button
v-if="isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="$emit('align', 'left')"
>
<IconLayoutAlignLeft class="w-4 h-4" />
<span>{{ t('align_left') }}</span>
</button>
<button
v-if="isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="$emit('align', 'right')"
>
<IconLayoutAlignRight class="w-4 h-4" />
<span>{{ t('align_right') }}</span>
</button>
<button
v-if="isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="$emit('align', 'top')"
>
<IconLayoutAlignTop class="w-4 h-4" />
<span>{{ t('align_top') }}</span>
</button>
<button
v-if="isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="$emit('align', 'bottom')"
>
<IconLayoutAlignBottom class="w-4 h-4" />
<span>{{ t('align_bottom') }}</span>
</button>
<hr
v-if="isMultiSelection && (showFont || showCondition)"
class="my-1 border-base-300"
>
<button
v-if="showFont && isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openFontModal"
>
<IconTypography class="w-4 h-4" />
<span>{{ t('font') }}</span>
</button>
<button
v-if="showCondition && isMultiSelection"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="openConditionModal"
>
<IconRouteAltLeft class="w-4 h-4" />
<span>{{ t('condition') }}</span>
</button>
<hr
v-if="isMultiSelection"
class="my-1 border-base-300"
>
<button
v-if="showCopy"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center justify-between text-sm"
@click.stop="$emit('copy')"
>
<span class="flex items-center space-x-2">
<IconCopy class="w-4 h-4" />
<span>{{ t('copy') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">{{ isMac ? '⌘C' : 'Ctrl+C' }}</span>
</button>
<button
v-if="showDelete"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center justify-between text-sm text-red-600"
@click.stop="$emit('delete')"
>
<span class="flex items-center space-x-2">
<IconTrashX class="w-4 h-4" />
<span>{{ t('remove') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">Del</span>
</button>
<button
v-if="showPaste"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center justify-between text-sm"
:class="!hasClipboardData ? 'opacity-50 cursor-not-allowed' : 'hover:bg-base-100'"
:disabled="!hasClipboardData"
@click.stop="!hasClipboardData ? null : $emit('paste')"
>
<span class="flex items-center space-x-2">
<IconClipboard class="w-4 h-4" />
<span>{{ t('paste') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">{{ isMac ? '⌘V' : 'Ctrl+V' }}</span>
</button>
<button
v-if="showSelectFields"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center justify-between text-sm"
@click.stop="handleToggleSelectMode"
>
<span class="flex items-center space-x-2">
<IconClick
v-if="!isSelectModeRef.value"
class="w-4 h-4"
/>
<IconNewSection
v-else
class="w-4 h-4"
/>
<span>{{ isSelectModeRef.value ? t('draw_fields') : t('select_fields') }}</span>
</span>
<span class="text-xs text-base-content/60 ml-4">Tab</span>
</button>
<hr
v-if="showAutodetectFields"
class="my-1 border-base-300"
>
<button
v-if="showAutodetectFields"
class="w-full px-2 py-1 rounded-md hover:bg-base-100 flex items-center space-x-2 text-sm"
@click.stop="$emit('autodetect-fields')"
>
<IconSparkles class="w-4 h-4" />
<span>{{ t('autodetect_fields') }}</span>
</button>
</div>
<Teleport
v-if="isShowFormulaModal"
:to="modalContainerEl"
>
<FormulaModal
:field="field"
:editable="editable"
:build-default-name="buildDefaultName"
@close="closeModal"
/>
</Teleport>
<Teleport
v-if="isShowFontModal"
:to="modalContainerEl"
>
<FontModal
:field="multiSelectField || field"
:area="contextMenu.area"
:editable="editable"
:build-default-name="buildDefaultName"
:with-click-save-event="isMultiSelection"
@click-save="handleSaveMultiSelectFontModal"
@close="closeModal"
/>
</Teleport>
<Teleport
v-if="isShowConditionsModal"
:to="modalContainerEl"
>
<ConditionsModal
:item="multiSelectField || field"
:build-default-name="buildDefaultName"
:exclude-field-uuids="isMultiSelection ? selectedFields.map(f => f.uuid) : []"
:with-click-save-event="isMultiSelection"
@click-save="handleSaveMultiSelectConditionsModal"
@close="closeModal"
/>
</Teleport>
<Teleport
v-if="isShowDescriptionModal"
:to="modalContainerEl"
>
<DescriptionModal
:field="field"
:editable="editable"
:build-default-name="buildDefaultName"
@close="closeModal"
/>
</Teleport>
</div>
</template>
<script>
import { IconCopy, IconClipboard, IconTrashX, IconTypography, IconInfoCircle, IconRouteAltLeft, IconMathFunction, IconClick, IconNewSection, IconLayoutAlignLeft, IconLayoutAlignRight, IconLayoutAlignTop, IconLayoutAlignBottom, IconSparkles } from '@tabler/icons-vue'
import FormulaModal from './formula_modal'
import FontModal from './font_modal'
import ConditionsModal from './conditions_modal'
import DescriptionModal from './description_modal'
import Field from './field'
import FieldType from './field_type.vue'
export default {
name: 'ContextMenu',
components: {
IconCopy,
IconClipboard,
IconTrashX,
IconTypography,
IconInfoCircle,
IconRouteAltLeft,
IconMathFunction,
IconClick,
IconNewSection,
IconLayoutAlignLeft,
IconLayoutAlignRight,
IconLayoutAlignTop,
IconLayoutAlignBottom,
IconSparkles,
FormulaModal,
FontModal,
ConditionsModal,
DescriptionModal
},
inject: ['t', 'save', 'selectedAreasRef', 'isSelectModeRef'],
props: {
contextMenu: {
type: Object,
default: null,
required: true
},
field: {
type: Object,
default: null
},
editable: {
type: Boolean,
default: true
},
isMultiSelection: {
type: Boolean,
default: false
},
selectedAreas: {
type: Array,
default: () => []
},
template: {
type: Object,
default: null
},
withFieldsDetection: {
type: Boolean,
default: false
}
},
emits: ['copy', 'paste', 'delete', 'close', 'align', 'autodetect-fields'],
data () {
return {
isShowFormulaModal: false,
isShowFontModal: false,
isShowConditionsModal: false,
isShowDescriptionModal: false,
multiSelectField: null
}
},
computed: {
fieldNames: FieldType.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels,
modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
},
selectedFields () {
if (!this.isMultiSelection) return []
return this.selectedAreasRef.value.map((area) => {
return this.template.fields.find((f) => f.areas?.includes(area))
}).filter(Boolean)
},
isMac () {
return (navigator.userAgentData?.platform || navigator.platform)?.toLowerCase()?.includes('mac')
},
hasClipboardData () {
try {
const clipboard = localStorage.getItem('docuseal_clipboard')
if (clipboard) {
const data = JSON.parse(clipboard)
return Date.now() - data.timestamp < 3600000
}
return false
} catch {
return false
}
},
menuStyle () {
return {
left: this.contextMenu.x + 'px',
top: this.contextMenu.y + 'px'
}
},
showCopy () {
return !!this.contextMenu.area || this.isMultiSelection
},
showPaste () {
return !this.contextMenu.area && !this.isMultiSelection
},
showDelete () {
return !!this.contextMenu.area || this.isMultiSelection
},
showFont () {
if (this.isMultiSelection) return true
if (!this.field) return false
return ['text', 'number', 'date', 'select', 'heading'].includes(this.field.type)
},
showDescription () {
if (!this.field) return false
return !['stamp', 'heading', 'strikethrough'].includes(this.field.type)
},
showCondition () {
if (this.isMultiSelection) return true
if (!this.field) return false
return !['stamp', 'heading'].includes(this.field.type)
},
showFormula () {
if (!this.field) return false
return this.field.type === 'number'
},
showRequired () {
if (!this.field) return false
return !['phone', 'stamp', 'verification', 'strikethrough', 'heading'].includes(this.field.type)
},
showReadOnly () {
if (!this.field) return false
return ['text', 'number'].includes(this.field.type)
},
isRequired () {
return this.field?.required || false
},
isReadOnly () {
return this.field?.readonly || false
},
showSelectFields () {
return !this.contextMenu.area && !this.isMultiSelection
},
showAutodetectFields () {
return this.withFieldsDetection && this.editable && !this.contextMenu.area && !this.isMultiSelection
}
},
mounted () {
document.addEventListener('keydown', this.onKeyDown)
document.addEventListener('mousedown', this.handleClickOutside)
this.$nextTick(() => {
this.checkMenuPosition()
})
},
beforeUnmount () {
document.removeEventListener('keydown', this.onKeyDown)
document.removeEventListener('mousedown', this.handleClickOutside)
},
methods: {
buildDefaultName: Field.methods.buildDefaultName,
checkMenuPosition () {
if (this.$refs.menu) {
const rect = this.$refs.menu.getBoundingClientRect()
if (rect.bottom > window.innerHeight) {
this.contextMenu.y = this.contextMenu.y - rect.height
}
if (rect.right > window.innerWidth) {
this.contextMenu.x = this.contextMenu.x - rect.width
}
}
},
handleToggleRequired (value) {
if (this.field) {
this.field.required = value
this.save()
}
},
handleToggleReadOnly (value) {
if (this.field) {
this.field.readonly = value
this.save()
}
},
onKeyDown (event) {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
this.$emit('close')
} else if ((event.ctrlKey || event.metaKey) && event.key === 'v' && this.hasClipboardData) {
event.preventDefault()
event.stopPropagation()
this.$emit('paste')
}
},
handleClickOutside (event) {
if (this.$refs.menu && !this.$refs.menu.contains(event.target)) {
this.$emit('close')
}
},
openFontModal () {
if (this.isMultiSelection) {
this.multiSelectField = {
name: this.t('fields_selected').replace('{count}', this.selectedFields.length),
preferences: {}
}
const preferencesStrings = this.selectedFields.map((f) => JSON.stringify(f.preferences || {}))
if (preferencesStrings.every((s) => s === preferencesStrings[0])) {
this.multiSelectField.preferences = JSON.parse(preferencesStrings[0])
}
}
this.isShowFontModal = true
},
openDescriptionModal () {
this.isShowDescriptionModal = true
},
openConditionModal () {
if (this.isMultiSelection) {
this.multiSelectField = {
name: this.t('fields_selected').replace('{count}', this.selectedFields.length),
conditions: []
}
const conditionStrings = this.selectedFields.map((f) => JSON.stringify(f.conditions || []))
if (conditionStrings.every((s) => s === conditionStrings[0])) {
this.multiSelectField.conditions = JSON.parse(conditionStrings[0])
}
}
this.isShowConditionsModal = true
},
openFormulaModal () {
this.isShowFormulaModal = true
},
closeModal () {
this.isShowFormulaModal = false
this.isShowFontModal = false
this.isShowConditionsModal = false
this.isShowDescriptionModal = false
this.multiSelectField = null
this.$emit('close')
},
handleSaveMultiSelectFontModal () {
this.selectedFields.forEach((field) => {
field.preferences = { ...field.preferences, ...this.multiSelectField.preferences }
})
this.save()
this.closeModal()
},
handleSaveMultiSelectConditionsModal () {
this.selectedFields.forEach((field) => {
field.conditions = JSON.parse(JSON.stringify(this.multiSelectField.conditions))
})
this.save()
this.closeModal()
},
handleToggleSelectMode () {
this.isSelectModeRef.value = !this.isSelectModeRef.value
this.$emit('close')
}
}
}
</script>
+15 -2
View File
@@ -22,8 +22,16 @@
:selected-submitter="selectedSubmitter"
:total-pages="sortedPreviewImages.length"
:image="image"
@drop-field="$emit('drop-field', {...$event, attachment_uuid: document.uuid })"
:attachment-uuid="document.uuid"
:with-fields-detection="withFieldsDetection"
@drop-field="$emit('drop-field', { ...$event, attachment_uuid: document.uuid })"
@remove-area="$emit('remove-area', $event)"
@copy-field="$emit('copy-field', $event)"
@paste-field="$emit('paste-field', { ...$event, attachment_uuid: document.uuid })"
@copy-selected-areas="$emit('copy-selected-areas')"
@delete-selected-areas="$emit('delete-selected-areas')"
@align-selected-areas="$emit('align-selected-areas', $event)"
@autodetect-fields="$emit('autodetect-fields', $event)"
@scroll-to="scrollToArea"
@draw="$emit('draw', { area: {...$event.area, attachment_uuid: document.uuid }, isTooSmall: $event.isTooSmall })"
/>
@@ -116,9 +124,14 @@ export default {
type: Boolean,
required: false,
default: false
},
withFieldsDetection: {
type: Boolean,
required: false,
default: false
}
},
emits: ['draw', 'drop-field', 'remove-area'],
emits: ['draw', 'drop-field', 'remove-area', 'paste-field', 'copy-field', 'copy-selected-areas', 'delete-selected-areas', 'align-selected-areas', 'autodetect-fields'],
data () {
return {
pageRefs: []
+86 -10
View File
@@ -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>
@@ -321,7 +330,7 @@ export default {
IconMathFunction,
FieldType
},
inject: ['template', 'save', 'backgroundColor', 'selectedAreaRef', 't', 'locale'],
inject: ['template', 'save', 'backgroundColor', 'selectedAreasRef', 't', 'locale'],
props: {
field: {
type: Object,
@@ -363,7 +372,8 @@ export default {
isShowFontModal: false,
isShowConditionsModal: false,
isShowDescriptionModal: false,
renderDropdown: false
renderDropdown: false,
optionDragRef: null
}
},
computed: {
@@ -441,7 +451,7 @@ export default {
const area = this.field.areas.find((a) => a.option_uuid === option.uuid)
if (area) {
this.selectedAreaRef.value = area
this.selectedAreasRef.value = [area]
}
},
scrollToFirstArea () {
@@ -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 () {
+13 -7
View File
@@ -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"
@@ -283,7 +283,7 @@ export default {
IconDrag,
IconLock
},
inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withPayment', 't', 'fieldsDragFieldRef', 'baseFetch'],
inject: ['save', 'backgroundColor', 'withPhone', 'withVerification', 'withKba', 'withPayment', 't', 'fieldsDragFieldRef', 'baseFetch', 'selectedAreasRef'],
props: {
fields: {
type: Array,
@@ -579,7 +579,7 @@ export default {
removeSubmitter (submitter) {
[...this.fields].forEach((field) => {
if (field.submitter_uuid === submitter.uuid) {
this.removeField(field)
this.removeField(field, false)
}
})
@@ -591,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) => {
@@ -610,7 +610,13 @@ export default {
})
})
this.save()
field.areas?.forEach((area) => {
this.selectedAreasRef.value.splice(this.selectedAreasRef.value.indexOf(area), 1)
})
if (save) {
this.save()
}
}
}
}
+11 -2
View File
@@ -212,12 +212,17 @@ export default {
required: false,
default: true
},
withClickSaveEvent: {
type: Boolean,
required: false,
default: false
},
buildDefaultName: {
type: Function,
required: true
}
},
emits: ['close'],
emits: ['close', 'click-save'],
data () {
return {
preferences: {}
@@ -322,7 +327,11 @@ export default {
Object.assign(this.field.preferences, this.preferences)
this.save()
if (this.withClickSaveEvent) {
this.$emit('click-save')
} else {
this.save()
}
this.$emit('close')
}
@@ -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 })"
+91 -7
View File
@@ -1,4 +1,5 @@
const en = {
kba: 'KBA',
analyzing_: 'Analyzing...',
download: 'Download',
downloading_: 'Downloading...',
@@ -184,10 +185,22 @@ const en = {
start_tour: 'Start Tour',
or_add_from: 'Or add from',
sync: 'Sync',
syncing: 'Syncing...'
syncing: 'Syncing...',
copy: 'Copy',
paste: 'Paste',
select_fields: 'Select Fields',
draw_fields: 'Draw Fields',
align_left: 'Align Left',
align_right: 'Align Right',
align_top: 'Align Top',
align_bottom: 'Align Bottom',
fields_selected: '{count} Fields Selected',
field_added: '{count} Field Added',
fields_added: '{count} Fields Added'
}
const es = {
kba: 'KBA',
autodetect_fields: 'Autodetectar campos',
analyzing_: 'Analizando...',
download: 'Descargar',
@@ -373,10 +386,22 @@ const es = {
start_tour: 'Iniciar guía',
or_add_from: 'O agregar desde',
sync: 'Sincronizar',
syncing: 'Sincronizando...'
syncing: 'Sincronizando...',
copy: 'Copiar',
paste: 'Pegar',
select_fields: 'Seleccionar Campos',
draw_fields: 'Dibujar Campos',
align_left: 'Alinear a la izquierda',
align_right: 'Alinear a la derecha',
align_top: 'Alinear arriba',
align_bottom: 'Alinear abajo',
fields_selected: '{count} Campos Seleccionados',
field_added: '{count} Campo Añadido',
fields_added: '{count} Campos Añadidos'
}
const it = {
kba: 'KBA',
autodetect_fields: 'Rileva campi',
analyzing_: 'Analisi...',
download: 'Scarica',
@@ -562,10 +587,22 @@ const it = {
start_tour: 'Inizia il tour',
or_add_from: 'O aggiungi da',
sync: 'Sincronizza',
syncing: 'Sincronizzazione...'
syncing: 'Sincronizzazione...',
copy: 'Copia',
paste: 'Incolla',
select_fields: 'Seleziona Campi',
draw_fields: 'Disegna Campi',
align_left: 'Allinea a sinistra',
align_right: 'Allinea a destra',
align_top: 'Allinea in alto',
align_bottom: 'Allinea in basso',
fields_selected: '{count} Campi Selezionati',
field_added: '{count} Campo Aggiunto',
fields_added: '{count} Campi Aggiunti'
}
const pt = {
kba: 'KBA',
autodetect_fields: 'Detectar campos',
analyzing_: 'Analisando...',
download: 'Baixar',
@@ -751,10 +788,22 @@ const pt = {
start_tour: 'Iniciar tour',
or_add_from: 'Ou adicionar de',
sync: 'Sincronizar',
syncing: 'Sincronizando...'
syncing: 'Sincronizando...',
copy: 'Copiar',
paste: 'Colar',
select_fields: 'Selecionar Campos',
draw_fields: 'Desenhar Campos',
align_left: 'Alinhar à esquerda',
align_right: 'Alinhar à direita',
align_top: 'Alinhar ao topo',
align_bottom: 'Alinhar à parte inferior',
fields_selected: '{count} Campos Selecionados',
field_added: '{count} Campo Adicionado',
fields_added: '{count} Campos Adicionados'
}
const fr = {
kba: 'KBA',
autodetect_fields: 'Détecter les champs',
analyzing_: 'Analyse...',
download: 'Télécharger',
@@ -940,10 +989,22 @@ const fr = {
start_tour: 'Démarrer',
or_add_from: 'Ou ajouter depuis',
sync: 'Synchroniser',
syncing: 'Synchronisation...'
syncing: 'Synchronisation...',
copy: 'Copier',
paste: 'Coller',
select_fields: 'Sélectionner Champs',
draw_fields: 'Dessiner Champs',
align_left: 'Aligner à gauche',
align_right: 'Aligner à droite',
align_top: 'Aligner en haut',
align_bottom: 'Aligner en bas',
fields_selected: '{count} Champs Sélectionnés',
field_added: '{count} Champ Ajouté',
fields_added: '{count} Champs Ajoutés'
}
const de = {
kba: 'KBA',
autodetect_fields: 'Felder erkennen',
analyzing_: 'Analysiere...',
download: 'Download',
@@ -1129,10 +1190,22 @@ const de = {
start_tour: 'Tour starten',
or_add_from: 'Oder hinzufügen aus',
sync: 'Synchronisieren',
syncing: 'Synchronisiere...'
syncing: 'Synchronisiere...',
copy: 'Kopieren',
paste: 'Einfügen',
select_fields: 'Felder Auswählen',
draw_fields: 'Felder Zeichnen',
align_left: 'Links ausrichten',
align_right: 'Rechts ausrichten',
align_top: 'Oben ausrichten',
align_bottom: 'Unten ausrichten',
fields_selected: '{count} Felder Ausgewählt',
field_added: '{count} Feld Hinzugefügt',
fields_added: '{count} Felder Hinzugefügt'
}
const nl = {
kba: 'KBA',
autodetect_fields: 'Velden detecteren',
analyzing_: 'Analyseren...',
download: 'Downloaden',
@@ -1318,7 +1391,18 @@ const nl = {
start_tour: 'Rondleiding starten',
or_add_from: 'Of toevoegen van',
sync: 'Synchroniseren',
syncing: 'Synchroniseren...'
syncing: 'Synchroniseren...',
copy: 'Kopiëren',
paste: 'Plakken',
select_fields: 'Velden Selecteren',
draw_fields: 'Velden Tekenen',
align_left: 'Links uitlijnen',
align_right: 'Rechts uitlijnen',
align_top: 'Boven uitlijnen',
align_bottom: 'Onder uitlijnen',
fields_selected: '{count} Velden Geselecteerd',
field_added: '{count} Veld Toegevoegd',
fields_added: '{count} Velden Toegevoegd'
}
export { en, es, it, pt, fr, de, nl }
@@ -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,
+323 -8
View File
@@ -1,7 +1,7 @@
<template>
<div
class="relative select-none mb-4 before:border before:rounded before:top-0 before:bottom-0 before:left-0 before:right-0 before:absolute"
:class="{ 'cursor-crosshair': allowDraw && editable, 'touch-none': !!drawField }"
:class="{ 'cursor-crosshair': allowDraw && editable && !isSelectMode, 'touch-none': !!drawField }"
style="container-type: size"
:style="{ aspectRatio: `${width} / ${height}`}"
>
@@ -17,7 +17,20 @@
<div
class="top-0 bottom-0 left-0 right-0 absolute"
@pointerdown="onStartDraw"
@contextmenu="openContextMenu"
>
<SelectionBox
v-if="showSelectionBox"
:selection-box="selectionBox"
:page-width="width"
:page-height="height"
:is-resizing="!!resizeDirection"
:is-drawing="!!drawFieldType"
:is-drag="isDrag"
@move="onSelectionBoxMove"
@contextmenu="openSelectionContextMenu"
@close-context-menu="closeSelectionContextMenu"
/>
<FieldArea
v-for="(item, i) in areas"
:key="i"
@@ -34,10 +47,12 @@
:default-field="defaultFieldsIndex[item.field.name]"
:default-submitters="defaultSubmitters"
:max-page="totalPages - 1"
:is-select-mode="isSelectMode"
@start-resize="resizeDirection = $event"
@stop-resize="resizeDirection = null"
@remove="$emit('remove-area', item.area)"
@scroll-to="$emit('scroll-to', $event)"
@contextmenu="openAreaContextMenu($event, item.area, item.field)"
/>
<FieldArea
v-if="newArea"
@@ -47,15 +62,45 @@
:field="{ submitter_uuid: selectedSubmitter.uuid, type: drawField?.type || dragFieldPlaceholder?.type || defaultFieldType }"
:area="newArea"
/>
<div
v-if="selectionRect"
class="absolute outline-dashed outline-gray-400 pointer-events-none z-20"
:style="selectionRectStyle"
/>
<ContextMenu
v-if="contextMenu"
:context-menu="contextMenu"
:field="contextMenu.field"
:editable="editable"
:with-fields-detection="withFieldsDetection"
@copy="handleCopy"
@delete="handleDelete"
@paste="handlePaste"
@autodetect-fields="handleAutodetectFields"
@close="closeContextMenu"
/>
<ContextMenu
v-if="selectionContextMenu"
:context-menu="selectionContextMenu"
:editable="editable"
:is-multi-selection="true"
:selected-areas="selectedAreasRef.value"
:template="template"
@copy="handleSelectionCopy"
@delete="handleSelectionDelete"
@align="handleSelectionAlign"
@close="closeSelectionContextMenu"
/>
</div>
<div
v-show="resizeDirection || isDrag || showMask || (drawField && isMobile) || fieldsDragFieldRef.value"
v-show="resizeDirection || isDrag || showMask || (drawField && isMobile) || fieldsDragFieldRef.value || selectionRect"
id="mask"
ref="mask"
class="top-0 bottom-0 left-0 right-0 absolute"
:class="{ 'z-10': !isMobile, 'cursor-grab': isDrag, 'cursor-nwse-resize': drawField, [resizeDirectionClasses[resizeDirection]]: !!resizeDirectionClasses }"
:class="{ 'z-10': !isMobile, 'cursor-grab': isDrag, 'cursor-nwse-resize': drawField && !isSelectMode, [resizeDirectionClasses[resizeDirection]]: !!resizeDirectionClasses }"
@pointermove="onPointermove"
@pointerdown="onStartDraw"
@contextmenu="openContextMenu"
@dragover.prevent="onDragover"
@dragenter="onDragenter"
@dragleave="newArea = null"
@@ -67,13 +112,17 @@
<script>
import FieldArea from './area'
import ContextMenu from './context_menu'
import SelectionBox from './selection_box'
export default {
name: 'TemplatePage',
components: {
FieldArea
FieldArea,
ContextMenu,
SelectionBox
},
inject: ['fieldTypes', 'defaultDrawFieldType', 'fieldsDragFieldRef', 'assignDropAreaSize'],
inject: ['fieldTypes', 'defaultDrawFieldType', 'fieldsDragFieldRef', 'assignDropAreaSize', 'selectedAreasRef', 'template', 'isSelectModeRef'],
props: {
image: {
type: Object,
@@ -155,18 +204,69 @@ export default {
number: {
type: Number,
required: true
},
attachmentUuid: {
type: String,
required: false,
default: ''
},
withFieldsDetection: {
type: Boolean,
required: false,
default: false
}
},
emits: ['draw', 'drop-field', 'remove-area', 'scroll-to'],
emits: ['draw', 'drop-field', 'remove-area', 'copy-field', 'paste-field', 'scroll-to', 'copy-selected-areas', 'delete-selected-areas', 'align-selected-areas', 'autodetect-fields'],
data () {
return {
areaRefs: [],
showMask: false,
resizeDirection: null,
newArea: null
newArea: null,
contextMenu: null,
selectionRect: null,
selectionContextMenu: null
}
},
computed: {
isSelectMode () {
return this.isSelectModeRef.value && !this.drawFieldType && this.editable && !this.drawField
},
pageSelectedAreas () {
if (!this.selectedAreasRef.value) return []
return this.selectedAreasRef.value.filter((a) =>
a.attachment_uuid === this.attachmentUuid && a.page === this.number
)
},
showSelectionBox () {
return this.pageSelectedAreas.length >= 2 && this.editable
},
minSelectionBoxHeight () {
const ys = this.pageSelectedAreas.map((a) => a.y)
return Math.max(...ys) - Math.min(...ys)
},
minSelectionBoxWidth () {
const xs = this.pageSelectedAreas.map((a) => a.x)
return Math.max(...xs) - Math.min(...xs)
},
selectionBox () {
if (!this.pageSelectedAreas.length) return null
const minX = Math.min(...this.pageSelectedAreas.map((a) => a.x))
const minY = Math.min(...this.pageSelectedAreas.map((a) => a.y))
const maxX = Math.max(...this.pageSelectedAreas.map((a) => a.x + a.w))
const maxY = Math.max(...this.pageSelectedAreas.map((a) => a.y + a.h))
return {
x: minX,
y: minY,
w: Math.max(maxX - minX, this.minSelectionBoxWidth),
h: Math.max(maxY - minY, this.minSelectionBoxHeight)
}
},
defaultFieldsIndex () {
return this.defaultFields.reduce((acc, field) => {
acc[field.name] = field
@@ -201,6 +301,16 @@ export default {
},
height () {
return this.image.metadata.height
},
selectionRectStyle () {
if (!this.selectionRect) return {}
return {
left: this.selectionRect.x * 100 + '%',
top: this.selectionRect.y * 100 + '%',
width: this.selectionRect.w * 100 + '%',
height: this.selectionRect.h * 100 + '%'
}
}
},
beforeUpdate () {
@@ -211,6 +321,114 @@ export default {
this.image.metadata.width = e.target.naturalWidth
this.image.metadata.height = e.target.naturalHeight
},
openContextMenu (event) {
if (!this.editable) {
return
}
event.preventDefault()
event.stopPropagation()
const rect = this.$refs.image.getBoundingClientRect()
this.newArea = null
this.showMask = false
this.contextMenu = {
x: event.clientX,
y: event.clientY,
relativeX: (event.clientX - rect.left) / rect.width,
relativeY: (event.clientY - rect.top) / rect.height
}
},
openAreaContextMenu (event, area, field) {
if (!this.editable) {
return
}
event.preventDefault()
event.stopPropagation()
const rect = this.$refs.image.getBoundingClientRect()
this.newArea = null
this.showMask = false
this.contextMenu = {
x: event.clientX,
y: event.clientY,
relativeX: (event.clientX - rect.left) / rect.width,
relativeY: (event.clientY - rect.top) / rect.height,
area,
field
}
},
openSelectionContextMenu (event) {
const rect = this.$el.getBoundingClientRect()
this.selectionContextMenu = {
x: event.clientX,
y: event.clientY,
relativeX: (event.clientX - rect.left) / rect.width,
relativeY: (event.clientY - rect.top) / rect.height
}
},
closeSelectionContextMenu () {
this.selectionContextMenu = null
},
handleSelectionCopy () {
this.$emit('copy-selected-areas')
this.closeSelectionContextMenu()
},
handleSelectionDelete () {
this.$emit('delete-selected-areas')
this.closeSelectionContextMenu()
},
handleSelectionAlign (direction) {
this.$emit('align-selected-areas', direction)
this.closeSelectionContextMenu()
},
closeContextMenu () {
this.contextMenu = null
this.newArea = null
this.showMask = false
},
handleCopy () {
if (this.contextMenu.area) {
this.selectedAreasRef.value = [this.contextMenu.area]
this.$emit('copy-field')
}
this.closeContextMenu()
},
handleDelete () {
if (this.contextMenu.area) {
this.$emit('remove-area', this.contextMenu.area)
}
this.closeContextMenu()
},
handlePaste () {
this.newArea = null
this.showMask = false
this.$emit('paste-field', {
page: this.number,
x: this.contextMenu.relativeX,
y: this.contextMenu.relativeY
})
this.closeContextMenu()
},
handleAutodetectFields () {
this.$emit('autodetect-fields', {
page: this.number,
attachmentUuid: this.attachmentUuid
})
this.closeContextMenu()
},
setAreaRefs (el) {
if (el) {
this.areaRefs.push(el)
@@ -243,6 +461,20 @@ export default {
})
},
onStartDraw (e) {
if (e.button === 2) {
return
}
if (this.selectedAreasRef.value.length >= 2) {
this.selectedAreasRef.value = []
}
if (this.isSelectMode) {
this.startSelectionRect(e)
return
}
if (!this.allowDraw) {
return
}
@@ -268,7 +500,69 @@ export default {
}
})
},
startSelectionRect (e) {
this.selectedAreasRef.value = []
this.showMask = true
this.$nextTick(() => {
const x = e.offsetX / this.$refs.mask.clientWidth
const y = e.offsetY / this.$refs.mask.clientHeight
this.selectionRect = {
initialX: x,
initialY: y,
x,
y,
w: 0,
h: 0
}
})
},
onSelectionBoxMove (dx, dy) {
let clampedDx = dx
let clampedDy = dy
this.pageSelectedAreas.forEach((area) => {
const maxDxLeft = -area.x
const maxDxRight = 1 - area.w - area.x
const maxDyTop = -area.y
const maxDyBottom = 1 - area.h - area.y
if (dx < maxDxLeft) clampedDx = Math.max(clampedDx, maxDxLeft)
if (dx > maxDxRight) clampedDx = Math.min(clampedDx, maxDxRight)
if (dy < maxDyTop) clampedDy = Math.max(clampedDy, maxDyTop)
if (dy > maxDyBottom) clampedDy = Math.min(clampedDy, maxDyBottom)
})
this.pageSelectedAreas.forEach((area) => {
area.x += clampedDx
area.y += clampedDy
})
},
onPointermove (e) {
if (this.selectionRect) {
const dx = e.offsetX / this.$refs.mask.clientWidth - this.selectionRect.initialX
const dy = e.offsetY / this.$refs.mask.clientHeight - this.selectionRect.initialY
if (dx > 0) {
this.selectionRect.x = this.selectionRect.initialX
} else {
this.selectionRect.x = e.offsetX / this.$refs.mask.clientWidth
}
if (dy > 0) {
this.selectionRect.y = this.selectionRect.initialY
} else {
this.selectionRect.y = e.offsetY / this.$refs.mask.clientHeight
}
this.selectionRect.w = Math.abs(dx)
this.selectionRect.h = Math.abs(dy)
return
}
if (this.newArea) {
const dx = e.offsetX / this.$refs.mask.clientWidth - this.newArea.initialX
const dy = e.offsetY / this.$refs.mask.clientHeight - this.newArea.initialY
@@ -294,7 +588,20 @@ export default {
}
},
onPointerup (e) {
if (this.newArea) {
if (this.selectionRect) {
const selRect = this.selectionRect
const areasToSelect = this.areas || []
areasToSelect.forEach((item) => {
const area = item.area
if (this.rectsOverlap(selRect, area)) {
this.selectedAreasRef.value.push(area)
}
})
this.selectionRect = null
} else if (this.newArea) {
const area = {
x: this.newArea.x,
y: this.newArea.y,
@@ -317,6 +624,14 @@ export default {
this.showMask = false
this.newArea = null
},
rectsOverlap (r1, r2) {
return !(
r1.x + r1.w < r2.x ||
r2.x + r2.w < r1.x ||
r1.y + r1.h < r2.y ||
r2.y + r2.h < r1.y
)
}
}
}
@@ -0,0 +1,100 @@
<template>
<div
class="absolute outline-dashed outline-gray-400 cursor-move"
:class="[isResizing || isCmdKeyRef.value ? 'z-0' : 'z-20', { 'pointer-events-none': isDrawing || fieldsDragFieldRef.value || isDrag }]"
:style="positionStyle"
@pointerdown.stop.prevent="onPointerDown"
@contextmenu.stop.prevent="openContextMenu"
/>
</template>
<script>
export default {
name: 'SelectionBox',
inject: ['save', 'selectedAreasRef', 'isCmdKeyRef', 'fieldsDragFieldRef'],
props: {
selectionBox: {
type: Object,
required: true
},
pageWidth: {
type: Number,
required: true
},
pageHeight: {
type: Number,
required: true
},
isResizing: {
type: Boolean,
default: false
},
isDrag: {
type: Boolean,
default: false
},
isDrawing: {
type: Boolean,
default: false
}
},
emits: ['move', 'contextmenu', 'close-context-menu'],
data () {
return {
isDragging: false,
dragStart: { x: 0, y: 0 }
}
},
computed: {
positionStyle () {
const { x, y, w, h } = this.selectionBox
return {
top: y * 100 + '%',
left: x * 100 + '%',
width: w * 100 + '%',
height: h * 100 + '%'
}
}
},
methods: {
openContextMenu (e) {
this.$emit('contextmenu', e)
},
onPointerDown (e) {
this.$emit('close-context-menu')
this.startDrag(e)
},
startDrag (e) {
this.isDragging = true
this.dragStart = { x: e.clientX, y: e.clientY }
document.addEventListener('pointermove', this.onDrag)
document.addEventListener('pointerup', this.stopDrag)
},
onDrag (e) {
if (!this.isDragging) return
const parent = this.$el.parentElement
const rect = parent.getBoundingClientRect()
const dx = (e.clientX - this.dragStart.x) / rect.width
const dy = (e.clientY - this.dragStart.y) / rect.height
this.$emit('move', dx, dy)
this.dragStart = { x: e.clientX, y: e.clientY }
},
stopDrag () {
this.isDragging = false
document.removeEventListener('pointermove', this.onDrag)
document.removeEventListener('pointerup', this.stopDrag)
this.save()
}
}
}
</script>
+4 -1
View File
@@ -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)
+3 -1
View File
@@ -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?
+10 -3
View File
@@ -39,11 +39,18 @@ 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,
@@ -51,7 +58,7 @@ class ProcessSubmitterCompletionJob
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
@@ -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
+21 -4
View File
@@ -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,
{
+11
View File
@@ -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)
+2
View File
@@ -45,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'
+5 -2
View File
@@ -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
@@ -76,8 +78,9 @@ class Submission < ApplicationRecord
scope :active, -> { where(archived_at: nil) }
scope :archived, -> { where.not(archived_at: nil) }
scope :pending, lambda {
where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
.and(Submitter.arel_table[:completed_at].eq(nil))).select(1).arel.exists)
where(expire_at: nil).or(where(expire_at: Time.current..))
.where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
.and(Submitter.arel_table[:completed_at].eq(nil))).select(1).arel.exists)
}
scope :completed, lambda {
where.not(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
+4
View File
@@ -48,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',
@@ -56,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',
+5 -1
View File
@@ -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
+1 -1
View File
@@ -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) %>
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" 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

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" 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

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" 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

+3
View File
@@ -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

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" 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

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" 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

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" 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

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" 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

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" 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

+8
View File
@@ -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

+3
View File
@@ -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

+1 -1
View File
@@ -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 %>
+5
View File
@@ -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 -1
View File
@@ -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>
+2 -2
View File
@@ -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">
+1 -1
View File
@@ -135,7 +135,7 @@
<a href="mailto:<%= Docuseal::SUPPORT_EMAIL %>" target="_blank" class="w-full block mt-4 underline text-center">
<%= Docuseal::SUPPORT_EMAIL %>
</a>
<% if Docuseal.version.present? %>
<% if Docuseal.version.present? && !Docuseal.multitenant? && can?(:manage, EncryptedConfig) %>
<a href="https://github.com/docusealco/docuseal/releases" target="_blank" class="badge badge-outline text-xs block mx-auto mt-4">
v<%= Docuseal.version %>
</a>
+17 -9
View File
@@ -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 && index.zero? %>
</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 && index.zero? %>
</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>
@@ -92,6 +99,7 @@
<% if has_phone_field %>
<%= render 'send_sms', f: %>
<% end %>
<%= render 'extra_fields', f: %>
</div>
<div class="form-control">
<%= f.button button_title(title: t('add_recipients')), class: 'base-button' %>
+2 -1
View File
@@ -47,8 +47,9 @@
</dynamic-list>
<% end %>
<div>
<%= render 'send_email', f:, template: %>
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
<%= render 'send_email', f:, template: %>
<%= render 'extra_fields', f: %>
</div>
<div class="form-control">
<%= content_for(:submit_button) || capture do %>
+2 -1
View File
@@ -58,7 +58,8 @@
</dynamic-list>
<div>
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
<%= render 'send_sms', f: %>
<%= render 'send_sms', f:, checked: true %>
<%= render 'extra_phone_fields', f: %>
</div>
<div class="form-control">
<%= f.button button_title(title: t('add_recipients')), class: 'base-button' %>
+9 -8
View File
@@ -7,7 +7,7 @@
<% font_size_px = (field.dig('preferences', 'font_size').presence || Submissions::GenerateResultAttachments::FONT_SIZE).to_i * local_assigns.fetch(:font_scale) { 1000.0 / PdfUtils::US_LETTER_W } %>
<field-value dir="auto" class="flex absolute <%= 'font-courier' if font == 'Courier' %> <%= 'font-times' if font == 'Times' %> <%= 'font-bold' if font_type == 'bold' || font_type == 'bold_italic' %> <%= 'italic' if font_type == 'italic' || font_type == 'bold_italic' %> <%= align == 'right' ? 'text-right' : (align == 'center' ? 'text-center' : '') %>" style="<%= "color: #{color}; " if color.present? %><%= "background: #{bg_color}; " if bg_color.present? %>width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%; font-size: <%= fs = "clamp(1pt, #{font_size_px / 10}vw, #{font_size_px}px)" %>; line-height: calc(<%= fs %> * 1.3); font-size: <%= fs = "#{font_size_px / 10}cqmin" %>; line-height: calc(<%= fs %> * 1.3)">
<% if field['type'] == 'signature' %>
<% is_narrow = area['h']&.positive? && (area['w'].to_f / area['h']) > 6 %>
<% is_narrow = area['h'].positive? && ((area['w'] * local_assigns[:page_width]).to_f / (area['h'] * local_assigns[:page_height])) > 4.5 %>
<div class="flex justify-between w-full h-full gap-1 <%= is_narrow && (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) ? 'flex-row' : 'flex-col' %>">
<div class="flex overflow-hidden <%= is_narrow && (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) ? 'w-1/2' : 'flex-grow' %>" style="min-height: 50%">
<img class="object-contain mx-auto" src="<%= attachments_index[value].url %>">
@@ -33,7 +33,7 @@
</div>
<% end %>
</div>
<% elsif field['type'].in?(['image', 'initials', 'stamp']) && attachments_index[value].image? %>
<% elsif field['type'].in?(['image', 'initials', 'stamp', 'kba']) && attachments_index[value].image? %>
<img class="object-contain mx-auto" src="<%= attachments_index[value].url %>" loading="lazy">
<% elsif field['type'].in?(['file', 'payment', 'image']) %>
<autosize-field></autosize-field>
@@ -50,12 +50,13 @@
<%= svg_icon('check', class: "aspect-square #{area['w'] > area['h'] ? '!w-auto !h-full' : '!w-full !h-auto'}") %>
</div>
<% elsif field['type'].in?(%w[multiple radio]) && area['option_uuid'] %>
<% option = field['options']&.find { |o| o['uuid'] == area['option_uuid'] } %>
<% option_name = option['value'].presence || "Option #{field['options'].index(option) + 1}" %>
<% if option && Array.wrap(value).include?(option_name) %>
<div class="w-full flex items-center justify-center">
<%= svg_icon('check', class: "aspect-square #{area['w'] > area['h'] ? '!w-auto !h-full' : '!w-full !h-auto'}") %>
</div>
<% if (option = field['options']&.find { |o| o['uuid'] == area['option_uuid'] }) %>
<% option_name = option['value'].presence || "Option #{field['options'].index(option) + 1}" %>
<% if Array.wrap(value).include?(option_name) %>
<div class="w-full flex items-center justify-center">
<%= svg_icon('check', class: "aspect-square #{area['w'] > area['h'] ? '!w-auto !h-full' : '!w-full !h-auto'}") %>
</div>
<% end %>
<% end %>
<% elsif field['type'] == 'cells' && area['cell_w'].to_f > 0.0 %>
<% cell_width = area['cell_w'] / area['w'] * 100 %>
+4 -2
View File
@@ -1,6 +1,8 @@
<% require_phone_2fa = @template.preferences['require_phone_2fa'] == true %>
<% require_email_2fa = @template.preferences['require_email_2fa'] == true %>
<% prefillable_fields = @template.fields.select { |f| f['prefillable'] } %>
<% only_detailed = require_phone_2fa || prefillable_fields.present? %>
<% recipient_form_fields = Accounts.load_recipient_form_fields(current_account) if prefillable_fields.blank? %>
<% only_detailed = require_phone_2fa || require_email_2fa || prefillable_fields.present? || recipient_form_fields.present? %>
<%= render 'shared/turbo_modal_large', title: params[:selfsign] ? t('add_recipients') : t('add_new_recipients') do %>
<% options = [only_detailed ? nil : [t('via_email'), 'email'], only_detailed ? nil : [t('via_phone'), 'phone'], [t('detailed'), 'detailed'], [t('upload_list'), 'list']].compact %>
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="relative text-center px-2 mt-4 block">
@@ -25,7 +27,7 @@
</div>
<% end %>
<div id="detailed" class="<%= 'hidden' unless only_detailed %>">
<%= render 'detailed_form', template: @template, require_phone_2fa:, prefillable_fields: %>
<%= render 'detailed_form', template: @template, require_phone_2fa:, require_email_2fa:, prefillable_fields:, recipient_form_fields: %>
</div>
<div id="list" class="hidden">
<%= render 'list_form', template: @template %>
+25 -13
View File
@@ -4,7 +4,7 @@
<% font_scale = 1040.0 / PdfUtils::US_LETTER_W %>
<% configs = AccountConfig.where(account_id: @submission.account_id, key: [AccountConfig::COMBINE_PDF_RESULT_KEY, AccountConfig::WITH_SIGNATURE_ID, AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY, AccountConfig::WITH_SIGNATURE_ID_REASON_KEY]) %>
<% with_signature_id = configs.find { |e| e.key == AccountConfig::WITH_SIGNATURE_ID }&.value == true %>
<% is_combined_enabled = configs.find { |e| e.key == AccountConfig::COMBINE_PDF_RESULT_KEY }&.value == true %>
<% is_combined_enabled = configs.find { |e| e.key == AccountConfig::COMBINE_PDF_RESULT_KEY }&.value == true && !@submission.template_fields&.any? { |f| f['type'] == 'verification' } %>
<% with_submitter_timezone = configs.find { |e| e.key == AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY }&.value == true %>
<% with_signature_id_reason = configs.find { |e| e.key == AccountConfig::WITH_SIGNATURE_ID_REASON_KEY }&.value != false %>
<div style="max-width: 1600px" class="mx-auto pl-4">
@@ -93,8 +93,10 @@
<div class="pr-3.5 pl-0.5">
<% fields_index = Templates.build_field_areas_index(@submission.template_fields || @submission.template.fields) %>
<% submitters_index = @submission.submitters.index_by(&:uuid) %>
<% submitters_order_index = nil %>
<% attachments_index = ActiveStorage::Attachment.where(record: @submission.submitters, name: :attachments).preload(:blob).index_by(&:uuid) %>
<% page_blob_struct = Struct.new(:url, :metadata, keyword_init: true) %>
<% bg_classes = %w[bg-red-100 bg-sky-100 bg-emerald-100 bg-yellow-100 bg-purple-100 bg-pink-100 bg-cyan-100 bg-orange-100 bg-lime-100 bg-indigo-100] %>
<% schema.each do |item| %>
<% document = @submission.schema_documents.find { |e| e.uuid == item['attachment_uuid'] } %>
<% document_annots_index = document.metadata.dig('pdf', 'annotations')&.group_by { |e| e['page'] } || {} %>
@@ -111,19 +113,29 @@
<% fields_index.dig(document.uuid, index)&.each do |(area, field)| %>
<% value = values[field['uuid']].presence || (field['default_value'] != '{{date}}' && field['readonly'] == true && field['conditions'].blank? && field['default_value'].present? ? Submitters::SubmitValues.template_default_value_for_submitter(field['default_value'], @submission.submitters.find { |e| e.uuid == field['submitter_uuid'] }, with_time: false) : nil) %>
<% value ||= field['default_value'] if field['type'] == 'heading' %>
<% next if value.blank? %>
<% submitter = submitters_index[field['submitter_uuid']] %>
<% if (mask = field.dig('preferences', 'mask').presence) && signed_in? && can?(:read, @submission) %>
<span class="group">
<span class="hidden group-hover:inline">
<%= render 'submissions/value', font_scale:, area:, field:, attachments_index:, value:, locale: @submission.account.locale, timezone: @submission.account.timezone, submitter:, with_signature_id: %>
<% if value.present? %>
<% if (mask = field.dig('preferences', 'mask').presence) && signed_in? && can?(:read, @submission) %>
<span class="group">
<span class="hidden group-hover:inline">
<%= render 'submissions/value', font_scale:, area:, field:, attachments_index:, value:, locale: @submission.account.locale, timezone: @submission.account.timezone, submitter:, with_signature_id: %>
</span>
<span class="group-hover:hidden">
<%= render 'submissions/value', font_scale:, area:, field:, attachments_index:, value: Array.wrap(value).map { |e| TextUtils.mask_value(e, mask) }.join(', '), locale: @submission.account.locale, timezone: @submission.account.timezone, submitter:, with_signature_id: %>
</span>
</span>
<span class="group-hover:hidden">
<%= render 'submissions/value', font_scale:, area:, field:, attachments_index:, value: Array.wrap(value).map { |e| TextUtils.mask_value(e, mask) }.join(', '), locale: @submission.account.locale, timezone: @submission.account.timezone, submitter:, with_signature_id: %>
</span>
</span>
<% else %>
<%= render 'submissions/value', page_width: width, page_height: height, font_scale:, area:, field:, attachments_index:, value: mask.present? ? Array.wrap(value).map { |e| TextUtils.mask_value(e, mask) }.join(', ') : value, locale: @submission.account.locale, timezone: @submission.account.timezone, submitter:, with_signature_id:, with_submitter_timezone:, with_signature_id_reason: %>
<% else %>
<%= render 'submissions/value', page_width: width, page_height: height, font_scale:, area:, field:, attachments_index:, value: mask.present? ? Array.wrap(value).map { |e| TextUtils.mask_value(e, mask) }.join(', ') : value, locale: @submission.account.locale, timezone: @submission.account.timezone, submitter:, with_signature_id:, with_submitter_timezone:, with_signature_id_reason: %>
<% end %>
<% elsif field['readonly'] != true && submitter && !submitter.completed_at? %>
<% submitters_order_index ||= (@submission.template_submitters || @submission.template.submitters).each_with_index.to_h { |s, i| [s['uuid'], i] } %>
<% submitter_index = submitters_order_index[submitter.uuid] %>
<% bg_class = bg_classes[submitter_index % bg_classes.size] %>
<div class="absolute overflow-visible" style="width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%;">
<div class="flex h-full w-full bg-opacity-80 justify-center items-center <%= bg_class %>">
<%= svg_icon(SubmissionsController::FIELD_ICONS.fetch(field['type'], 'text_size'), class: 'max-h-10 w-full h-full stroke-2 opacity-50') %>
</div>
</div>
<% end %>
<% end %>
</div>
@@ -250,7 +262,7 @@
<div class="w-full bg-base-300 py-1">
<img class="object-contain mx-auto" style="max-height: <%= field['type'] == 'signature' ? 100 : 50 %>px" height="<%= attachments_index[value].metadata['height'] %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy">
</div>
<% elsif field['type'].in?(['image', 'stamp']) && attachments_index[value].image? %>
<% elsif field['type'].in?(['image', 'stamp', 'kba']) && attachments_index[value].image? %>
<img class="object-contain mx-auto max-h-28" style="max-height: 200px" height="<%= attachments_index[value].metadata['height'] %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy">
<% elsif field['type'].in?(['file', 'payment', 'image']) %>
<div class="flex flex-col justify-center">
@@ -2,4 +2,4 @@
<% data_fields = Submissions.filtered_conditions_fields(submitter).to_json %>
<% invite_submitters = (submitter.submission.template_submitters || submitter.submission.template.submitters).select { |s| s['invite_by_uuid'] == submitter.uuid && submitter.submission.submitters.none? { |e| e.uuid == s['uuid'] } }.to_json %>
<% optional_invite_submitters = (submitter.submission.template_submitters || submitter.submission.template.submitters).select { |s| s['optional_invite_by_uuid'] == submitter.uuid && submitter.submission.submitters.none? { |e| e.uuid == s['uuid'] } }.to_json %>
<submission-form data-is-demo="<%= Docuseal.demo? %>" data-schema="<%= schema.to_json %>" data-reuse-signature="<%= configs[:reuse_signature] %>" data-require-signing-reason="<%= configs[:require_signing_reason] %>" data-with-signature-id="<%= configs[:with_signature_id] %>" data-with-confetti="<%= configs[:with_confetti] %>" data-completed-redirect-url="<%= submitter.preferences['completed_redirect_url'].presence || submitter.submission.template&.preferences&.dig('completed_redirect_url') %>" data-completed-message="<%= (configs[:completed_message]&.compact_blank.presence || submitter.submission.template&.preferences&.dig('completed_message') || {}).to_json %>" data-completed-button="<%= configs[:completed_button].to_json %>" data-go-to-last="<%= submitter.preferences.key?('go_to_last') ? submitter.preferences['go_to_last'] : submitter.opened_at? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(submitter.submission.account) %>" data-optional-invite-submitters="<%= optional_invite_submitters %>" data-invite-submitters="<%= invite_submitters %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-values="<%= submitter.values.to_json %>" data-with-typed-signature="<%= configs[:with_typed_signature] %>" data-previous-signature-value="<%= local_assigns[:signature_attachment]&.uuid %>" data-remember-signature="<%= configs[:prefill_signature] %>" data-dry-run="<%= local_assigns[:dry_run] %>" data-expand="<%= local_assigns[:expand] %>" data-scroll-padding="<%= local_assigns[:scroll_padding] %>" data-language="<%= I18n.locale.to_s.split('-').first %>"></submission-form>
<submission-form data-is-demo="<%= Docuseal.demo? %>" data-schema="<%= schema.to_json %>" data-reuse-signature="<%= configs[:reuse_signature] %>" data-require-signing-reason="<%= configs[:require_signing_reason] %>" data-with-signature-id="<%= configs[:with_signature_id] %>" data-with-field-labels="<%= configs[:with_field_labels] %>" data-with-confetti="<%= configs[:with_confetti] %>" data-completed-redirect-url="<%= submitter.preferences['completed_redirect_url'].presence || submitter.submission.template&.preferences&.dig('completed_redirect_url') %>" data-completed-message="<%= (configs[:completed_message]&.compact_blank.presence || submitter.submission.template&.preferences&.dig('completed_message') || {}).to_json %>" data-completed-button="<%= configs[:completed_button].to_json %>" data-go-to-last="<%= submitter.preferences.key?('go_to_last') ? submitter.preferences['go_to_last'] : submitter.opened_at? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(submitter.submission.account) %>" data-optional-invite-submitters="<%= optional_invite_submitters %>" data-invite-submitters="<%= invite_submitters %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-values="<%= submitter.values.to_json %>" data-with-typed-signature="<%= configs[:with_typed_signature] %>" data-previous-signature-value="<%= local_assigns[:signature_attachment]&.uuid %>" data-remember-signature="<%= configs[:prefill_signature] %>" data-dry-run="<%= local_assigns[:dry_run] %>" data-expand="<%= local_assigns[:expand] %>" data-scroll-padding="<%= local_assigns[:scroll_padding] %>" data-language="<%= I18n.locale.to_s.split('-').first %>"></submission-form>
+72
View File
@@ -0,0 +1,72 @@
<% content_for(:html_title, "#{@submitter.submission.name || @submitter.submission.template.name} | DocuSeal") %>
<% I18n.with_locale(@submitter.account.locale) do %>
<% content_for(:html_description, t('account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution', account_name: @submitter.account.name)) %>
<% end %>
<div class="max-w-md mx-auto px-2 mt-12 mb-4">
<div class="space-y-6 mx-auto">
<div class="space-y-6">
<div class="flex items-center justify-center">
<%= render 'start_form/banner' %>
</div>
<div class="flex items-center bg-base-200 rounded-xl p-4 mb-4">
<div class="flex items-center">
<div class="mr-3">
<%= svg_icon('writing_sign', class: 'w-10 h-10') %>
</div>
<div>
<p dir="auto" class="text-lg font-bold mb-1"><%= @submitter.submission.name || @submitter.submission.template.name %></p>
<% last_submitter = @submitter.submission.submitters.completed.order(:completed_at).last %>
<% if last_submitter %>
<p dir="auto" class="text-sm">
<%= t(last_submitter.with_signature_fields? ? 'signed_on_time' : 'completed_on_time', time: l(last_submitter.completed_at.to_date, format: :long)) %>
</p>
<% end %>
</div>
</div>
</div>
</div>
<% if @submitter.email.present? %>
<div>
<%= t('the_sender_has_requested_a_two_factor_authentication_via_one_time_password_sent_to_your_email_html', email: TextUtils.mask_email(@submitter.email, 2)) %>
</div>
<% if params[:status] == 'sent' || params[:status] == 'error' %>
<%= form_for '', url: submit_form_email_2fa_path, method: :post, html: { class: 'space-y-4', id: 'code_form' } do |f| %>
<div dir="auto" class="form-control !mt-0">
<%= f.hidden_field :submitter_slug, value: @submitter.slug %>
<%= f.text_field :one_time_code, required: true, class: 'base-input text-center', placeholder: 'XXX-XXX' %>
<div class="flex justify-between items-center mt-1">
<span>
<% if flash[:alert] %>
<span class="text-red-500">
<%= flash[:alert] %>
</span>
<% end %>
<% if flash[:notice] %>
<%= flash[:notice] %>
<% end %>
</span>
<span>
<label for="resend_code" id="resend_label" class="link"><%= t(:re_send_email) %></label>
</span>
</div>
</div>
<toggle-submit dir="auto" class="form-control">
<%= f.button button_title(title: t('submit')), class: 'base-button' %>
</toggle-submit>
<% end %>
<%= button_to t(:re_send_email), submit_form_email_2fa_path, params: { submitter_slug: @submitter.slug, resend: true }, method: :put, id: 'resend_code', class: 'hidden' %>
<% else %>
<% if params[:t] %>
<fetch-form data-onload="true">
<%= button_to '', api_submitter_email_clicks_path, params: { submitter_slug: @submitter.slug, t: params[:t] }, method: :post, class: 'hidden' %>
</fetch-form>
<% end %>
<toggle-submit dir="auto" class="form-control">
<%= button_to button_title(title: t('send_verification_code')), submit_form_email_2fa_path, params: { submitter_slug: @submitter.slug }, method: :put, class: 'base-button w-full' %>
</toggle-submit>
<% end %>
<% else %>
<div><%= t('please_contact_the_requester_to_specify_your_email_for_two_factor_authentication') %></div>
<% end %>
</div>
</div>

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