Compare commits

...
43 Commits
Author SHA1 Message Date
Alex Turchyn c88715daec Merge from docusealco/wip 2025-06-03 12:22:34 +03:00
Pete Matsyburka 0375dad7c9 smaller audit initial 2025-06-03 10:13:49 +03:00
Pete Matsyburka de3f83db96 use metadata lang 2025-06-03 09:52:46 +03:00
Pete Matsyburka a7bb96464c completed action shared link 2025-06-01 22:42:44 +03:00
Pete Matsyburka 1d48fe9621 fix spec 2025-06-01 15:51:43 +03:00
Pete Matsyburka 9544bb3d76 Revert "shared link true default"
This reverts commit e41fe5fbca.
2025-06-01 15:23:20 +03:00
Pete Matsyburka 4e94664008 refactor start form 2025-06-01 15:23:19 +03:00
Pete Matsyburka e41fe5fbca shared link true default 2025-06-01 13:21:11 +03:00
Pete Matsyburka 24d713dec6 resubmit shared template 2025-06-01 12:27:36 +03:00
Pete Matsyburka f464e1c46a add log 2025-06-01 12:22:16 +03:00
Pete Matsyburka 85d52d1f28 refactor policy 2025-06-01 11:38:08 +03:00
Alex Turchyn e77b5fa2c9 add optional shared link to templates 2025-06-01 11:07:52 +03:00
Alex Turchyn c91b4a765b add Privacy Policy and eSignature Disclosure links 2025-06-01 09:20:42 +03:00
Pete Matsyburka 2a031c14c8 fix js error 2025-05-30 16:58:20 +03:00
Pete Matsyburka f7dfc0b2ed add email event date index 2025-05-30 11:12:33 +03:00
Pete Matsyburka 3b894f5281 fix 2025-05-30 10:36:08 +03:00
Alex Turchyn 9645332e48 add countless pagination 2025-05-30 09:12:42 +03:00
Pete Matsyburka 5670390b37 fix signature reason 2025-05-29 17:31:09 +03:00
Pete Matsyburka 260bd6d5d5 fix signature field size 2025-05-29 17:24:03 +03:00
Alex Turchyn fa0733f480 improve user email validation 2025-05-29 14:24:17 +03:00
Pete Matsyburka 8b9678a434 fix heif 2025-05-28 23:01:34 +03:00
Pete Matsyburka 43669bf095 do not prefill optional signature 2025-05-28 21:51:17 +03:00
Pete Matsyburka 2ef8df3f0c optimize query 2025-05-28 10:23:36 +03:00
Alex Turchyn 1c9d15aa7e add translations for pagination 2025-05-28 10:06:06 +03:00
Alex Turchyn 9fcd8955d3 add 'field-area-active-label' theme class 2025-05-27 21:10:58 +03:00
Pete Matsyburka 5e44ba49b8 refactor 2025-05-26 22:26:14 +03:00
Pete Matsyburka ac1f90e711 do not email smtp settings 2025-05-26 14:46:33 +03:00
Alex Turchyn c6eae66127 Merge from docusealco/wip 2025-05-26 13:26:19 +03:00
Pete Matsyburka 08aa902e07 set from user id 2025-05-26 11:58:47 +03:00
Pete Matsyburka 1690337699 fix pdf image result 2025-05-26 10:11:01 +03:00
Pete Matsyburka f78cf8cc6e pdfium 2025-05-26 10:01:27 +03:00
Pete Matsyburka 343118d00d fix bmp 2025-05-24 11:54:18 +03:00
Alex Turchyn a3bd37c9a2 Improve error message when does not match in completed form 2025-05-24 11:25:49 +03:00
Alex Turchyn 8bc13a5900 fix german translations 2025-05-23 10:52:47 +03:00
Pete Matsyburka 21138c1d02 remove log 2025-05-22 15:41:19 +03:00
Alex Turchyn 38674a9fc9 fix accidental checkbox addition 2025-05-21 22:18:25 +03:00
Pete Matsyburka a7ed0b86a4 dot rspec 2025-05-21 10:20:43 +03:00
Pete Matsyburka 91a96f06c8 remove account config action 2025-05-20 22:50:43 +03:00
Alex Turchyn 23631b75f2 add template_accesses factory 2025-05-20 19:36:45 +03:00
Pete Matsyburka 0fb9c2bbf6 fix gmail normalize 2025-05-20 19:32:23 +03:00
Pete Matsyburka 14fa8fc8e7 fix expired 2025-05-20 11:18:08 +03:00
Pete Matsyburka 047c192988 fix typo 2025-05-20 09:49:17 +03:00
Pete Matsyburka bfa244c8fa add ico and bmp support 2025-05-20 09:49:16 +03:00
106 changed files with 1628 additions and 234 deletions

No files matched your search

+4 -1
View File
@@ -157,7 +157,10 @@ jobs:
bundle install --jobs 4 --retry 4 bundle install --jobs 4 --retry 4
yarn install yarn install
sudo apt-get update sudo apt-get update
sudo apt-get install libvips sudo apt-get install -y libvips
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"
sudo tar -xzf pdfium-linux.tgz --strip-components=1 -C /usr/lib lib/libpdfium.so
rm -f pdfium-linux.tgz
- name: Run - name: Run
env: env:
RAILS_ENV: test RAILS_ENV: test
+1
View File
@@ -0,0 +1 @@
--require rails_helper
+16 -5
View File
@@ -1,8 +1,17 @@
FROM ruby:3.4.2-alpine AS fonts FROM ruby:3.4.2-alpine AS download
WORKDIR /fonts WORKDIR /fonts
RUN apk --no-cache add fontforge wget && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && 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 RUN apk --no-cache add fontforge wget && \
wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && \
wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && \
wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && \
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 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
RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.ttf")' RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.ttf")'
@@ -41,7 +50,7 @@ ENV OPENSSL_CONF=/app/openssl_legacy.cnf
WORKDIR /app WORKDIR /app
RUN echo '@edge https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils redis libheif@edge vips-heif gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf RUN echo '@edge https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev@edge redis libheif@edge vips-heif@edge gcompat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
RUN echo $'.include = /etc/ssl/openssl.cnf\n\ RUN echo $'.include = /etc/ssl/openssl.cnf\n\
\n\ \n\
@@ -70,8 +79,10 @@ COPY ./tmp ./tmp
COPY LICENSE README.md Rakefile config.ru .version ./ COPY LICENSE README.md Rakefile config.ru .version ./
COPY .version ./public/version COPY .version ./public/version
COPY --from=fonts /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts COPY --from=download /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
COPY --from=fonts /fonts/FreeSans.ttf /usr/share/fonts/freefont 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=webpack /app/public/packs ./public/packs COPY --from=webpack /app/public/packs ./public/packs
RUN ln -s /fonts /app/public/fonts RUN ln -s /fonts /app/public/fonts
+12 -2
View File
@@ -1,8 +1,10 @@
# frozen_string_literal: true # frozen_string_literal: true
class AccountConfigsController < ApplicationController class AccountConfigsController < ApplicationController
before_action :load_account_config before_action :load_account_config, only: :create
authorize_resource :account_config authorize_resource :account_config, only: :create
load_and_authorize_resource :account_config, only: :destroy
ALLOWED_KEYS = [ ALLOWED_KEYS = [
AccountConfig::ALLOW_TYPED_SIGNATURE, AccountConfig::ALLOW_TYPED_SIGNATURE,
@@ -30,6 +32,14 @@ class AccountConfigsController < ApplicationController
head :ok head :ok
end end
def destroy
raise InvalidKey unless ALLOWED_KEYS.include?(@account_config.key)
@account_config.destroy!
redirect_back(fallback_location: root_path)
end
private private
def load_account_config def load_account_config
@@ -100,6 +100,7 @@ module Api
permitted_params = [ permitted_params = [
:name, :name,
:external_id, :external_id,
:shared_link,
{ {
submitters: [%i[name uuid is_requester invite_by_uuid optional_invite_by_uuid linked_to_uuid email]], submitters: [%i[name uuid is_requester invite_by_uuid optional_invite_by_uuid linked_to_uuid email]],
fields: [[:uuid, :submitter_uuid, :name, :type, fields: [[:uuid, :submitter_uuid, :name, :type,
@@ -55,6 +55,14 @@ class ApplicationController < ActionController::Base
request.session[:impersonated_user_id] = user.uuid request.session[:impersonated_user_id] = user.uuid
end end
def pagy_auto(collection, **keyword_args)
if current_ability.can?(:manage, :countless)
pagy_countless(collection, **keyword_args)
else
pagy(collection, **keyword_args)
end
end
private private
def with_locale(&) def with_locale(&)
@@ -9,7 +9,9 @@ class EmailSmtpSettingsController < ApplicationController
def create def create
if @encrypted_config.update(email_configs) if @encrypted_config.update(email_configs)
unless Docuseal.multitenant?
SettingsMailer.smtp_successful_setup(@encrypted_config.value['from_email'] || current_user.email).deliver_now! SettingsMailer.smtp_successful_setup(@encrypted_config.value['from_email'] || current_user.email).deliver_now!
end
redirect_to settings_email_index_path, notice: I18n.t('changes_have_been_saved') redirect_to settings_email_index_path, notice: I18n.t('changes_have_been_saved')
else else
@@ -10,15 +10,16 @@ class SendSubmissionEmailController < ApplicationController
SEND_DURATION = 30.minutes SEND_DURATION = 30.minutes
def create def create
@submitter =
if params[:template_slug] if params[:template_slug]
Submitter.joins(submission: :template).find_by!(email: params[:email].to_s.downcase, @submitter = Submitter.completed.joins(submission: :template).find_by!(email: params[:email].to_s.downcase,
template: { slug: params[:template_slug] }) template: { slug: params[:template_slug] })
elsif params[:submission_slug] elsif params[:submission_slug]
Submitter.joins(:submission).find_by!(email: params[:email].to_s.downcase, @submitter = Submitter.completed.joins(:submission).find_by(email: params[:email].to_s.downcase,
submission: { slug: params[:submission_slug] }) submission: { slug: params[:submission_slug] })
return redirect_to submissions_preview_completed_path(params[:submission_slug], status: :error) unless @submitter
else else
Submitter.find_by!(slug: params[:submitter_slug]) @submitter = Submitter.completed.find_by!(slug: params[:submitter_slug])
end end
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes) RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
+47 -15
View File
@@ -8,20 +8,28 @@ class StartFormController < ApplicationController
around_action :with_browser_locale, only: %i[show completed] around_action :with_browser_locale, only: %i[show completed]
before_action :maybe_redirect_com, only: %i[show completed] before_action :maybe_redirect_com, only: %i[show completed]
before_action :load_resubmit_submitter, only: :update
before_action :load_template before_action :load_template
before_action :authorize_start!, only: :update
def show def show
raise ActionController::RoutingError, I18n.t('not_found') if @template.preferences['require_phone_2fa'] == true raise ActionController::RoutingError, I18n.t('not_found') if @template.preferences['require_phone_2fa']
if @template.shared_link?
@submitter = @template.submissions.new(account_id: @template.account_id) @submitter = @template.submissions.new(account_id: @template.account_id)
.submitters.new(account_id: @template.account_id, .submitters.new(account_id: @template.account_id,
uuid: (filter_undefined_submitters(@template).first || uuid: (filter_undefined_submitters(@template).first ||
@template.submitters.first)['uuid']) @template.submitters.first)['uuid'])
else
Rollbar.warning("Not shared template: #{@template.id}") if defined?(Rollbar)
return render :private if current_user && current_ability.can?(:read, @template)
raise ActionController::RoutingError, I18n.t('not_found')
end
end end
def update def update
return redirect_to start_form_path(@template.slug) if @template.archived_at?
@submitter = find_or_initialize_submitter(@template, submitter_params) @submitter = find_or_initialize_submitter(@template, submitter_params)
if @submitter.completed_at? if @submitter.completed_at?
@@ -59,6 +67,8 @@ class StartFormController < ApplicationController
end end
def completed def completed
return redirect_to start_form_path(@template.slug) if !@template.shared_link? || @template.archived_at?
@submitter = Submitter.where(submission: @template.submissions) @submitter = Submitter.where(submission: @template.submissions)
.where.not(completed_at: nil) .where.not(completed_at: nil)
.find_by!(email: params[:email]) .find_by!(email: params[:email])
@@ -66,6 +76,24 @@ class StartFormController < ApplicationController
private private
def load_resubmit_submitter
@resubmit_submitter =
if params[:resubmit].present? && !params[:resubmit].in?([true, 'true'])
Submitter.find_by(slug: params[:resubmit])
end
end
def authorize_start!
return redirect_to start_form_path(@template.slug) if @template.archived_at?
return if @resubmit_submitter
return if @template.shared_link? || (current_user && current_ability.can?(:read, @template))
Rollbar.warning("Not shared template: #{@template.id}") if defined?(Rollbar)
redirect_to start_form_path(@template.slug)
end
def enqueue_submission_create_webhooks(submitter) def enqueue_submission_create_webhooks(submitter)
WebhookUrls.for_account_id(submitter.account_id, 'submission.created').each do |webhook_url| WebhookUrls.for_account_id(submitter.account_id, 'submission.created').each do |webhook_url|
SendSubmissionCreatedWebhookRequestJob.perform_async('submission_id' => submitter.submission_id, SendSubmissionCreatedWebhookRequestJob.perform_async('submission_id' => submitter.submission_id,
@@ -74,31 +102,29 @@ class StartFormController < ApplicationController
end end
def find_or_initialize_submitter(template, submitter_params) def find_or_initialize_submitter(template, submitter_params)
Submitter.where(submission: template.submissions.where(expire_at: Time.current..) Submitter
.where(submission: template.submissions.where(expire_at: Time.current..)
.or(template.submissions.where(expire_at: nil)).where(archived_at: nil)) .or(template.submissions.where(expire_at: nil)).where(archived_at: nil))
.order(id: :desc) .order(id: :desc)
.where(declined_at: nil) .where(declined_at: nil)
.where(external_id: nil) .where(external_id: nil)
.where(ip: [nil, request.remote_ip]) .where(ip: [nil, request.remote_ip])
.then { |rel| params[:resubmit].present? ? rel.where(completed_at: nil) : rel } .then { |rel| params[:resubmit].present? || params[:selfsign].present? ? rel.where(completed_at: nil) : rel }
.find_or_initialize_by(email: submitter_params[:email], **submitter_params.compact_blank) .find_or_initialize_by(email: submitter_params[:email], **submitter_params.compact_blank)
end end
def assign_submission_attributes(submitter, template) def assign_submission_attributes(submitter, template)
resubmit_submitter =
(Submitter.where(submission: template.submissions).find_by(slug: params[:resubmit]) if params[:resubmit].present?)
submitter.assign_attributes( submitter.assign_attributes(
uuid: (filter_undefined_submitters(template).first || @template.submitters.first)['uuid'], uuid: (filter_undefined_submitters(template).first || @template.submitters.first)['uuid'],
ip: request.remote_ip, ip: request.remote_ip,
ua: request.user_agent, ua: request.user_agent,
values: resubmit_submitter&.preferences&.fetch('default_values', nil) || {}, values: @resubmit_submitter&.preferences&.fetch('default_values', nil) || {},
preferences: resubmit_submitter&.preferences.presence || { 'send_email' => true }, preferences: @resubmit_submitter&.preferences.presence || { 'send_email' => true },
metadata: resubmit_submitter&.metadata.presence || {} metadata: @resubmit_submitter&.metadata.presence || {}
) )
if submitter.values.present? if submitter.values.present?
resubmit_submitter.attachments.each do |attachment| @resubmit_submitter.attachments.each do |attachment|
submitter.attachments << attachment.dup if submitter.values.value?(attachment.uuid) submitter.attachments << attachment.dup if submitter.values.value?(attachment.uuid)
end end
end end
@@ -120,15 +146,21 @@ class StartFormController < ApplicationController
end end
def submitter_params def submitter_params
return current_user.slice(:email) if params[:selfsign]
return @resubmit_submitter.slice(:name, :phone, :email) if @resubmit_submitter.present?
params.require(:submitter).permit(:email, :phone, :name).tap do |attrs| params.require(:submitter).permit(:email, :phone, :name).tap do |attrs|
attrs[:email] = Submissions.normalize_email(attrs[:email]) attrs[:email] = Submissions.normalize_email(attrs[:email])
end end
end end
def load_template def load_template
slug = params[:slug] || params[:start_form_slug] @template =
if @resubmit_submitter
@template = Template.find_by!(slug:) @resubmit_submitter.template
else
Template.find_by!(slug: params[:slug] || params[:start_form_slug])
end
end end
def multiple_submitters_error_message def multiple_submitters_error_message
@@ -18,6 +18,6 @@ class SubmissionsArchivedController < ApplicationController
@submissions.order(id: :desc) @submissions.order(id: :desc)
end end
@pagy, @submissions = pagy(@submissions.preload(submitters: :start_form_submission_events)) @pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events))
end end
end end
@@ -19,6 +19,6 @@ class SubmissionsDashboardController < ApplicationController
@submissions.order(id: :desc) @submissions.order(id: :desc)
end end
@pagy, @submissions = pagy(@submissions.preload(submitters: :start_form_submission_events)) @pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events))
end end
end end
@@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class SubmissionsPreviewController < ApplicationController class SubmissionsPreviewController < ApplicationController
around_action :with_browser_locale
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
skip_authorization_check skip_authorization_check
@@ -9,7 +9,7 @@ class TemplateFoldersController < ApplicationController
@templates = Templates.search(@templates, params[:q]) @templates = Templates.search(@templates, params[:q])
@templates = Templates::Order.call(@templates, current_user, cookies.permanent[:dashboard_templates_order]) @templates = Templates::Order.call(@templates, current_user, cookies.permanent[:dashboard_templates_order])
@pagy, @templates = pagy(@templates, limit: 12) @pagy, @templates = pagy_auto(@templates, limit: 12)
end end
def edit; end def edit; end
@@ -7,6 +7,6 @@ class TemplatesArchivedController < ApplicationController
@templates = @templates.where.not(archived_at: nil).preload(:author, :folder, :template_accesses).order(id: :desc) @templates = @templates.where.not(archived_at: nil).preload(:author, :folder, :template_accesses).order(id: :desc)
@templates = Templates.search(@templates, params[:q]) @templates = Templates.search(@templates, params[:q])
@pagy, @templates = pagy(@templates, limit: 12) @pagy, @templates = pagy_auto(@templates, limit: 12)
end end
end end
@@ -15,7 +15,7 @@ class TemplatesArchivedSubmissionsController < ApplicationController
@submissions.order(id: :desc) @submissions.order(id: :desc)
end end
@pagy, @submissions = pagy(@submissions.preload(submitters: :start_form_submission_events)) @pagy, @submissions = pagy_auto(@submissions.preload(submitters: :start_form_submission_events))
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
redirect_to root_path redirect_to root_path
end end
+1 -1
View File
@@ -21,7 +21,7 @@ class TemplatesController < ApplicationController
submissions.order(id: :desc) submissions.order(id: :desc)
end end
@pagy, @submissions = pagy(submissions.preload(:template_accesses, submitters: :start_form_submission_events)) @pagy, @submissions = pagy_auto(submissions.preload(:template_accesses, submitters: :start_form_submission_events))
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
redirect_to root_path redirect_to root_path
end end
@@ -17,7 +17,7 @@ class TemplatesDashboardController < ApplicationController
@pagy, @template_folders = pagy( @pagy, @template_folders = pagy(
@template_folders, @template_folders,
items: FOLDERS_PER_PAGE, limit: FOLDERS_PER_PAGE,
page: @template_folders.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD ? params[:page] : 1 page: @template_folders.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD ? params[:page] : 1
) )
@@ -35,7 +35,7 @@ class TemplatesDashboardController < ApplicationController
(@template_folders.size < 7 ? 9 : 6) (@template_folders.size < 7 ? 9 : 6)
end end
@pagy, @templates = pagy(@templates, limit:) @pagy, @templates = pagy_auto(@templates, limit:)
end end
end end
@@ -0,0 +1,21 @@
# frozen_string_literal: true
class TemplatesShareLinkController < ApplicationController
load_and_authorize_resource :template
def show; end
def create
authorize!(:update, @template)
@template.update!(template_params)
head :ok
end
private
def template_params
params.require(:template).permit(:shared_link)
end
end
+2
View File
@@ -24,6 +24,7 @@ import SubmitForm from './elements/submit_form'
import PromptPassword from './elements/prompt_password' import PromptPassword from './elements/prompt_password'
import EmailsTextarea from './elements/emails_textarea' import EmailsTextarea from './elements/emails_textarea'
import ToggleOnSubmit from './elements/toggle_on_submit' import ToggleOnSubmit from './elements/toggle_on_submit'
import CheckOnClick from './elements/check_on_click'
import PasswordInput from './elements/password_input' import PasswordInput from './elements/password_input'
import SearchInput from './elements/search_input' import SearchInput from './elements/search_input'
import ToggleAttribute from './elements/toggle_attribute' import ToggleAttribute from './elements/toggle_attribute'
@@ -103,6 +104,7 @@ safeRegisterElement('set-date-button', SetDateButton)
safeRegisterElement('indeterminate-checkbox', IndeterminateCheckbox) safeRegisterElement('indeterminate-checkbox', IndeterminateCheckbox)
safeRegisterElement('app-tour', AppTour) safeRegisterElement('app-tour', AppTour)
safeRegisterElement('dashboard-dropzone', DashboardDropzone) safeRegisterElement('dashboard-dropzone', DashboardDropzone)
safeRegisterElement('check-on-click', CheckOnClick)
safeRegisterElement('template-builder', class extends HTMLElement { safeRegisterElement('template-builder', class extends HTMLElement {
connectedCallback () { connectedCallback () {
+14
View File
@@ -0,0 +1,14 @@
export default class extends HTMLElement {
connectedCallback () {
this.addEventListener('click', () => {
if (!this.element.checked) {
this.element.checked = true
this.element.dispatchEvent(new Event('change', { bubbles: true }))
}
})
}
get element () {
return document.getElementById(this.dataset.elementId)
}
}
@@ -3,8 +3,6 @@ export default class extends HTMLElement {
this.clearChecked() this.clearChecked()
this.addEventListener('click', (e) => { this.addEventListener('click', (e) => {
e.stopPropagation()
const text = this.dataset.text || this.innerText.trim() const text = this.dataset.text || this.innerText.trim()
if (navigator.clipboard) { if (navigator.clipboard) {
+5 -5
View File
@@ -23,7 +23,7 @@
</div> </div>
<div <div
v-if="isActive && withLabel && (!area.option_uuid || !option.value)" v-if="isActive && withLabel && (!area.option_uuid || !option.value)"
class="absolute -top-7 rounded bg-base-content text-base-100 px-2 text-sm whitespace-nowrap pointer-events-none" class="absolute -top-7 rounded bg-base-content text-base-100 px-2 text-sm whitespace-nowrap pointer-events-none field-area-active-label"
> >
<template v-if="area.option_uuid && !option.value"> <template v-if="area.option_uuid && !option.value">
{{ optionValue(option) }} {{ optionValue(option) }}
@@ -55,12 +55,12 @@
> >
<div <div
v-else-if="field.type === 'signature' && signature" v-else-if="field.type === 'signature' && signature"
class="flex justify-between h-full gap-1 overflow-hidden" class="flex justify-between h-full gap-1 overflow-hidden w-full"
:class="isNarrow ? 'flex-row' : 'flex-col'" :class="isNarrow && (withSignatureId || field.preferences?.reason_field_uuid) ? 'flex-row' : 'flex-col'"
> >
<div <div
class="flex overflow-hidden" class="flex overflow-hidden"
:class="isNarrow ? 'w-1/2' : 'flex-grow'" :class="isNarrow && (withSignatureId || field.preferences?.reason_field_uuid) ? 'w-1/2' : 'flex-grow'"
style="min-height: 50%" style="min-height: 50%"
> >
<img <img
@@ -69,7 +69,7 @@
> >
</div> </div>
<div <div
v-if="withSignatureId" v-if="withSignatureId || field.preferences?.reason_field_uuid"
class="text-[1vw] lg:text-[0.55rem] lg:leading-[0.65rem]" class="text-[1vw] lg:text-[0.55rem] lg:leading-[0.65rem]"
:class="isNarrow ? 'w-1/2' : 'w-full'" :class="isNarrow ? 'w-1/2' : 'w-full'"
> >
@@ -211,7 +211,7 @@ export default {
}, },
computed: { computed: {
computedPreviousValue () { computedPreviousValue () {
if (this.isUsePreviousValue) { if (this.isUsePreviousValue && this.field.required === true) {
return this.previousValue return this.previousValue
} else { } else {
return null return null
@@ -414,7 +414,7 @@ export default {
} }
}, },
computedPreviousValue () { computedPreviousValue () {
if (this.isUsePreviousValue) { if (this.isUsePreviousValue && this.field.required === true) {
return this.previousValue return this.previousValue
} else { } else {
return null return null
@@ -151,8 +151,10 @@ export default {
resizeTextarea () { resizeTextarea () {
const textarea = this.$refs.textarea const textarea = this.$refs.textarea
if (textarea) {
textarea.style.height = 'auto' textarea.style.height = 'auto'
textarea.style.height = Math.min(250, textarea.scrollHeight) + 'px' textarea.style.height = Math.min(250, textarea.scrollHeight) + 'px'
}
}, },
toggleTextArea () { toggleTextArea () {
this.isTextArea = true this.isTextArea = true
+47 -8
View File
@@ -79,13 +79,52 @@
name="buttons" name="buttons"
/> />
<template v-else> <template v-else>
<a <form
v-if="withSignYourselfButton" v-if="withSignYourselfButton && template.submitters.length < 2"
id="sign_yourself_button" target="_blank"
:href="template.submitters.length > 1 ? `/templates/${template.id}/submissions/new?selfsign=true` : `/d/${template.slug}`" data-turbo="false"
class="inline"
method="post"
:action="`/d/${template.slug}`"
@submit="maybeShowErrorTemplateAlert"
>
<input
type="hidden"
name="_method"
value="put"
autocomplete="off"
>
<input
type="hidden"
name="authenticity_token"
:value="authenticityToken"
autocomplete="off"
>
<input
type="hidden"
name="selfsign"
value="true"
autocomplete="off"
>
<button
class="btn btn-primary btn-ghost text-base hidden md:flex" class="btn btn-primary btn-ghost text-base hidden md:flex"
:target="template.submitters.length > 1 ? '' : '_blank'" type="submit"
:data-turbo-frame="template.submitters.length > 1 ? 'modal' : ''" >
<IconWritingSign
width="22"
class="inline"
/>
<span class="hidden md:inline">
{{ t('sign_yourself') }}
</span>
</button>
</form>
<a
v-else-if="withSignYourselfButton"
id="sign_yourself_button"
:href="`/templates/${template.id}/submissions/new?selfsign=true`"
class="btn btn-primary btn-ghost text-base hidden md:flex"
data-turbo-frame="modal"
@click="maybeShowErrorTemplateAlert" @click="maybeShowErrorTemplateAlert"
> >
<IconWritingSign <IconWritingSign
@@ -1279,7 +1318,7 @@ export default {
area.h = (pageMask.clientWidth / 35 / pageMask.clientWidth) area.h = (pageMask.clientWidth / 35 / pageMask.clientWidth)
} }
}, },
onDraw (area) { onDraw ({ area, isTooSmall }) {
if (this.drawField) { if (this.drawField) {
if (this.drawOption) { if (this.drawOption) {
const areaWithoutOption = this.drawField.areas?.find((a) => !a.option_uuid) const areaWithoutOption = this.drawField.areas?.find((a) => !a.option_uuid)
@@ -1374,7 +1413,7 @@ export default {
area.y -= area.h / 2 area.y -= area.h / 2
} }
if (area.w) { if (area.w && (type !== 'checkbox' || this.drawFieldType || !isTooSmall)) {
this.addField(type, area) this.addField(type, area)
this.selectedAreaRef.value = area this.selectedAreaRef.value = area
+1 -1
View File
@@ -23,7 +23,7 @@
@drop-field="$emit('drop-field', {...$event, attachment_uuid: document.uuid })" @drop-field="$emit('drop-field', {...$event, attachment_uuid: document.uuid })"
@remove-area="$emit('remove-area', $event)" @remove-area="$emit('remove-area', $event)"
@scroll-to="scrollToArea" @scroll-to="scrollToArea"
@draw="$emit('draw', {...$event, attachment_uuid: document.uuid })" @draw="$emit('draw', { area: {...$event.area, attachment_uuid: document.uuid }, isTooSmall: $event.isTooSmall })"
/> />
</div> </div>
</template> </template>
+6 -1
View File
@@ -290,7 +290,12 @@ export default {
area.cell_w = this.newArea.cell_w area.cell_w = this.newArea.cell_w
} }
this.$emit('draw', area) const dx = Math.abs(e.offsetX - this.$refs.mask.clientWidth * this.newArea.initialX)
const dy = Math.abs(e.offsetY - this.$refs.mask.clientHeight * this.newArea.initialY)
const isTooSmall = dx < 8 && dy < 8
this.$emit('draw', { area, isTooSmall })
} }
this.showMask = false this.showMask = false
+7 -3
View File
@@ -20,7 +20,7 @@ class ApplicationMailer < ActionMailer::Base
end end
def set_message_metadata def set_message_metadata
message.instance_variable_set(:@message_metadata, @message_metadata) message.instance_variable_set(:@message_metadata, @message_metadata || {})
end end
def set_message_uuid def set_message_uuid
@@ -28,10 +28,14 @@ class ApplicationMailer < ActionMailer::Base
end end
def assign_message_metadata(tag, record) def assign_message_metadata(tag, record)
@message_metadata = { @message_metadata = (@message_metadata || {}).merge(
'tag' => tag, 'tag' => tag,
'record_id' => record.id, 'record_id' => record.id,
'record_type' => record.class.name 'record_type' => record.class.name
} )
end
def put_metadata(attrs)
@message_metadata = (@message_metadata || {}).merge(attrs)
end end
end end
+9 -1
View File
@@ -233,9 +233,17 @@ class SubmitterMailer < ApplicationMailer
def from_address_for_submitter(submitter) def from_address_for_submitter(submitter)
if submitter.submission.source.in?(%w[api embed]) && if submitter.submission.source.in?(%w[api embed]) &&
(from_email = AccountConfig.find_by(account: submitter.account, key: 'integration_from_email')&.value.presence) (from_email = AccountConfig.find_by(account: submitter.account, key: 'integration_from_email')&.value.presence)
user = submitter.account.users.find_by(email: from_email)
put_metadata('from_user_id' => user.id)
from_email from_email
else else
(submitter.submission.created_by_user || submitter.submission.template.author).friendly_name user = submitter.submission.created_by_user || submitter.submission.template.author
put_metadata('from_user_id' => user.id)
user.friendly_name
end end
end end
end end
+1 -1
View File
@@ -18,7 +18,7 @@
# #
# Indexes # Indexes
# #
# index_email_events_on_account_id (account_id) # index_email_events_on_account_id_and_event_datetime (account_id,event_datetime)
# index_email_events_on_email (email) # index_email_events_on_email (email)
# index_email_events_on_emailable (emailable_type,emailable_id) # index_email_events_on_emailable (emailable_type,emailable_id)
# index_email_events_on_message_id (message_id) # index_email_events_on_message_id (message_id)
+9 -3
View File
@@ -65,13 +65,19 @@ class Submission < ApplicationRecord
scope :active, -> { where(archived_at: nil) } scope :active, -> { where(archived_at: nil) }
scope :archived, -> { where.not(archived_at: nil) } scope :archived, -> { where.not(archived_at: nil) }
scope :pending, -> { joins(:submitters).where(submitters: { completed_at: nil }).group(:id) } 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)
}
scope :completed, lambda { scope :completed, lambda {
where.not(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id]) where.not(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
.and(Submitter.arel_table[:completed_at].eq(nil))).select(1).arel.exists) .and(Submitter.arel_table[:completed_at].eq(nil))).select(1).arel.exists)
} }
scope :declined, -> { joins(:submitters).where.not(submitters: { declined_at: nil }).group(:id) } scope :declined, lambda {
scope :expired, -> { where(expire_at: ..Time.current) } where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
.and(Submitter.arel_table[:declined_at].not_eq(nil))).select(1).arel.exists)
}
scope :expired, -> { pending.where(expire_at: ..Time.current) }
enum :source, { enum :source, {
invite: 'invite', invite: 'invite',
+1
View File
@@ -10,6 +10,7 @@
# name :string not null # name :string not null
# preferences :text not null # preferences :text not null
# schema :text not null # schema :text not null
# shared_link :boolean default(FALSE), not null
# slug :string not null # slug :string not null
# source :text not null # source :text not null
# submitters :text not null # submitters :text not null
+2
View File
@@ -73,6 +73,8 @@ class User < ApplicationRecord
scope :archived, -> { where.not(archived_at: nil) } scope :archived, -> { where.not(archived_at: nil) }
scope :admins, -> { where(role: ADMIN_ROLE) } scope :admins, -> { where(role: ADMIN_ROLE) }
validates :email, format: { with: /\A[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\z/ }
def access_token def access_token
super || build_access_token.tap(&:save!) super || build_access_token.tap(&:save!)
end end
+5 -1
View File
@@ -2,7 +2,11 @@
<% if @pagy.pages > 1 %> <% if @pagy.pages > 1 %>
<div class="flex my-6 justify-center md:justify-between"> <div class="flex my-6 justify-center md:justify-between">
<div class="hidden md:block text-sm"> <div class="hidden md:block text-sm">
<%= @pagy.from %>-<%= local_assigns.fetch(:to, @pagy.to) %> of <%= local_assigns.fetch(:count, @pagy.count) %> <%= local_assigns[:items_name] || 'items' %> <% if @pagy.count.nil? %>
<%= t("pagination.#{local_assigns.fetch(:items_name, 'items')}.range_without_total", from: local_assigns.fetch(:from, @pagy.from), to: local_assigns.fetch(:to, @pagy.to)) %>
<% else %>
<%= t("pagination.#{local_assigns.fetch(:items_name, 'items')}.range_with_total", from: local_assigns.fetch(:from, @pagy.from), to: local_assigns.fetch(:to, @pagy.to), count: local_assigns.fetch(:count, @pagy.count)) %>
<% end %>
<%= local_assigns[:left_additional_html] %> <%= local_assigns[:left_additional_html] %>
</div> </div>
<div class="flex items-center space-x-1.5"> <div class="flex items-center space-x-1.5">
+5
View File
@@ -0,0 +1,5 @@
<% if configs = account.account_configs.find_by(key: AccountConfig::POLICY_LINKS_KEY) %>
<div class="max-w-md mx-auto flex flex-wrap gap-1 justify-center text-sm text-base-content/60 mt-2">
<%= auto_link(MarkdownToHtml.call(configs.value)) %>
</div>
<% end %>
+3 -3
View File
@@ -23,12 +23,12 @@
</div> </div>
<% if Docuseal.multitenant? || Accounts.can_send_emails?(@submitter.account) %> <% if Docuseal.multitenant? || Accounts.can_send_emails?(@submitter.account) %>
<toggle-submit class="block"> <toggle-submit class="block">
<%= button_to button_title(title: t('send_copy_to_email'), disabled_with: t('sending'), icon: svg_icon('mail_forward', class: 'w-6 h-6')), send_submission_email_index_path, params: { submitter_slug: @submitter.slug }, class: 'base-button w-full' %> <%= button_to button_title(title: t('send_copy_to_email'), disabled_with: t('sending'), icon: svg_icon('mail_forward', class: 'w-6 h-6')), send_submission_email_index_path, params: { template_slug: @template.slug, email: params[:email] }, class: 'base-button w-full' %>
</toggle-submit> </toggle-submit>
<% end %> <% end %>
<% if Templates.filter_undefined_submitters(@template).size == 1 && %w[api embed].exclude?(@submitter.submission.source) && @submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false %> <% if Templates.filter_undefined_submitters(@template).size == 1 && %w[api embed].exclude?(@submitter.submission.source) && @submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false && @template.shared_link? %>
<toggle-submit class="block"> <toggle-submit class="block">
<%= button_to button_title(title: t('resubmit'), disabled_with: t('resubmit'), icon: svg_icon('reload', class: 'w-6 h-6')), start_form_path(@template.slug), params: { submitter: { email: params[:email] }, resubmit: @submitter.slug }, method: :put, class: 'white-button w-full' %> <%= button_to button_title(title: t('resubmit'), disabled_with: t('resubmit'), icon: svg_icon('reload', class: 'w-6 h-6')), start_form_path(@template.slug), params: { submitter: { email: params[:email] }, resubmit: true }, method: :put, class: 'white-button w-full' %>
</toggle-submit> </toggle-submit>
<% end %> <% end %>
</div> </div>
+28
View File
@@ -0,0 +1,28 @@
<% content_for(:html_title, "#{@template.name} | DocuSeal") %>
<% content_for(:html_description, t('share_link_is_currently_disabled')) %>
<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="text-center w-full space-y-6">
<%= render 'banner' %>
<p class="text-xl font-semibold text-center">
<%= t('share_link_is_currently_disabled') %>
</p>
</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 class="text-lg font-bold mb-1"><%= @template.name %></p>
<% if @template.archived_at? %>
<p dir="auto" class="text-sm"><%= t('form_has_been_deleted_by_html', name: @template.account.name) %></p>
<% end %>
</div>
</div>
</div>
</div>
</div>
</div>
<%= render 'shared/attribution', link_path: '/start', account: @template.account %>
+1
View File
@@ -42,3 +42,4 @@
</div> </div>
</div> </div>
<%= render 'shared/attribution', link_path: '/start', account: @template.account %> <%= render 'shared/attribution', link_path: '/start', account: @template.account %>
<%= render 'start_form/policy', account: @template.account %>
+3 -3
View File
@@ -6,11 +6,11 @@
<field-value dir="auto" class="flex absolute text-[1.6vw] lg:text-base <%= 'font-mono' if font == 'Courier' %> <%= 'font-serif' 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? %>width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%; <%= "font-size: clamp(4pt, 1.6vw, #{field['preferences']['font_size'].to_i * 1.23}pt); line-height: `clamp(6pt, 2.0vw, #{(field['preferences']['font_size'].to_i * 1.23) + 3}pt)`" if field.dig('preferences', 'font_size') %>"> <field-value dir="auto" class="flex absolute text-[1.6vw] lg:text-base <%= 'font-mono' if font == 'Courier' %> <%= 'font-serif' 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? %>width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%; <%= "font-size: clamp(4pt, 1.6vw, #{field['preferences']['font_size'].to_i * 1.23}pt); line-height: `clamp(6pt, 2.0vw, #{(field['preferences']['font_size'].to_i * 1.23) + 3}pt)`" if field.dig('preferences', 'font_size') %>">
<% if field['type'] == 'signature' %> <% if field['type'] == 'signature' %>
<% is_narrow = area['h']&.positive? && (area['w'].to_f / area['h']) > 6 %> <% is_narrow = area['h']&.positive? && (area['w'].to_f / area['h']) > 6 %>
<div class="flex justify-between w-full h-full gap-1 <%= is_narrow ? 'flex-row' : 'flex-col' %>"> <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 ? 'w-1/2' : 'flex-grow' %>" style="min-height: 50%"> <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 %>"> <img class="object-contain mx-auto" src="<%= attachments_index[value].url %>">
</div> </div>
<% if local_assigns[:with_signature_id] && attachment = attachments_index[value] %> <% if (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) && attachment = attachments_index[value] %>
<div class="text-[1vw] lg:text-[0.55rem] lg:leading-[0.65rem] <%= is_narrow ? 'w-1/2' : 'w-full' %>"> <div class="text-[1vw] lg:text-[0.55rem] lg:leading-[0.65rem] <%= is_narrow ? 'w-1/2' : 'w-full' %>">
<div class="truncate uppercase"> <div class="truncate uppercase">
ID: <%= attachment.uuid %> ID: <%= attachment.uuid %>
@@ -21,7 +21,7 @@
<%= render 'submissions_filters/filter_button', filter_params: %> <%= render 'submissions_filters/filter_button', filter_params: %>
</div> </div>
</div> </div>
<% if @pagy.count > 0 %> <% if @pagy.count.nil? || @pagy.count > 0 %>
<div class="space-y-4"> <div class="space-y-4">
<%= render partial: 'templates/submission', collection: @submissions, locals: { with_template: true, archived: true } %> <%= render partial: 'templates/submission', collection: @submissions, locals: { with_template: true, archived: true } %>
</div> </div>
@@ -1,5 +1,5 @@
<% filter_params = params.permit(Submissions::Filter::ALLOWED_PARAMS).compact_blank %> <% filter_params = params.permit(Submissions::Filter::ALLOWED_PARAMS).compact_blank %>
<% is_show_tabs = @pagy.count >= 5 || params[:status].present? || filter_params.present? %> <% is_show_tabs = (@pagy.count.nil? || @pagy.count >= 5) || params[:status].present? || filter_params.present? %>
<% if Docuseal.demo? %><%= render 'shared/demo_alert' %><% end %> <% if Docuseal.demo? %><%= render 'shared/demo_alert' %><% end %>
<div class="flex justify-between items-center w-full mb-4"> <div class="flex justify-between items-center w-full mb-4">
<div class="flex items-center flex-grow min-w-0"> <div class="flex items-center flex-grow min-w-0">
@@ -61,12 +61,12 @@
</div> </div>
</div> </div>
<% end %> <% end %>
<% if @pagy.count > 0 %> <% if @pagy.count.nil? || @pagy.count > 0 %>
<div class="space-y-4"> <div class="space-y-4">
<%= render partial: 'templates/submission', collection: @submissions, locals: { with_template: true } %> <%= render partial: 'templates/submission', collection: @submissions, locals: { with_template: true } %>
</div> </div>
<% end %> <% end %>
<% if params[:q].blank? && params[:status].blank? && filter_params.blank? && @pagy.count < 5 %> <% if params[:q].blank? && params[:status].blank? && filter_params.blank? && @pagy.count.present? && @pagy.count < 5 %>
<%= render 'templates/dropzone' %> <%= render 'templates/dropzone' %>
<% end %> <% end %>
<% if @submissions.present? || (params[:q].blank? && filter_params.blank?) %> <% if @submissions.present? || (params[:q].blank? && filter_params.blank?) %>
@@ -28,6 +28,11 @@
<%= f.hidden_field :submission_slug, value: @submission.slug %> <%= f.hidden_field :submission_slug, value: @submission.slug %>
<%= f.label :email, t('email'), class: 'label' %> <%= f.label :email, t('email'), class: 'label' %>
<%= f.email_field :email, value: current_user&.email || params[:email], required: true, class: 'base-input', placeholder: t('send_copy_to_email') %> <%= f.email_field :email, value: current_user&.email || params[:email], required: true, class: 'base-input', placeholder: t('send_copy_to_email') %>
<% if params[:status] == 'error' %>
<span class="label-text-alt text-red-400 mt-1">
<%= t('please_enter_your_email_address_associated_with_the_completed_submission') %>
</span>
<% end %>
</div> </div>
<toggle-submit dir="auto" class="form-control"> <toggle-submit dir="auto" class="form-control">
<%= f.button button_title(title: t('send_copy_to_email'), disabled_with: t('starting')), class: 'base-button' %> <%= f.button button_title(title: t('send_copy_to_email'), disabled_with: t('starting')), class: 'base-button' %>
+2 -2
View File
@@ -43,10 +43,10 @@
<% end %> <% end %>
</div> </div>
<% undefined_submitters = Templates.filter_undefined_submitters(@submitter.submission.template) %> <% undefined_submitters = Templates.filter_undefined_submitters(@submitter.submission.template) %>
<% if undefined_submitters.size == 1 && undefined_submitters.first['uuid'] == @submitter.uuid && %w[api embed].exclude?(@submitter.submission.source) && @submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false && !@submitter.template.archived_at? %> <% if undefined_submitters.size == 1 && undefined_submitters.first['uuid'] == @submitter.uuid && %w[api embed].exclude?(@submitter.submission.source) && @submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false %>
<div class="divider uppercase"><%= t('or') %></div> <div class="divider uppercase"><%= t('or') %></div>
<toggle-submit class="block"> <toggle-submit class="block">
<%= button_to button_title(title: t('resubmit'), disabled_with: t('resubmit'), icon: svg_icon('reload', class: 'w-6 h-6')), start_form_path(@submitter.submission.template.slug), params: { submitter: { email: @submitter.email, phone: @submitter.phone, name: @submitter.name }, resubmit: @submitter.slug }, method: :put, class: 'white-button w-full' %> <%= button_to button_title(title: t('resubmit'), disabled_with: t('resubmit'), icon: svg_icon('reload', class: 'w-6 h-6')), resubmit_form_path, params: { resubmit: @submitter.slug }, method: :put, class: 'white-button w-full' %>
</toggle-submit> </toggle-submit>
<% end %> <% end %>
</div> </div>
+2 -2
View File
@@ -39,13 +39,13 @@
<% end %> <% end %>
</div> </div>
</div> </div>
<% if @pagy.count > 0 %> <% if @pagy.count.nil? || @pagy.count > 0 %>
<div class="grid gap-4 md:grid-cols-3"> <div class="grid gap-4 md:grid-cols-3">
<%= render partial: 'templates/template', collection: @templates %> <%= render partial: 'templates/template', collection: @templates %>
</div> </div>
<% templates_order_select_html = capture do %> <% templates_order_select_html = capture do %>
<% if params[:q].blank? && @pagy.pages > 1 %> <% if params[:q].blank? && @pagy.pages > 1 %>
<%= render('shared/templates_order_select', with_recently_used: @pagy.count < 10_000) %> <%= render('shared/templates_order_select', with_recently_used: @pagy.count.present? && @pagy.count < 10_000) %>
<% end %> <% end %>
<% end %> <% end %>
<%= render 'shared/pagination', pagy: @pagy, items_name: 'templates', right_additional_html: templates_order_select_html %> <%= render 'shared/pagination', pagy: @pagy, items_name: 'templates', right_additional_html: templates_order_select_html %>
+1 -1
View File
@@ -34,7 +34,7 @@
<% submitter = submitters.first %> <% submitter = submitters.first %>
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<span class="flex flex-col md:flex-row md:items-center gap-3"> <span class="flex flex-col md:flex-row md:items-center gap-3">
<% if submission.expired? %> <% if submission.expired? && !submitter.completed_at? && !submitter.declined_at? %>
<div class="tooltip flex" data-tip="<%= l(submission.expire_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %>"> <div class="tooltip flex" data-tip="<%= l(submission.expire_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %>">
<span class="badge badge-error md:w-32 bg-opacity-50 badge-lg uppercase text-sm font-semibold"> <span class="badge badge-error md:w-32 bg-opacity-50 badge-lg uppercase text-sm font-semibold">
<%= t('expired') %> <%= t('expired') %>
+6 -1
View File
@@ -49,7 +49,12 @@
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
<%= render 'shared/clipboard_copy', text: start_form_url(slug: @template.slug), id: 'share_link_clipboard', class: 'absolute md:relative bottom-0 right-0 btn btn-xs md:btn-sm whitespace-nowrap btn-neutral text-white mt-1 px-2', icon_class: 'w-4 h-4 md:w-6 md:h-6 text-white', copy_title: t('link'), copied_title: t('copied'), copy_title_md: t('link'), copied_title_md: t('copied') %> <%= link_to template_share_link_path(template), class: 'absolute md:relative bottom-0 right-0 btn btn-xs md:btn-sm whitespace-nowrap btn-neutral text-white mt-1 px-2', data: { turbo_frame: :modal } do %>
<span class="flex items-center justify-center space-x-2">
<%= svg_icon('link', class: 'w-4 h-4 md:w-6 md:h-6 text-white') %>
<span><%= t('link') %></span>
</span>
<% end %>
</div> </div>
<% end %> <% end %>
</div> </div>
+8 -2
View File
@@ -1,7 +1,7 @@
<%= render 'title', template: @template %> <%= render 'title', template: @template %>
<% filter_params = params.permit(Submissions::Filter::ALLOWED_PARAMS).compact_blank %> <% filter_params = params.permit(Submissions::Filter::ALLOWED_PARAMS).compact_blank %>
<% is_show_tabs = @pagy.pages > 1 || params[:q].present? || params[:status].present? || filter_params.present? %> <% is_show_tabs = @pagy.pages > 1 || params[:q].present? || params[:status].present? || filter_params.present? %>
<% if !@pagy.count.zero? || params[:q].present? || params[:status].present? || filter_params.present? %> <% if @pagy.count.nil? || !@pagy.count.zero? || params[:q].present? || params[:status].present? || filter_params.present? %>
<div class="<%= is_show_tabs ? 'mb-4' : 'mb-6' %>"> <div class="<%= is_show_tabs ? 'mb-4' : 'mb-6' %>">
<div class="flex justify-between items-center md:items-end"> <div class="flex justify-between items-center md:items-end">
<div> <div>
@@ -35,27 +35,33 @@
<%= svg_icon('list', class: 'w-5 h-5') %> <%= svg_icon('list', class: 'w-5 h-5') %>
<span class="font-normal"><%= t('all') %></span> <span class="font-normal"><%= t('all') %></span>
</div> </div>
<% unless can?(:manage, :countless) %>
<div class="badge badge-neutral badge-outline font-medium"> <div class="badge badge-neutral badge-outline font-medium">
<%= params[:status].blank? && filter_params.blank? ? @pagy.count : @base_submissions.unscope(:group, :order).select(:id).distinct.count %> <%= params[:status].blank? && filter_params.blank? ? @pagy.count : @base_submissions.unscope(:group, :order).select(:id).distinct.count %>
</div> </div>
<% end %>
</a> </a>
<a href="<%= url_for(params.to_unsafe_h.merge(status: :pending)) %>" class="<%= params[:status] == 'pending' ? 'border-neutral-700' : 'border-neutral-300' %> flex h-10 px-2 py-1 text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl w-full md:w-48 hover:border-neutral-700"> <a href="<%= url_for(params.to_unsafe_h.merge(status: :pending)) %>" class="<%= params[:status] == 'pending' ? 'border-neutral-700' : 'border-neutral-300' %> flex h-10 px-2 py-1 text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl w-full md:w-48 hover:border-neutral-700">
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1">
<%= svg_icon('clock', class: 'w-5 h-5') %> <%= svg_icon('clock', class: 'w-5 h-5') %>
<span class="font-normal"><%= t('pending') %></span> <span class="font-normal"><%= t('pending') %></span>
</div> </div>
<% unless can?(:manage, :countless) %>
<div class="badge badge-neutral badge-outline font-medium"> <div class="badge badge-neutral badge-outline font-medium">
<%= params[:status] == 'pending' && filter_params.blank? ? @pagy.count : @base_submissions.pending.unscope(:group, :order).select(:id).distinct.count %> <%= params[:status] == 'pending' && filter_params.blank? ? @pagy.count : @base_submissions.pending.unscope(:group, :order).select(:id).distinct.count %>
</div> </div>
<% end %>
</a> </a>
<a href="<%= url_for(params.to_unsafe_h.merge(status: :completed)) %>" class="<%= params[:status] == 'completed' ? 'border-neutral-700' : 'border-neutral-300' %> flex h-10 px-2 py-1 text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl w-full md:w-48 hover:border-neutral-700"> <a href="<%= url_for(params.to_unsafe_h.merge(status: :completed)) %>" class="<%= params[:status] == 'completed' ? 'border-neutral-700' : 'border-neutral-300' %> flex h-10 px-2 py-1 text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl w-full md:w-48 hover:border-neutral-700">
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1">
<%= svg_icon('circle_check', class: 'w-5 h-5') %> <%= svg_icon('circle_check', class: 'w-5 h-5') %>
<span class="font-normal"><%= t('completed') %></span> <span class="font-normal"><%= t('completed') %></span>
</div> </div>
<% unless can?(:manage, :countless) %>
<div class="badge badge-neutral badge-outline font-medium"> <div class="badge badge-neutral badge-outline font-medium">
<%= params[:status] == 'completed' && filter_params.blank? ? @pagy.count : @base_submissions.completed.unscope(:group, :order).select(:id).distinct.count %> <%= params[:status] == 'completed' && filter_params.blank? ? @pagy.count : @base_submissions.completed.unscope(:group, :order).select(:id).distinct.count %>
</div> </div>
<% end %>
</a> </a>
</div> </div>
<div class="flex items-end flex-col md:flex-row gap-2 w-full md:w-fit"> <div class="flex items-end flex-col md:flex-row gap-2 w-full md:w-fit">
@@ -89,7 +95,7 @@
<% end %> <% end %>
<% end %> <% end %>
<% if @template.submitters.size == 1 %> <% if @template.submitters.size == 1 %>
<%= link_to start_form_url(slug: @template.slug), id: 'sign_yourself_button', class: 'white-button mt-6', target: '_blank', rel: 'noopener' do %> <%= button_to start_form_path(@template.slug), params: { selfsign: true }, method: :put, class: 'white-button w-full', form: { style: 'display: inline', target: '_blank', data: { turbo: false } } do %>
<%= svg_icon('writing', class: 'w-6 h-6') %> <%= svg_icon('writing', class: 'w-6 h-6') %>
<span class="mr-1"><%= t('sign_it_yourself') %></span> <span class="mr-1"><%= t('sign_it_yourself') %></span>
<% end %> <% end %>
+1 -1
View File
@@ -12,7 +12,7 @@
<%= render 'shared/search_input', placeholder: "#{t('search')}..." %> <%= render 'shared/search_input', placeholder: "#{t('search')}..." %>
<% end %> <% end %>
</div> </div>
<% if @pagy.count > 0 %> <% if @pagy.count.nil? || @pagy.count > 0 %>
<div class="grid gap-4 md:grid-cols-3"> <div class="grid gap-4 md:grid-cols-3">
<%= render partial: 'templates/template', collection: @templates %> <%= render partial: 'templates/template', collection: @templates %>
</div> </div>
@@ -29,7 +29,7 @@
</div> </div>
<% end %> <% end %>
</div> </div>
<% if @pagy.count > 0 %> <% if @pagy.count.nil? || @pagy.count > 0 %>
<div class="space-y-4"> <div class="space-y-4">
<%= render partial: 'templates/submission', collection: @submissions, locals: { template: @template, archived: true } %> <%= render partial: 'templates/submission', collection: @submissions, locals: { template: @template, archived: true } %>
</div> </div>
+4 -4
View File
@@ -12,7 +12,7 @@
<%= render 'templates/dashboard_dropzone', style: 'height: 114px' %> <%= render 'templates/dashboard_dropzone', style: 'height: 114px' %>
<% end %> <% end %>
<div class="flex items-center flex-grow min-w-0"> <div class="flex items-center flex-grow min-w-0">
<% if has_archived || @pagy.count > 0 || @template_folders.present? %> <% if has_archived || @pagy.count.nil? || @pagy.count > 0 || @template_folders.present? %>
<div class="mr-2"> <div class="mr-2">
<%= render 'dashboard/toggle_view', selected: 'templates' %> <%= render 'dashboard/toggle_view', selected: 'templates' %>
</div> </div>
@@ -45,7 +45,7 @@
<% end %> <% end %>
<% templates_order_select_html = capture do %> <% templates_order_select_html = capture do %>
<% if params[:q].blank? && @pagy.pages > 1 %> <% if params[:q].blank? && @pagy.pages > 1 %>
<%= render('shared/templates_order_select', with_recently_used: @pagy.count < 10_000) %> <%= render('shared/templates_order_select', with_recently_used: @pagy.count.present? && @pagy.count < 10_000) %>
<% end %> <% end %>
<% end %> <% end %>
<% if @template_folders.present? %> <% if @template_folders.present? %>
@@ -84,9 +84,9 @@
<% if show_dropzone %> <% if show_dropzone %>
<%= render 'templates/dropzone' %> <%= render 'templates/dropzone' %>
<% end %> <% end %>
<% if @templates.present? || params[:q].blank? %> <% if @templates.present? || @template_folders.present? || params[:q].blank? %>
<% if @pagy.pages > 1 %> <% if @pagy.pages > 1 %>
<%= render 'shared/pagination', pagy: @pagy, items_name: 'templates', left_additional_html: view_archived_html, right_additional_html: templates_order_select_html %> <%= render 'shared/pagination', pagy: @pagy, items_name: @templates.present? ? 'templates' : 'template_folders', left_additional_html: view_archived_html, right_additional_html: templates_order_select_html %>
<% else %> <% else %>
<div class="mt-2"> <div class="mt-2">
<%= view_archived_html %> <%= view_archived_html %>
@@ -380,8 +380,18 @@
<%= t('embedding_url') %> <%= t('embedding_url') %>
</label> </label>
<div class="flex gap-2 mb-4 mt-2"> <div class="flex gap-2 mb-4 mt-2">
<%= form_for @template, url: template_share_link_path(@template), method: :post, html: { id: 'shared_link_form', autocomplete: 'off', class: 'w-full mt-1' }, data: { close_on_submit: false } do |f| %>
<div class="flex gap-2">
<input id="embedding_url" type="text" value="<%= start_form_url(slug: @template.slug) %>" class="base-input w-full" autocomplete="off" readonly> <input id="embedding_url" type="text" value="<%= start_form_url(slug: @template.slug) %>" class="base-input w-full" autocomplete="off" readonly>
<check-on-click data-element-id="template_shared_link">
<%= render 'shared/clipboard_copy', icon: 'copy', text: start_form_url(slug: @template.slug), class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %> <%= render 'shared/clipboard_copy', icon: 'copy', text: start_form_url(slug: @template.slug), class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
</check-on-click>
</div>
<div class="flex items-center justify-between gap-1 pt-3">
<span><%= t('enable_shared_link') %></span>
<%= f.check_box :shared_link, { class: 'toggle', onchange: 'this.form.requestSubmit()' }, 'true', 'false' %>
</div>
<% end %>
</div> </div>
</div> </div>
<%= render 'templates_code_modal/placeholder' %> <%= render 'templates_code_modal/placeholder' %>
@@ -0,0 +1,16 @@
<%= render 'shared/turbo_modal_large', title: t('share_link') do %>
<div class="mt-2 mb-4 px-5">
<%= form_for @template, url: template_share_link_path(@template), method: :post, html: { id: 'shared_link_form', autocomplete: 'off', class: 'mt-3' }, data: { close_on_submit: false } do |f| %>
<div class="flex items-center justify-between gap-1 px-1">
<span><%= t('enable_shared_link') %></span>
<%= f.check_box :shared_link, { disabled: !can?(:update, @template), class: 'toggle', onchange: 'this.form.requestSubmit()' }, 'true', 'false' %>
</div>
<div class="flex gap-2 mt-3">
<input id="embedding_url" type="text" value="<%= start_form_url(slug: @template.slug) %>" class="base-input w-full" autocomplete="off" readonly>
<check-on-click data-element-id="template_shared_link">
<%= render 'shared/clipboard_copy', icon: 'copy', text: start_form_url(slug: @template.slug), class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
</check-on-click>
</div>
<% end %>
</div>
<% end %>
+2
View File
@@ -1,5 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'pagy/extras/countless'
Pagy::DEFAULT[:limit] = 10 Pagy::DEFAULT[:limit] = 10
Pagy::DEFAULT.freeze Pagy::DEFAULT.freeze
+175 -7
View File
@@ -22,6 +22,9 @@ en: &en
hi_there: Hi there hi_there: Hi there
thanks: Thanks thanks: Thanks
private: Private private: Private
stripe_integration: Stripe Integration
stripe_account_has_been_connected: Stripe account has been connected.
re_connect_stripe: Re-connect Stripe
bcc_recipients: BCC recipients bcc_recipients: BCC recipients
resend_pending: Re-send pending resend_pending: Re-send pending
always_enforce_signing_order: Always enforce the signing order always_enforce_signing_order: Always enforce the signing order
@@ -46,7 +49,7 @@ en: &en
team_member_permissions: Team member permissions team_member_permissions: Team member permissions
entire_team: Entire team entire_team: Entire team
admin_only: Admin only admin_only: Admin only
accessiable_by: Accessiable by accessiable_by: Accessible by
team_access: Team access team_access: Team access
document_download_filename_format: Document download filename format document_download_filename_format: Document download filename format
document_name: Document Name document_name: Document Name
@@ -655,7 +658,7 @@ en: &en
completed_at: Completed at completed_at: Completed at
edit_recipient: Edit Recipient edit_recipient: Edit Recipient
update_recipient: Update Recipient update_recipient: Update Recipient
use_international_format_1xxx_: 'Use internatioanl format: +1xxx...' use_international_format_1xxx_: 'Use international format: +1xxx...'
submitter_cannot_be_updated: Submitter cannot be updated. submitter_cannot_be_updated: Submitter cannot be updated.
at_least_one_field_must_be_filled: At least one field must be filled. at_least_one_field_must_be_filled: At least one field must be filled.
archived_users: Archived Users archived_users: Archived Users
@@ -741,11 +744,16 @@ en: &en
two_months: 2 months two_months: 2 months
three_months: 3 months three_months: 3 months
eu_data_residency: EU data residency eu_data_residency: EU data residency
please_enter_your_email_address_associated_with_the_completed_submission: Please enter your email address associated with the completed submission.
esignature_disclosure: eSignature Disclosure
share_link: Share link
enable_shared_link: Enable shared link
share_link_is_currently_disabled: Share link is currently disabled
submission_sources: submission_sources:
api: API api: API
bulk: Bulk Send bulk: Bulk Send
embed: Embedding embed: Embedding
invie: Invite invite: Invite
link: Link link: Link
submission_event_names: submission_event_names:
send_email_to_html: '<b>Email sent</b> to %{submitter_name}' send_email_to_html: '<b>Email sent</b> to %{submitter_name}'
@@ -825,8 +833,27 @@ en: &en
scopes: scopes:
write: Update your data write: Update your data
read: Read your data read: Read your data
pagination:
submissions:
range_with_total: "%{from}-%{to} of %{count} submissions"
range_without_total: "%{from}-%{to} submissions"
templates:
range_with_total: "%{from}-%{to} of %{count} templates"
range_without_total: "%{from}-%{to} templates"
template_folders:
range_with_total: "%{from}-%{to} of %{count} folders"
range_without_total: "%{from}-%{to} folders"
users:
range_with_total: "%{from}-%{to} of %{count} users"
range_without_total: "%{from}-%{to} users"
items:
range_with_total: "%{from}-%{to} of %{count} items"
range_without_total: "%{from}-%{to} items"
es: &es es: &es
stripe_integration: Integración con Stripe
stripe_account_has_been_connected: La cuenta de Stripe ha sido conectada.
re_connect_stripe: Volver a conectar Stripe
private: Privado private: Privado
resend_pending: Reenviar pendiente resend_pending: Reenviar pendiente
ensure_unique_recipients: Asegurar destinatarios únicos ensure_unique_recipients: Asegurar destinatarios únicos
@@ -1549,6 +1576,11 @@ es: &es
two_months: 2 meses two_months: 2 meses
three_months: 3 meses three_months: 3 meses
eu_data_residency: Datos alojados UE eu_data_residency: Datos alojados UE
please_enter_your_email_address_associated_with_the_completed_submission: Por favor, introduce tu dirección de correo electrónico asociada con el envío completado.
esignature_disclosure: Uso de firma electrónica
share_link: Enlace para compartir
enable_shared_link: Habilitar enlace compartido
share_link_is_currently_disabled: El enlace compartido está deshabilitado actualmente
submission_sources: submission_sources:
api: API api: API
bulk: Envío masivo bulk: Envío masivo
@@ -1633,8 +1665,27 @@ es: &es
scopes: scopes:
write: Actualizar tus datos write: Actualizar tus datos
read: Leer tus datos read: Leer tus datos
pagination:
submissions:
range_with_total: "%{from}-%{to} de %{count} envíos"
range_without_total: "%{from}-%{to} envíos"
templates:
range_with_total: "%{from}-%{to} de %{count} plantillas"
range_without_total: "%{from}-%{to} plantillas"
template_folders:
range_with_total: "%{from}-%{to} de %{count} carpetas"
range_without_total: "%{from}-%{to} carpetas"
users:
range_with_total: "%{from}-%{to} de %{count} usuarios"
range_without_total: "%{from}-%{to} usuarios"
items:
range_with_total: "%{from}-%{to} de %{count} elementos"
range_without_total: "%{from}-%{to} elementos"
it: &it it: &it
stripe_integration: Integrazione Stripe
stripe_account_has_been_connected: L'account Stripe è stato collegato.
re_connect_stripe: Ricollega Stripe
private: Privato private: Privato
resend_pending: Reinvia in sospeso resend_pending: Reinvia in sospeso
ensure_unique_recipients: Assicurarsi destinatari unici ensure_unique_recipients: Assicurarsi destinatari unici
@@ -2355,6 +2406,11 @@ it: &it
two_months: 2 mesi two_months: 2 mesi
three_months: 3 mesi three_months: 3 mesi
eu_data_residency: "Dati nell'UE" eu_data_residency: "Dati nell'UE"
please_enter_your_email_address_associated_with_the_completed_submission: "Inserisci il tuo indirizzo email associato all'invio completato."
esignature_disclosure: Uso della firma elettronica
share_link: Link di condivisione
enable_shared_link: Abilita link condiviso
share_link_is_currently_disabled: Il link condiviso è attualmente disabilitato
submission_sources: submission_sources:
api: API api: API
bulk: Invio massivo bulk: Invio massivo
@@ -2439,8 +2495,27 @@ it: &it
scopes: scopes:
write: Aggiorna i tuoi dati write: Aggiorna i tuoi dati
read: Leggi i tuoi dati read: Leggi i tuoi dati
pagination:
submissions:
range_with_total: "%{from}-%{to} di %{count} invii"
range_without_total: "%{from}-%{to} invii"
templates:
range_with_total: "%{from}-%{to} di %{count} modelli"
range_without_total: "%{from}-%{to} modelli"
template_folders:
range_with_total: "%{from}-%{to} di %{count} cartelle"
range_without_total: "%{from}-%{to} cartelle"
users:
range_with_total: "%{from}-%{to} di %{count} utenti"
range_without_total: "%{from}-%{to} utenti"
items:
range_with_total: "%{from}-%{to} di %{count} elementi"
range_without_total: "%{from}-%{to} elementi"
fr: &fr fr: &fr
stripe_integration: Intégration Stripe
stripe_account_has_been_connected: Le compte Stripe a été connecté.
re_connect_stripe: Reconnecter Stripe
private: Privé private: Privé
resend_pending: Renvoyer en attente resend_pending: Renvoyer en attente
ensure_unique_recipients: Assurer l'unicité des destinataires ensure_unique_recipients: Assurer l'unicité des destinataires
@@ -3164,6 +3239,11 @@ fr: &fr
two_months: 2 mois two_months: 2 mois
three_months: 3 mois three_months: 3 mois
eu_data_residency: "Données dans l'UE" eu_data_residency: "Données dans l'UE"
please_enter_your_email_address_associated_with_the_completed_submission: "Veuillez saisir l'adresse e-mail associée à l'envoi complété."
esignature_disclosure: Divulgation de Signature Électronique
share_link: Lien de partage
enable_shared_link: Activer le lien de partage
share_link_is_currently_disabled: Le lien de partage est actuellement désactivé
submission_sources: submission_sources:
api: API api: API
bulk: Envoi en masse bulk: Envoi en masse
@@ -3248,8 +3328,27 @@ fr: &fr
scopes: scopes:
write: Mettre à jour vos données write: Mettre à jour vos données
read: Lire vos données read: Lire vos données
pagination:
submissions:
range_with_total: "%{from} à %{to} sur %{count} soumissions"
range_without_total: "%{from} à %{to} soumissions"
templates:
range_with_total: "%{from} à %{to} sur %{count} modèles"
range_without_total: "%{from} à %{to} modèles"
template_folders:
range_with_total: "%{from} à %{to} sur %{count} dossiers"
range_without_total: "%{from} à %{to} dossiers"
users:
range_with_total: "%{from} à %{to} sur %{count} utilisateurs"
range_without_total: "%{from} à %{to} utilisateurs"
items:
range_with_total: "%{from} à %{to} sur %{count} éléments"
range_without_total: "%{from} à %{to} éléments"
pt: &pt pt: &pt
stripe_integration: Integração com Stripe
stripe_account_has_been_connected: Conta Stripe foi conectada.
re_connect_stripe: Reconectar Stripe
private: Privado private: Privado
resend_pending: Re-enviar pendente resend_pending: Re-enviar pendente
ensure_unique_recipients: Garantir destinatários únicos ensure_unique_recipients: Garantir destinatários únicos
@@ -3972,6 +4071,11 @@ pt: &pt
two_months: 2 meses two_months: 2 meses
three_months: 3 meses three_months: 3 meses
eu_data_residency: Dados na UE eu_data_residency: Dados na UE
please_enter_your_email_address_associated_with_the_completed_submission: Por favor, insira seu e-mail associado ao envio concluído.
esignature_disclosure: Uso de assinatura eletrônica
share_link: Link de compartilhamento
enable_shared_link: Ativar link compartilhado
share_link_is_currently_disabled: O link compartilhado está desativado no momento
submission_sources: submission_sources:
api: API api: API
bulk: Envio em massa bulk: Envio em massa
@@ -4016,8 +4120,8 @@ pt: &pt
start: Iniciar start: Iniciar
previous: Anterior previous: Anterior
next: Próximo next: Próximo
template_and_submissions: 'Modelos e Envios' template_and_submissions: 'Modelos e Submissões'
template_and_submissions_description: "Você pode selecionar a visualização que melhor se adapta ao seu fluxo de trabalho. Escolha a visualização 'Modelos' para criar modelos reutilizáveis de documentos ou 'Envios' para assinar documentos individuais ou verificar o status de cada solicitação de assinatura." template_and_submissions_description: "Você pode selecionar a visualização que melhor se adapta ao seu fluxo de trabalho. Escolha a visualização 'Modelos' para criar modelos reutilizáveis de documentos ou 'Submissões' para assinar documentos individuais ou verificar o status de cada solicitação de assinatura."
upload_a_pdf_file: 'Enviar um arquivo PDF' upload_a_pdf_file: 'Enviar um arquivo PDF'
upload_a_pdf_file_description: 'Envie um documento PDF para criar um modelo de formulário de assinatura.' upload_a_pdf_file_description: 'Envie um documento PDF para criar um modelo de formulário de assinatura.'
select_a_signer_party: 'Selecionar parte assinante' select_a_signer_party: 'Selecionar parte assinante'
@@ -4057,8 +4161,27 @@ pt: &pt
scopes: scopes:
write: Atualizar seus dados write: Atualizar seus dados
read: Ler seus dados read: Ler seus dados
pagination:
submissions:
range_with_total: "%{from}-%{to} de %{count} submissões"
range_without_total: "%{from}-%{to} submissões"
templates:
range_with_total: "%{from}-%{to} de %{count} modelos"
range_without_total: "%{from}-%{to} modelos"
template_folders:
range_with_total: "%{from}-%{to} de %{count} pastas"
range_without_total: "%{from}-%{to} pastas"
users:
range_with_total: "%{from}-%{to} de %{count} usuários"
range_without_total: "%{from}-%{to} usuários"
items:
range_with_total: "%{from}-%{to} de %{count} itens"
range_without_total: "%{from}-%{to} itens"
de: &de de: &de
stripe_integration: Stripe-Integration
stripe_account_has_been_connected: Stripe-Konto wurde verbunden.
re_connect_stripe: Stripe erneut verbinden
private: Privat private: Privat
resend_pending: Ausstehende erneut senden resend_pending: Ausstehende erneut senden
ensure_unique_recipients: Stellen Sie einzigartige Empfänger sicher ensure_unique_recipients: Stellen Sie einzigartige Empfänger sicher
@@ -4164,9 +4287,9 @@ de: &de
sending: Senden sending: Senden
resubmit: Erneut einreichen resubmit: Erneut einreichen
or: oder or: oder
download_documents: Dokumente herunterladen download_documents: Dokumente downloaden
downloading: Lädt herunter downloading: Lädt herunter
download: Laden download: Download
decline: Ablehnen decline: Ablehnen
declined: Abgelehnt declined: Abgelehnt
decline_reason: Ablehnungsgrund decline_reason: Ablehnungsgrund
@@ -4781,6 +4904,11 @@ de: &de
two_months: 2 Monate two_months: 2 Monate
three_months: 3 Monate three_months: 3 Monate
eu_data_residency: EU-Datenspeicher eu_data_residency: EU-Datenspeicher
please_enter_your_email_address_associated_with_the_completed_submission: Bitte gib deine E-Mail-Adresse ein, die mit der abgeschlossenen Übermittlung verknüpft ist.
esignature_disclosure: Nutzung der E-Signatur
share_link: Freigabelink
enable_shared_link: 'Freigabelink aktivieren'
share_link_is_currently_disabled: 'Freigabelink ist derzeit deaktiviert'
submission_sources: submission_sources:
api: API api: API
bulk: Massenversand bulk: Massenversand
@@ -4865,6 +4993,22 @@ de: &de
scopes: scopes:
write: Aktualisiere deine Daten write: Aktualisiere deine Daten
read: Lese deine Daten read: Lese deine Daten
pagination:
submissions:
range_with_total: "%{from}-%{to} von %{count} Einreichungen"
range_without_total: "%{from}-%{to} Einreichungen"
templates:
range_with_total: "%{from}-%{to} von %{count} Vorlagen"
range_without_total: "%{from}-%{to} Vorlagen"
template_folders:
range_with_total: "%{from}-%{to} von %{count} Ordnern"
range_without_total: "%{from}-%{to} Ordner"
users:
range_with_total: "%{from}-%{to} von %{count} Benutzern"
range_without_total: "%{from}-%{to} Benutzer"
items:
range_with_total: "%{from}-%{to} von %{count} Elementen"
range_without_total: "%{from}-%{to} Elemente"
pl: pl:
require_phone_2fa_to_open: Wymagaj uwierzytelniania telefonicznego 2FA do otwarcia require_phone_2fa_to_open: Wymagaj uwierzytelniania telefonicznego 2FA do otwarcia
@@ -4933,6 +5077,9 @@ pl:
count_documents_signed_with_html: '<b>%{count}</b> dokumentów podpisanych za pomocą' count_documents_signed_with_html: '<b>%{count}</b> dokumentów podpisanych za pomocą'
open_source_documents_software: 'oprogramowanie do dokumentów open source' open_source_documents_software: 'oprogramowanie do dokumentów open source'
eu_data_residency: Dane w UE eu_data_residency: Dane w UE
please_enter_your_email_address_associated_with_the_completed_submission: Wprowadź adres e-mail powiązany z ukończonym zgłoszeniem.
privacy_policy: Polityka Prywatności
esignature_disclosure: Użycie e-podpisu
uk: uk:
require_phone_2fa_to_open: Вимагати двофакторну автентифікацію через телефон для відкриття require_phone_2fa_to_open: Вимагати двофакторну автентифікацію через телефон для відкриття
@@ -5001,6 +5148,9 @@ uk:
count_documents_signed_with_html: '<b>%{count}</b> документів підписано за допомогою' count_documents_signed_with_html: '<b>%{count}</b> документів підписано за допомогою'
open_source_documents_software: 'відкрите програмне забезпечення для документів' open_source_documents_software: 'відкрите програмне забезпечення для документів'
eu_data_residency: 'Зберігання даних в ЄС' eu_data_residency: 'Зберігання даних в ЄС'
please_enter_your_email_address_associated_with_the_completed_submission: "Введіть адресу електронної пошти, пов'язану із завершеним поданням."
privacy_policy: Політика конфіденційності
esignature_disclosure: Використання e-підпису
cs: cs:
require_phone_2fa_to_open: Vyžadovat otevření pomocí telefonního 2FA require_phone_2fa_to_open: Vyžadovat otevření pomocí telefonního 2FA
@@ -5069,6 +5219,9 @@ cs:
count_documents_signed_with_html: '<b>%{count}</b> dokumentů podepsáno pomocí' count_documents_signed_with_html: '<b>%{count}</b> dokumentů podepsáno pomocí'
open_source_documents_software: 'open source software pro dokumenty' open_source_documents_software: 'open source software pro dokumenty'
eu_data_residency: 'Uložení dat v EU' eu_data_residency: 'Uložení dat v EU'
please_enter_your_email_address_associated_with_the_completed_submission: Zadejte e-mailovou adresu spojenou s dokončeným odesláním.
privacy_policy: Zásady Ochrany Osobních Údajů
esignature_disclosure: Použití e-podpisu
he: he:
require_phone_2fa_to_open: דרוש אימות דו-שלבי באמצעות טלפון לפתיחה require_phone_2fa_to_open: דרוש אימות דו-שלבי באמצעות טלפון לפתיחה
@@ -5137,6 +5290,9 @@ he:
count_documents_signed_with_html: '<b>%{count}</b> מסמכים נחתמו באמצעות' count_documents_signed_with_html: '<b>%{count}</b> מסמכים נחתמו באמצעות'
open_source_documents_software: 'תוכנה בקוד פתוח למסמכים' open_source_documents_software: 'תוכנה בקוד פתוח למסמכים'
eu_data_residency: 'נתונים באיחוד האירופי ' eu_data_residency: 'נתונים באיחוד האירופי '
please_enter_your_email_address_associated_with_the_completed_submission: 'אנא הזן את כתובת הדוא"ל המשויכת למשלוח שהושלם.'
privacy_policy: 'מדיניות פרטיות'
esignature_disclosure: 'גילוי חתימה אלקטרונית'
nl: nl:
require_phone_2fa_to_open: Vereis telefoon 2FA om te openen require_phone_2fa_to_open: Vereis telefoon 2FA om te openen
@@ -5205,6 +5361,9 @@ nl:
count_documents_signed_with_html: '<b>%{count}</b> documenten ondertekend met' count_documents_signed_with_html: '<b>%{count}</b> documenten ondertekend met'
open_source_documents_software: 'open source documenten software' open_source_documents_software: 'open source documenten software'
eu_data_residency: Gegevens EU eu_data_residency: Gegevens EU
please_enter_your_email_address_associated_with_the_completed_submission: Voer het e-mailadres in dat is gekoppeld aan de voltooide indiening.
privacy_policy: Privacybeleid
esignature_disclosure: Gebruik van e-handtekening
ar: ar:
require_phone_2fa_to_open: "تطلب فتح عبر تحقق الهاتف ذو العاملين" require_phone_2fa_to_open: "تطلب فتح عبر تحقق الهاتف ذو العاملين"
@@ -5273,6 +5432,9 @@ ar:
count_documents_signed_with_html: '<b>%{count}</b> مستندات تم توقيعها باستخدام' count_documents_signed_with_html: '<b>%{count}</b> مستندات تم توقيعها باستخدام'
open_source_documents_software: 'برنامج مستندات مفتوح المصدر' open_source_documents_software: 'برنامج مستندات مفتوح المصدر'
eu_data_residency: 'بيانات في الاتحاد الأوروبي' eu_data_residency: 'بيانات في الاتحاد الأوروبي'
please_enter_your_email_address_associated_with_the_completed_submission: 'يرجى إدخال عنوان البريد الإلكتروني المرتبط بالإرسال المكتمل.'
privacy_policy: 'سياسة الخصوصية'
esignature_disclosure: 'إفصاح التوقيع الإلكتروني'
ko: ko:
require_phone_2fa_to_open: 휴대폰 2FA를 열 때 요구함 require_phone_2fa_to_open: 휴대폰 2FA를 열 때 요구함
@@ -5341,6 +5503,9 @@ ko:
count_documents_signed_with_html: '<b>%{count}</b>개의 문서가 다음을 통해 서명됨' count_documents_signed_with_html: '<b>%{count}</b>개의 문서가 다음을 통해 서명됨'
open_source_documents_software: '오픈소스 문서 소프트웨어' open_source_documents_software: '오픈소스 문서 소프트웨어'
eu_data_residency: 'EU 데이터 보관' eu_data_residency: 'EU 데이터 보관'
please_enter_your_email_address_associated_with_the_completed_submission: '완료된 제출과 연결된 이메일 주소를 입력하세요.'
privacy_policy: 개인정보 처리방침
esignature_disclosure: 전자서명 공개
ja: ja:
require_phone_2fa_to_open: 電話による2段階認証が必要です require_phone_2fa_to_open: 電話による2段階認証が必要です
@@ -5409,6 +5574,9 @@ ja:
count_documents_signed_with_html: '<b>%{count}</b> 件のドキュメントが以下で署名されました' count_documents_signed_with_html: '<b>%{count}</b> 件のドキュメントが以下で署名されました'
open_source_documents_software: 'オープンソースのドキュメントソフトウェア' open_source_documents_software: 'オープンソースのドキュメントソフトウェア'
eu_data_residency: 'EU データ居住' eu_data_residency: 'EU データ居住'
please_enter_your_email_address_associated_with_the_completed_submission: '完了した提出に関連付けられたメールアドレスを入力してください。'
privacy_policy: プライバシーポリシー
esignature_disclosure: 電子署名に関する開示
en-US: en-US:
<<: *en <<: *en
+4 -1
View File
@@ -57,7 +57,7 @@ Rails.application.routes.draw do
resources :verify_pdf_signature, only: %i[create] resources :verify_pdf_signature, only: %i[create]
resource :mfa_setup, only: %i[show new edit create destroy], controller: 'mfa_setup' resource :mfa_setup, only: %i[show new edit create destroy], controller: 'mfa_setup'
resources :account_configs, only: %i[create] resources :account_configs, only: %i[create destroy]
resources :user_configs, only: %i[create] resources :user_configs, only: %i[create]
resources :encrypted_user_configs, only: %i[destroy] resources :encrypted_user_configs, only: %i[destroy]
resources :timestamp_server, only: %i[create] resources :timestamp_server, only: %i[create]
@@ -106,6 +106,7 @@ Rails.application.routes.draw do
resource :form, only: %i[show], controller: 'templates_form_preview' resource :form, only: %i[show], controller: 'templates_form_preview'
resource :code_modal, only: %i[show], controller: 'templates_code_modal' resource :code_modal, only: %i[show], controller: 'templates_code_modal'
resource :preferences, only: %i[show create], controller: 'templates_preferences' resource :preferences, only: %i[show create], controller: 'templates_preferences'
resource :share_link, only: %i[show create], controller: 'templates_share_link'
resources :recipients, only: %i[create], controller: 'templates_recipients' resources :recipients, only: %i[create], controller: 'templates_recipients'
resources :submissions_export, only: %i[index new] resources :submissions_export, only: %i[index new]
end end
@@ -131,6 +132,8 @@ Rails.application.routes.draw do
get :completed get :completed
end end
resource :resubmit_form, controller: 'start_form', only: :update
resources :submit_form, only: %i[], path: '' do resources :submit_form, only: %i[], path: '' do
get :success, on: :collection get :success, on: :collection
end end
@@ -0,0 +1,22 @@
# frozen_string_literal: true
class AddSharedLinkToTemplates < ActiveRecord::Migration[8.0]
disable_ddl_transaction
class MigrationTemplate < ActiveRecord::Base
self.table_name = 'templates'
end
def up
add_column :templates, :shared_link, :boolean, if_not_exists: true
MigrationTemplate.where(shared_link: nil).in_batches.update_all(shared_link: true)
change_column_default :templates, :shared_link, from: nil, to: false
change_column_null :templates, :shared_link, false
end
def down
remove_column :templates, :shared_link
end
end
@@ -0,0 +1,8 @@
# frozen_string_literal: true
class AddEmailEventsDateIndex < ActiveRecord::Migration[8.0]
def change
remove_index :email_events, :account_id
add_index :email_events, %i[account_id event_datetime]
end
end
+3 -2
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_05_18_070555) do ActiveRecord::Schema[8.0].define(version: 2025_05_30_080846) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@@ -174,7 +174,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_18_070555) do
t.text "data", null: false t.text "data", null: false
t.datetime "event_datetime", null: false t.datetime "event_datetime", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.index ["account_id"], name: "index_email_events_on_account_id" t.index ["account_id", "event_datetime"], name: "index_email_events_on_account_id_and_event_datetime"
t.index ["email"], name: "index_email_events_on_email" t.index ["email"], name: "index_email_events_on_email"
t.index ["emailable_type", "emailable_id"], name: "index_email_events_on_emailable" t.index ["emailable_type", "emailable_id"], name: "index_email_events_on_emailable"
t.index ["message_id"], name: "index_email_events_on_message_id" t.index ["message_id"], name: "index_email_events_on_message_id"
@@ -361,6 +361,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_18_070555) do
t.bigint "folder_id", null: false t.bigint "folder_id", null: false
t.string "external_id" t.string "external_id"
t.text "preferences", null: false t.text "preferences", null: false
t.boolean "shared_link", default: false, null: false
t.index ["account_id"], name: "index_templates_on_account_id" t.index ["account_id"], name: "index_templates_on_account_id"
t.index ["author_id"], name: "index_templates_on_author_id" t.index ["author_id"], name: "index_templates_on_author_id"
t.index ["external_id"], name: "index_templates_on_external_id" t.index ["external_id"], name: "index_templates_on_external_id"
+187
View File
@@ -0,0 +1,187 @@
# frozen_string_literal: true
module LoadBmp
module_function
# rubocop:disable Metrics
def call(bmp_bytes)
bmp_bytes = bmp_bytes.b
header_data = parse_bmp_headers(bmp_bytes)
raw_pixel_data_from_file = extract_raw_pixel_data_blob(
bmp_bytes,
header_data[:pixel_data_offset],
header_data[:bmp_stride],
header_data[:height]
)
final_pixel_data = prepare_unpadded_pixel_data_string(
raw_pixel_data_from_file,
header_data[:bpp],
header_data[:width],
header_data[:height],
header_data[:bmp_stride]
)
bands = header_data[:bpp] / 8
unless header_data[:bpp] == 24 || header_data[:bpp] == 32
raise ArgumentError, "Conversion for #{header_data[:bpp]}-bpp BMP not implemented."
end
image = Vips::Image.new_from_memory(final_pixel_data, header_data[:width], header_data[:height], bands, :uchar)
image = image.flip(:vertical) if header_data[:orientation] == -1
image_rgb =
if bands == 3
image.recomb(band3_recomb)
elsif bands == 4
image.recomb(band4_recomb)
end
image_rgb = image_rgb.copy(interpretation: :srgb) if image_rgb.interpretation != :srgb
image_rgb
end
def parse_bmp_headers(bmp_bytes)
raise ArgumentError, 'BMP data too short for file header (14 bytes).' if bmp_bytes.bytesize < 14
signature, pixel_data_offset = bmp_bytes.unpack('a2@10L<')
raise ArgumentError, "Not a valid BMP file (invalid signature 'BM')." if signature != 'BM'
raise ArgumentError, 'BMP data too short for info header size field (4 bytes).' if bmp_bytes.bytesize < (14 + 4)
info_header_size = bmp_bytes.unpack1('@14L<')
min_expected_info_header_size = 40
if info_header_size < min_expected_info_header_size
raise ArgumentError,
"Unsupported BMP info header size: #{info_header_size}. Expected at least #{min_expected_info_header_size}."
end
header_and_info_header_min_bytes = 14 + min_expected_info_header_size
if bmp_bytes.bytesize < header_and_info_header_min_bytes
raise ArgumentError,
'BMP data too short for essential BITMAPINFOHEADER fields ' \
"(requires #{header_and_info_header_min_bytes} bytes total)."
end
_header_size_check, width, raw_height_from_header, planes, bpp, compression =
bmp_bytes.unpack('@14L<l<l<S<S<L<')
height = 0
orientation = -1
if raw_height_from_header.negative?
height = -raw_height_from_header
orientation = 1
else
height = raw_height_from_header
end
raise ArgumentError, 'BMP width must be positive.' if width <= 0
raise ArgumentError, 'BMP height must be positive.' if height <= 0
if compression != 0
raise ArgumentError,
"Unsupported BMP compression type: #{compression}. Only uncompressed (0) is supported."
end
unless [24, 32].include?(bpp)
raise ArgumentError, "Unsupported BMP bits per pixel: #{bpp}. Only 24-bit and 32-bit are supported."
end
raise ArgumentError, "Unsupported BMP planes: #{planes}. Expected 1." if planes != 1
bytes_per_pixel = bpp / 8
row_size_unpadded = width * bytes_per_pixel
bmp_stride = (row_size_unpadded + 3) & ~3
{
width:,
height:,
bpp:,
pixel_data_offset:,
bmp_stride:,
orientation:
}
end
def extract_raw_pixel_data_blob(bmp_bytes, pixel_data_offset, bmp_stride, height)
expected_pixel_data_size = bmp_stride * height
if pixel_data_offset + expected_pixel_data_size > bmp_bytes.bytesize
actual_available = bmp_bytes.bytesize - pixel_data_offset
actual_available = 0 if actual_available.negative?
raise ArgumentError,
"Pixel data segment (offset #{pixel_data_offset}, expected size #{expected_pixel_data_size}) " \
"exceeds BMP file size (#{bmp_bytes.bytesize}). " \
"Only #{actual_available} bytes available after offset."
end
raw_pixel_data_from_file = bmp_bytes.byteslice(pixel_data_offset, expected_pixel_data_size)
if raw_pixel_data_from_file.nil? || raw_pixel_data_from_file.bytesize < expected_pixel_data_size
raise ArgumentError,
"Extracted pixel data is smaller (#{raw_pixel_data_from_file&.bytesize || 0} bytes) " \
"than expected (#{expected_pixel_data_size} bytes based on stride and height)."
end
raw_pixel_data_from_file
end
def prepare_unpadded_pixel_data_string(raw_pixel_data_from_file, bpp, width, height, bmp_stride)
bytes_per_pixel = bpp / 8
actual_row_width_bytes = width * bytes_per_pixel
unpadded_rows = Array.new(height)
current_offset_in_blob = 0
height.times do |i|
if current_offset_in_blob + actual_row_width_bytes > raw_pixel_data_from_file.bytesize
raise ArgumentError,
"Not enough data in pixel blob for row #{i}. Offset #{current_offset_in_blob}, " \
"row width #{actual_row_width_bytes}, blob size #{raw_pixel_data_from_file.bytesize}"
end
unpadded_row_slice = raw_pixel_data_from_file.byteslice(current_offset_in_blob, actual_row_width_bytes)
if unpadded_row_slice.nil? || unpadded_row_slice.bytesize < actual_row_width_bytes
raise ArgumentError, "Failed to slice a full unpadded row from pixel data blob for row #{i}."
end
unpadded_rows[i] = unpadded_row_slice
current_offset_in_blob += bmp_stride
end
unpadded_rows.join
end
def band3_recomb
@band3_recomb ||=
Vips::Image.new_from_array(
[
[0, 0, 1],
[0, 1, 0],
[1, 0, 0]
]
)
end
def band4_recomb
@band4_recomb ||= Vips::Image.new_from_array(
[
[0, 0, 1, 0],
[0, 1, 0, 0],
[1, 0, 0, 0]
]
)
end
# rubocop:enable Metrics
end
+209
View File
@@ -0,0 +1,209 @@
# frozen_string_literal: true
module LoadIco
BI_RGB = 0
module_function
# rubocop:disable Metrics
def call(ico_bytes)
io = StringIO.new(ico_bytes)
_reserved, type, count = io.read(6)&.unpack('S<S<S<')
raise ArgumentError, 'Unable to load' unless type == 1 && count&.positive?
ico_entries_parsed = []
count.times do
entry_bytes = io.read(16)
raise ArgumentError, 'Unable to load' unless entry_bytes && entry_bytes.bytesize == 16
width_byte, height_byte, _num_colors_palette, _rsvd_entry, _planes_icon_entry, bpp_icon_entry,
img_data_size, img_data_offset = entry_bytes.unpack('CCCCS<S<L<L<')
width = width_byte.zero? ? 256 : width_byte
height = height_byte.zero? ? 256 : height_byte
sort_bpp = bpp_icon_entry.zero? ? 32 : bpp_icon_entry
ico_entries_parsed << {
width: width, height: height,
sort_bpp: sort_bpp,
size: img_data_size, offset: img_data_offset
}
end
best_entry = ico_entries_parsed.min_by { |e| [-e[:width] * e[:height], -e[:sort_bpp]] }
raise ArgumentError, 'Unable to load' unless best_entry
io.seek(best_entry[:offset])
image_data_bytes = io.read(best_entry[:size])
raise ArgumentError, 'Unable to load' unless image_data_bytes && image_data_bytes.bytesize == best_entry[:size]
image = load_image_entry(image_data_bytes, best_entry[:width], best_entry[:height])
raise ArgumentError, 'Unable to load' unless image
image
end
def load_image_entry(image_data_bytes, ico_entry_width, ico_entry_height)
dib_io = StringIO.new(image_data_bytes)
dib_header_size_arr = dib_io.read(4)&.unpack('L<')
return nil unless dib_header_size_arr
dib_header_size = dib_header_size_arr.first
return nil unless dib_header_size && dib_header_size >= 40
dib_params_bytes = dib_io.read(36)
return nil unless dib_params_bytes && dib_params_bytes.bytesize == 36
dib_width, dib_actual_height_field, dib_planes, dib_bpp,
dib_compression, _dib_image_size, _xpels, _ypels,
dib_clr_used, _dib_clr_important = dib_params_bytes.unpack('l<l<S<S<L<L<l<l<L<L<')
return nil unless dib_width && dib_actual_height_field && dib_planes && dib_bpp && dib_compression && dib_clr_used
return nil unless dib_width == ico_entry_width
image_pixel_height = ico_entry_height
expected_dib_height_no_mask = image_pixel_height
expected_dib_height_with_mask = image_pixel_height * 2
actual_dib_pixel_rows_abs = dib_actual_height_field.abs
unless actual_dib_pixel_rows_abs == expected_dib_height_no_mask ||
actual_dib_pixel_rows_abs == expected_dib_height_with_mask
return nil
end
return nil unless dib_planes == 1
return nil unless dib_compression == BI_RGB
return nil unless [1, 4, 8, 24, 32].include?(dib_bpp)
has_and_mask = (actual_dib_pixel_rows_abs == expected_dib_height_with_mask) && (dib_bpp < 32)
dib_io.seek(dib_header_size, IO::SEEK_SET)
palette = []
if dib_bpp <= 8
num_palette_entries = dib_clr_used.zero? ? (1 << dib_bpp) : dib_clr_used
num_palette_entries.times do
palette_color_bytes = dib_io.read(4)
return nil unless palette_color_bytes && palette_color_bytes.bytesize == 4
b, g, r, _a_reserved = palette_color_bytes.unpack('CCCC')
palette << [r, g, b, 255]
end
end
xor_mask_data_offset = dib_io.pos
xor_scanline_stride = (((dib_width * dib_bpp) + 31) / 32) * 4
and_mask_data_offset = 0
and_scanline_stride = 0
if has_and_mask
and_mask_data_offset = xor_mask_data_offset + (image_pixel_height * xor_scanline_stride)
and_scanline_stride = (((dib_width * 1) + 31) / 32) * 4
end
flat_rgba_pixels = []
(0...image_pixel_height).each do |y_row|
y_dib_row = image_pixel_height - 1 - y_row
dib_io.seek(xor_mask_data_offset + (y_dib_row * xor_scanline_stride))
xor_scanline_bytes = dib_io.read(xor_scanline_stride)
min_xor_bytes_needed = ((dib_width * dib_bpp) + 7) / 8
return nil unless xor_scanline_bytes && xor_scanline_bytes.bytesize >= min_xor_bytes_needed
and_mask_bits_for_row = []
if has_and_mask
dib_io.seek(and_mask_data_offset + (y_dib_row * and_scanline_stride))
and_mask_scanline_bytes = dib_io.read(and_scanline_stride)
min_and_bytes_needed = ((dib_width * 1) + 7) / 8
return nil unless and_mask_scanline_bytes && and_mask_scanline_bytes.bytesize >= min_and_bytes_needed
(0...dib_width).each do |x_pixel|
byte_index = x_pixel / 8
bit_index_in_byte = 7 - (x_pixel % 8)
byte_val = and_mask_scanline_bytes.getbyte(byte_index)
and_mask_bits_for_row << ((byte_val >> bit_index_in_byte) & 1)
end
end
(0...dib_width).each do |x_pixel|
r = 0
g = 0
b = 0
a = 255
case dib_bpp
when 32
offset = x_pixel * 4
blue = xor_scanline_bytes.getbyte(offset)
green = xor_scanline_bytes.getbyte(offset + 1)
red = xor_scanline_bytes.getbyte(offset + 2)
alpha_val = xor_scanline_bytes.getbyte(offset + 3)
r = red
g = green
b = blue
a = alpha_val
when 24
offset = x_pixel * 3
blue = xor_scanline_bytes.getbyte(offset)
green = xor_scanline_bytes.getbyte(offset + 1)
red = xor_scanline_bytes.getbyte(offset + 2)
r = red
g = green
b = blue
when 8
idx = xor_scanline_bytes.getbyte(x_pixel)
r_p, g_p, b_p, a_p = palette[idx] || [0, 0, 0, 0]
r = r_p
g = g_p
b = b_p
a = a_p
when 4
byte_val = xor_scanline_bytes.getbyte(x_pixel / 2)
idx = (x_pixel.even? ? (byte_val >> 4) : (byte_val & 0x0F))
r_p, g_p, b_p, a_p = palette[idx] || [0, 0, 0, 0]
r = r_p
g = g_p
b = b_p
a = a_p
when 1
byte_val = xor_scanline_bytes.getbyte(x_pixel / 8)
idx = (byte_val >> (7 - (x_pixel % 8))) & 1
r_p, g_p, b_p, a_p = palette[idx] || [0, 0, 0, 0]
r = r_p
g = g_p
b = b_p
a = a_p
end
if has_and_mask && !and_mask_bits_for_row.empty?
a = and_mask_bits_for_row[x_pixel] == 1 ? 0 : 255
end
flat_rgba_pixels.push(r, g, b, a)
end
end
pixel_data_string = flat_rgba_pixels.pack('C*')
expected_bytes = dib_width * image_pixel_height * 4
return nil unless pixel_data_string.bytesize == expected_bytes && expected_bytes.positive?
Vips::Image.new_from_memory(
pixel_data_string,
dib_width,
image_pixel_height,
4,
:uchar
)
end
# rubocop:enable Metrics
end
+412
View File
@@ -0,0 +1,412 @@
# frozen_string_literal: true
class Pdfium
extend FFI::Library
LIB_NAME = 'pdfium'
begin
ffi_lib case FFI::Platform::OS
when 'darwin'
[
"lib#{LIB_NAME}.dylib",
'/Applications/LibreOffice.app/Contents/Frameworks/libpdfiumlo.dylib'
]
else
"lib#{LIB_NAME}.so"
end
rescue LoadError => e
raise "Could not load libpdfium library. Make sure it's installed and in your library path. Error: #{e.message}"
end
typedef :pointer, :FPDF_STRING
typedef :pointer, :FPDF_DOCUMENT
typedef :pointer, :FPDF_PAGE
typedef :pointer, :FPDF_BITMAP
typedef :pointer, :FPDF_FORMHANDLE
MAX_SIZE = 32_767
FPDF_ANNOT = 0x01
FPDF_LCD_TEXT = 0x02
FPDF_NO_NATIVETEXT = 0x04
FPDF_GRAYSCALE = 0x08
FPDF_REVERSE_BYTE_ORDER = 0x10
FPDF_RENDER_LIMITEDIMAGECACHE = 0x200
FPDF_RENDER_FORCEHALFTONE = 0x400
FPDF_PRINTING = 0x800
# rubocop:disable Naming/ClassAndModuleCamelCase
class FPDF_LIBRARY_CONFIG < FFI::Struct
layout :version, :int,
:m_pUserFontPaths, :pointer,
:m_pIsolate, :pointer,
:m_v8EmbedderSlot, :uint,
:m_pPlatform, :pointer,
:m_RendererType, :int
end
# rubocop:enable Naming/ClassAndModuleCamelCase
attach_function :FPDF_InitLibraryWithConfig, [:pointer], :void
attach_function :FPDF_DestroyLibrary, [], :void
attach_function :FPDF_LoadDocument, %i[string FPDF_STRING], :FPDF_DOCUMENT
attach_function :FPDF_LoadMemDocument, %i[pointer int FPDF_STRING], :FPDF_DOCUMENT
attach_function :FPDF_CloseDocument, [:FPDF_DOCUMENT], :void
attach_function :FPDF_GetPageCount, [:FPDF_DOCUMENT], :int
attach_function :FPDF_GetLastError, [], :ulong
attach_function :FPDF_LoadPage, %i[FPDF_DOCUMENT int], :FPDF_PAGE
attach_function :FPDF_ClosePage, [:FPDF_PAGE], :void
attach_function :FPDF_GetPageWidthF, [:FPDF_PAGE], :float
attach_function :FPDF_GetPageHeightF, [:FPDF_PAGE], :float
attach_function :FPDFBitmap_Create, %i[int int int], :FPDF_BITMAP
attach_function :FPDFBitmap_CreateEx, %i[int int int pointer int], :FPDF_BITMAP
attach_function :FPDFBitmap_Destroy, [:FPDF_BITMAP], :void
attach_function :FPDFBitmap_GetBuffer, [:FPDF_BITMAP], :pointer
attach_function :FPDFBitmap_GetWidth, [:FPDF_BITMAP], :int
attach_function :FPDFBitmap_GetHeight, [:FPDF_BITMAP], :int
attach_function :FPDFBitmap_GetStride, [:FPDF_BITMAP], :int
attach_function :FPDFBitmap_FillRect, %i[FPDF_BITMAP int int int int ulong], :void
attach_function :FPDF_RenderPageBitmap, %i[FPDF_BITMAP FPDF_PAGE int int int int int int], :void
typedef :int, :FPDF_BOOL
typedef :pointer, :IPDF_JSPLATFORM
# rubocop:disable Naming/ClassAndModuleCamelCase
class FPDF_FORMFILLINFO_V2 < FFI::Struct
layout :version, :int,
:Release, :pointer,
:FFI_Invalidate, :pointer,
:FFI_OutputSelectedRect, :pointer,
:FFI_SetCursor, :pointer,
:FFI_SetTimer, :pointer,
:FFI_KillTimer, :pointer,
:FFI_GetLocalTime, :pointer,
:FFI_OnChange, :pointer,
:FFI_GetPage, :pointer,
:FFI_GetCurrentPage, :pointer,
:FFI_GetRotation, :pointer,
:FFI_ExecuteNamedAction, :pointer,
:FFI_SetTextFieldFocus, :pointer,
:FFI_DoURIAction, :pointer,
:FFI_DoGoToAction, :pointer,
:m_pJsPlatform, :IPDF_JSPLATFORM,
:xfa_disabled, :FPDF_BOOL,
:FFI_DisplayCaret, :pointer,
:FFI_GetCurrentPageIndex, :pointer,
:FFI_SetCurrentPage, :pointer,
:FFI_GotoURL, :pointer,
:FFI_GetPageViewRect, :pointer,
:FFI_PageEvent, :pointer,
:FFI_PopupMenu, :pointer,
:FFI_OpenFile, :pointer,
:FFI_EmailTo, :pointer,
:FFI_UploadTo, :pointer,
:FFI_GetPlatform, :pointer,
:FFI_GetLanguage, :pointer,
:FFI_DownloadFromURL, :pointer,
:FFI_PostRequestURL, :pointer,
:FFI_PutRequestURL, :pointer,
:FFI_OnFocusChange, :pointer,
:FFI_DoURIActionWithKeyboardModifier, :pointer
end
# rubocop:enable Naming/ClassAndModuleCamelCase
attach_function :FPDFDOC_InitFormFillEnvironment, %i[FPDF_DOCUMENT pointer], :FPDF_FORMHANDLE
attach_function :FPDFDOC_ExitFormFillEnvironment, [:FPDF_FORMHANDLE], :void
attach_function :FPDF_FFLDraw, %i[FPDF_FORMHANDLE FPDF_BITMAP FPDF_PAGE int int int int int int], :void
FPDF_ERR_SUCCESS = 0
FPDF_ERR_UNKNOWN = 1
FPDF_ERR_FILE = 2
FPDF_ERR_FORMAT = 3
FPDF_ERR_PASSWORD = 4
FPDF_ERR_SECURITY = 5
FPDF_ERR_PAGE = 6
PDFIUM_ERRORS = {
FPDF_ERR_SUCCESS => 'Success',
FPDF_ERR_UNKNOWN => 'Unknown error',
FPDF_ERR_FILE => 'Error open file',
FPDF_ERR_FORMAT => 'Invalid format',
FPDF_ERR_PASSWORD => 'Incorrect password',
FPDF_ERR_SECURITY => 'Security scheme error',
FPDF_ERR_PAGE => 'Page not found'
}.freeze
class PdfiumError < StandardError; end
def self.error_message(code)
PDFIUM_ERRORS[code] || "Unknown error code: #{code}"
end
def self.check_last_error(context_message = 'PDFium operation failed')
error_code = FPDF_GetLastError()
return if error_code == FPDF_ERR_SUCCESS
raise PdfiumError, "#{context_message}: #{error_message(error_code)} (Code: #{error_code})"
end
class Document
attr_reader :document_ptr, :form_handle
def initialize(document_ptr, source_buffer = nil)
raise ArgumentError, 'document_ptr cannot be nil' if document_ptr.nil? || document_ptr.null?
@document_ptr = document_ptr
@pages = {}
@closed = false
@source_buffer = source_buffer
@form_handle = FFI::Pointer::NULL
@form_fill_info_mem = FFI::Pointer::NULL
init_form_fill_environment
end
def init_form_fill_environment
return if @document_ptr.null?
@form_fill_info_mem = FFI::MemoryPointer.new(FPDF_FORMFILLINFO_V2.size)
form_fill_info_struct = FPDF_FORMFILLINFO_V2.new(@form_fill_info_mem)
form_fill_info_struct[:version] = 2
@form_handle = Pdfium.FPDFDOC_InitFormFillEnvironment(@document_ptr, @form_fill_info_mem)
end
def page_count
@page_count ||= Pdfium.FPDF_GetPageCount(@document_ptr)
end
def self.open_file(file_path, password = nil)
doc_ptr = Pdfium.FPDF_LoadDocument(file_path, password)
if doc_ptr.null?
Pdfium.check_last_error("Failed to load document from file '#{file_path}'")
raise PdfiumError, "Failed to load document from file '#{file_path}', pointer is NULL."
end
doc = new(doc_ptr)
return doc unless block_given?
begin
yield doc
ensure
doc.close
end
end
def self.open_bytes(bytes, password = nil)
buffer = FFI::MemoryPointer.new(:char, bytes.bytesize)
buffer.put_bytes(0, bytes)
doc_ptr = Pdfium.FPDF_LoadMemDocument(buffer, bytes.bytesize, password)
if doc_ptr.null?
Pdfium.check_last_error('Failed to load document from memory')
raise PdfiumError, 'Failed to load document from memory, pointer is NULL.'
end
doc = new(doc_ptr, buffer)
return doc unless block_given?
begin
yield doc
ensure
doc.close
end
end
def closed?
@closed
end
def ensure_not_closed!
raise PdfiumError, 'Document is closed.' if closed?
end
def get_page(page_index)
ensure_not_closed!
unless page_index.is_a?(Integer) && page_index >= 0 && page_index < page_count
raise PdfiumError, "Page index #{page_index} out of range (0..#{page_count - 1})"
end
@pages[page_index] ||= Page.new(self, page_index)
end
def close
return if closed?
@pages.each_value { |page| page.close unless page.closed? }
@pages.clear
unless @form_handle.null?
Pdfium.FPDFDOC_ExitFormFillEnvironment(@form_handle)
@form_handle = FFI::Pointer::NULL
end
if @form_fill_info_mem && !@form_fill_info_mem.null?
@form_fill_info_mem.free
@form_fill_info_mem = FFI::Pointer::NULL
end
Pdfium.FPDF_CloseDocument(@document_ptr) unless @document_ptr.null?
@document_ptr = FFI::Pointer::NULL
@source_buffer = nil
@closed = true
end
end
class Page
attr_reader :document, :page_index, :page_ptr
def initialize(document, page_index)
raise ArgumentError, 'Document object is required' unless document.is_a?(Pdfium::Document)
@document = document
@document.ensure_not_closed!
@page_index = page_index
@page_ptr = Pdfium.FPDF_LoadPage(document.document_ptr, page_index)
if @page_ptr.null?
Pdfium.check_last_error("Failed to load page #{page_index}")
raise PdfiumError, "Failed to load page #{page_index}, pointer is NULL."
end
@closed = false
end
def width
@width ||= Pdfium.FPDF_GetPageWidthF(@page_ptr)
end
def height
@height ||= Pdfium.FPDF_GetPageHeightF(@page_ptr)
end
def closed?
@closed
end
def form_handle
@document.form_handle
end
def ensure_not_closed!
raise PdfiumError, 'Page is closed.' if closed?
@document.ensure_not_closed!
end
def render_to_bitmap(width: nil, height: nil, scale: nil, background_color: 0xFFFFFFFF,
flags: FPDF_ANNOT | FPDF_LCD_TEXT | FPDF_NO_NATIVETEXT | FPDF_REVERSE_BYTE_ORDER)
ensure_not_closed!
render_width, render_height = calculate_render_dimensions(width, height, scale)
bitmap_ptr = Pdfium.FPDFBitmap_Create(render_width, render_height, 1)
if bitmap_ptr.null?
Pdfium.check_last_error('Failed to create bitmap (potential pre-existing error)')
raise PdfiumError, 'Failed to create bitmap (FPDFBitmap_Create returned NULL)'
end
Pdfium.FPDFBitmap_FillRect(bitmap_ptr, 0, 0, render_width, render_height, background_color)
Pdfium.FPDF_RenderPageBitmap(bitmap_ptr, page_ptr, 0, 0, render_width, render_height, 0, flags)
Pdfium.check_last_error('Failed to render page to bitmap')
unless form_handle.null?
Pdfium.FPDF_FFLDraw(form_handle, bitmap_ptr, page_ptr, 0, 0, render_width, render_height, 0, flags)
Pdfium.check_last_error('Call to FPDF_FFLDraw completed (check for rendering issues if any)')
end
buffer_ptr = Pdfium.FPDFBitmap_GetBuffer(bitmap_ptr)
stride = Pdfium.FPDFBitmap_GetStride(bitmap_ptr)
bitmap_data = buffer_ptr.read_bytes(stride * render_height)
[bitmap_data, render_width, render_height]
ensure
Pdfium.FPDFBitmap_Destroy(bitmap_ptr) if bitmap_ptr && !bitmap_ptr.null?
end
def close
return if closed?
Pdfium.FPDF_ClosePage(@page_ptr) unless @page_ptr.null?
@page_ptr = FFI::Pointer::NULL
@closed = true
end
private
def calculate_render_dimensions(width_param, height_param, scale_param)
if scale_param
render_width = (width * scale_param).round
render_height = (height * scale_param).round
elsif width_param || height_param
if width_param && height_param
render_width = width_param
render_height = height_param
elsif width_param
scale_factor = width_param.to_f / width
render_width = width_param
render_height = (height * scale_factor).round
else
scale_factor = height_param.to_f / height
render_width = (width * scale_factor).round
render_height = height_param
end
else
render_width = width.to_i
render_height = height.to_i
end
[render_width.clamp(1, MAX_SIZE), render_height.clamp(1, MAX_SIZE)]
end
end
def self.initialize_library
config_mem = FFI::MemoryPointer.new(FPDF_LIBRARY_CONFIG.size)
config_struct = FPDF_LIBRARY_CONFIG.new(config_mem)
config_struct[:version] = 2
config_struct[:m_pUserFontPaths] = FFI::Pointer::NULL
config_struct[:m_pIsolate] = FFI::Pointer::NULL
config_struct[:m_v8EmbedderSlot] = 0
FPDF_InitLibraryWithConfig(config_mem)
end
def self.cleanup_library
FPDF_DestroyLibrary()
end
initialize_library
at_exit do
cleanup_library
end
end
+2
View File
@@ -120,6 +120,8 @@ module Submissions
email = email.to_s.tr('/', ',') email = email.to_s.tr('/', ',')
return email.downcase.sub(/@gmail?\z/i, '@gmail.com') if email.match?(/@gmail?\z/i)
return email.downcase if email.include?(',') || return email.downcase if email.include?(',') ||
email.match?(/\.(?:gob|om|mm|cm|et|mo|nz|za|ie)\z/) || email.match?(/\.(?:gob|om|mm|cm|et|mo|nz|za|ie)\z/) ||
email.exclude?('.') email.exclude?('.')
+5 -5
View File
@@ -31,7 +31,9 @@ module Submissions
def call(submission) def call(submission)
account = submission.account account = submission.account
I18n.with_locale(account.locale) do last_submitter = submission.submitters.select(&:completed_at).max_by(&:completed_at)
I18n.with_locale(last_submitter.metadata.fetch('lang', account.locale)) do
document = build_audit_trail(submission) document = build_audit_trail(submission)
pkcs = Accounts.load_signing_pkcs(account) pkcs = Accounts.load_signing_pkcs(account)
@@ -41,8 +43,6 @@ module Submissions
document.trailer.info[:Creator] = "#{Docuseal.product_name} (#{Docuseal::PRODUCT_URL})" document.trailer.info[:Creator] = "#{Docuseal.product_name} (#{Docuseal::PRODUCT_URL})"
last_submitter = submission.submitters.select(&:completed_at).max_by(&:completed_at)
if pkcs if pkcs
sign_params = { sign_params = {
reason: sign_reason, reason: sign_reason,
@@ -309,7 +309,7 @@ module Submissions
image = image =
begin begin
Vips::Image.new_from_buffer(attachment.download, '').autorot Submissions::GenerateResultAttachments.load_vips_image(attachment).autorot
rescue Vips::Error rescue Vips::Error
next unless attachment.content_type.starts_with?('image/') next unless attachment.content_type.starts_with?('image/')
next if attachment.byte_size.zero? next if attachment.byte_size.zero?
@@ -322,7 +322,7 @@ module Submissions
resized_image = image.resize([scale, 1].min) resized_image = image.resize([scale, 1].min)
io = StringIO.new(resized_image.write_to_buffer('.png')) io = StringIO.new(resized_image.write_to_buffer('.png'))
width = field['type'] == 'initials' ? 100 : 200 width = field['type'] == 'initials' ? 50 : 200
height = resized_image.height * (width.to_f / resized_image.width) height = resized_image.height * (width.to_f / resized_image.width)
if height > MAX_IMAGE_HEIGHT if height > MAX_IMAGE_HEIGHT
+33 -15
View File
@@ -11,6 +11,9 @@ module Submissions
'Helvetica' 'Helvetica'
end end
ICO_REGEXP = %r{\Aimage/(?:x-icon|vnd\.microsoft\.icon)\z}
BMP_REGEXP = %r{\Aimage/(?:bmp|x-bmp|x-ms-bmp)\z}
FONT_BOLD_NAME = if File.exist?(FONT_BOLD_PATH) FONT_BOLD_NAME = if File.exist?(FONT_BOLD_PATH)
FONT_BOLD_PATH FONT_BOLD_PATH
else else
@@ -149,7 +152,8 @@ module Submissions
TESTING_FOOTER TESTING_FOOTER
end end
else else
"#{I18n.t('document_id', locale: submitter.account.locale)}: #{document_id}" "#{I18n.t('document_id',
locale: submitter.metadata.fetch('lang', submitter.account.locale))}: #{document_id}"
end end
text = HexaPDF::Layout::TextFragment.create( text = HexaPDF::Layout::TextFragment.create(
@@ -179,6 +183,8 @@ module Submissions
with_headings = find_last_submitter(submitter.submission, submitter:).blank? if with_headings.nil? with_headings = find_last_submitter(submitter.submission, submitter:).blank? if with_headings.nil?
locale = submitter.metadata.fetch('lang', account.locale)
submitter.submission.template_fields.each do |field| submitter.submission.template_fields.each do |field|
next if field['type'] == 'heading' && !with_headings next if field['type'] == 'heading' && !with_headings
next if field['submitter_uuid'] != submitter.uuid && field['type'] != 'heading' next if field['submitter_uuid'] != submitter.uuid && field['type'] != 'heading'
@@ -250,14 +256,12 @@ module Submissions
when ->(type) { type == 'signature' && (with_signature_id || field.dig('preferences', 'reason_field_uuid')) } when ->(type) { type == 'signature' && (with_signature_id || field.dig('preferences', 'reason_field_uuid')) }
attachment = submitter.attachments.find { |a| a.uuid == value } attachment = submitter.attachments.find { |a| a.uuid == value }
attachments_data_cache[attachment.uuid] ||= attachment.download image = load_vips_image(attachment, attachments_data_cache).autorot
image = Vips::Image.new_from_buffer(attachments_data_cache[attachment.uuid], '').autorot
reason_value = submitter.values[field.dig('preferences', 'reason_field_uuid')].presence reason_value = submitter.values[field.dig('preferences', 'reason_field_uuid')].presence
reason_string = reason_string =
I18n.with_locale(submitter.account.locale) do I18n.with_locale(locale) do
"#{reason_value ? "#{I18n.t('reason')}: " : ''}#{reason_value || I18n.t('digitally_signed_by')} " \ "#{reason_value ? "#{I18n.t('reason')}: " : ''}#{reason_value || I18n.t('digitally_signed_by')} " \
"#{submitter.name}#{submitter.email.present? ? " <#{submitter.email}>" : ''}\n" \ "#{submitter.name}#{submitter.email.present? ? " <#{submitter.email}>" : ''}\n" \
"#{I18n.l(attachment.created_at.in_time_zone(submitter.account.timezone), format: :long)} " \ "#{I18n.l(attachment.created_at.in_time_zone(submitter.account.timezone), format: :long)} " \
@@ -360,11 +364,9 @@ module Submissions
when 'image', 'signature', 'initials', 'stamp' when 'image', 'signature', 'initials', 'stamp'
attachment = submitter.attachments.find { |a| a.uuid == value } attachment = submitter.attachments.find { |a| a.uuid == value }
attachments_data_cache[attachment.uuid] ||= attachment.download
image = image =
begin begin
Vips::Image.new_from_buffer(attachments_data_cache[attachment.uuid], '').autorot load_vips_image(attachment, attachments_data_cache).autorot
rescue Vips::Error rescue Vips::Error
next unless attachment.content_type.starts_with?('image/') next unless attachment.content_type.starts_with?('image/')
next if attachment.byte_size.zero? next if attachment.byte_size.zero?
@@ -443,7 +445,7 @@ module Submissions
option = field['options']&.find { |o| o['uuid'] == area['option_uuid'] } option = field['options']&.find { |o| o['uuid'] == area['option_uuid'] }
option_name = option['value'].presence option_name = option['value'].presence
option_name ||= "#{I18n.t('option', locale: account.locale)} #{field['options'].index(option) + 1}" option_name ||= "#{I18n.t('option', locale: locale)} #{field['options'].index(option) + 1}"
value = Array.wrap(value).include?(option_name) value = Array.wrap(value).include?(option_name)
end end
@@ -510,7 +512,7 @@ module Submissions
end end
else else
if field['type'] == 'date' if field['type'] == 'date'
value = TimeUtils.format_date_string(value, field.dig('preferences', 'format'), account.locale) value = TimeUtils.format_date_string(value, field.dig('preferences', 'format'), locale)
end end
value = NumberUtils.format_number(value, field.dig('preferences', 'format')) if field['type'] == 'number' value = NumberUtils.format_number(value, field.dig('preferences', 'format')) if field['type'] == 'number'
@@ -736,14 +738,16 @@ module Submissions
page = pdf.pages.add page = pdf.pages.add
scale = [A4_SIZE.first / attachment.metadata['width'].to_f, image = attachment.preview_images.first
A4_SIZE.last / attachment.metadata['height'].to_f].min
page.box.width = attachment.metadata['width'] * scale scale = [A4_SIZE.first / image.metadata['width'].to_f,
page.box.height = attachment.metadata['height'] * scale A4_SIZE.last / image.metadata['height'].to_f].min
page.box.width = image.metadata['width'] * scale
page.box.height = image.metadata['height'] * scale
page.canvas.image( page.canvas.image(
StringIO.new(attachment.preview_images.first.download), StringIO.new(image.download),
at: [0, 0], at: [0, 0],
width: page.box.width, width: page.box.width,
height: page.box.height height: page.box.height
@@ -804,6 +808,20 @@ module Submissions
[] []
end end
def load_vips_image(attachment, cache = {})
cache[attachment.uuid] ||= attachment.download
data = cache[attachment.uuid]
if ICO_REGEXP.match?(attachment.content_type)
LoadIco.call(data)
elsif BMP_REGEXP.match?(attachment.content_type)
LoadBmp.call(data)
else
Vips::Image.new_from_buffer(data, '')
end
end
def h def h
Rails.application.routes.url_helpers Rails.application.routes.url_helpers
end end
+1
View File
@@ -9,6 +9,7 @@ module Templates
template = original_template.account.templates.new template = original_template.account.templates.new
template.external_id = external_id template.external_id = external_id
template.shared_link = original_template.shared_link
template.author = author template.author = author
template.name = name.presence || "#{original_template.name} (#{I18n.t('clone')})" template.name = name.presence || "#{original_template.name} (#{I18n.t('clone')})"
+39 -33
View File
@@ -6,6 +6,7 @@ module Templates
FORMAT = '.png' FORMAT = '.png'
ATTACHMENT_NAME = 'preview_images' ATTACHMENT_NAME = 'preview_images'
BMP_REGEXP = %r{\Aimage/(?:bmp|x-bmp|x-ms-bmp)\z}
PDF_CONTENT_TYPE = 'application/pdf' PDF_CONTENT_TYPE = 'application/pdf'
CONCURRENCY = 2 CONCURRENCY = 2
Q = 95 Q = 95
@@ -38,7 +39,13 @@ module Templates
def generate_preview_image(attachment, data) def generate_preview_image(attachment, data)
ActiveStorage::Attachment.where(name: ATTACHMENT_NAME, record: attachment).destroy_all ActiveStorage::Attachment.where(name: ATTACHMENT_NAME, record: attachment).destroy_all
image = Vips::Image.new_from_buffer(data, '') image =
if BMP_REGEXP.match?(attachment.content_type)
LoadBmp.call(data)
else
Vips::Image.new_from_buffer(data, '')
end
image = image.autorot.resize(MAX_WIDTH / image.width.to_f) image = image.autorot.resize(MAX_WIDTH / image.width.to_f)
bitdepth = 2**image.stats.to_a[1..3].pluck(2).uniq.size bitdepth = 2**image.stats.to_a[1..3].pluck(2).uniq.size
@@ -77,11 +84,13 @@ module Templates
end end
def generate_document_preview_images(attachment, data, range, concurrency: CONCURRENCY) def generate_document_preview_images(attachment, data, range, concurrency: CONCURRENCY)
doc = Pdfium::Document.open_bytes(data)
pool = Concurrent::FixedThreadPool.new(concurrency) pool = Concurrent::FixedThreadPool.new(concurrency)
promises = promises =
range.map do |page_number| range.map do |page_number|
Concurrent::Promise.execute(executor: pool) { build_and_upload_blob(data, page_number) } Concurrent::Promise.execute(executor: pool) { build_and_upload_blob(doc, page_number) }
end end
Concurrent::Promise.zip(*promises).value!.each do |blob| Concurrent::Promise.zip(*promises).value!.each do |blob|
@@ -95,31 +104,44 @@ module Templates
) )
end end
end end
ensure
pool.kill doc&.close
pool&.kill
end end
def build_and_upload_blob(data, page_number) def build_and_upload_blob(doc, page_number, format = FORMAT)
page = Vips::Image.new_from_buffer(data, '', dpi: DPI, page: page_number) doc_page = doc.get_page(page_number)
page = page.resize(MAX_WIDTH / page.width.to_f)
data, width, height = doc_page.render_to_bitmap(width: MAX_WIDTH)
page = Vips::Image.new_from_memory(data, width, height, 4, :uchar)
page = page.copy(interpretation: :srgb)
data =
if format == FORMAT
bitdepth = 2**page.stats.to_a[1..3].pluck(2).uniq.size bitdepth = 2**page.stats.to_a[1..3].pluck(2).uniq.size
io = StringIO.new(page.write_to_buffer(FORMAT, compression: 7, filter: 0, bitdepth:, page.write_to_buffer(format, compression: 7, filter: 0, bitdepth:,
palette: true, Q: bitdepth == 8 ? Q : 5, dither: 0)) palette: true, Q: bitdepth == 8 ? Q : 5, dither: 0)
else
page.write_to_buffer(format, interlace: true, Q: JPEG_Q)
end
blob = ActiveStorage::Blob.new( blob = ActiveStorage::Blob.new(
filename: "#{page_number}#{FORMAT}", filename: "#{page_number}#{format}",
metadata: { analyzed: true, identified: true, width: page.width, height: page.height } metadata: { analyzed: true, identified: true, width: page.width, height: page.height }
) )
blob.upload(io) blob.upload(StringIO.new(data))
blob blob
rescue Vips::Error => e rescue Vips::Error, Pdfium::PdfiumError => e
Rollbar.warning(e) if defined?(Rollbar) Rollbar.warning(e) if defined?(Rollbar)
nil nil
ensure
doc_page&.close
end end
def maybe_flatten_form(data, pdf) def maybe_flatten_form(data, pdf)
@@ -162,35 +184,19 @@ module Templates
end end
def generate_pdf_preview_from_file(attachment, file_path, page_number) def generate_pdf_preview_from_file(attachment, file_path, page_number)
io = StringIO.new doc = Pdfium::Document.open_file(file_path)
command = [ blob = build_and_upload_blob(doc, page_number, '.jpeg')
'pdftocairo', '-jpeg', '-jpegopt', "progressive=y,quality=#{JPEG_Q},optimize=y",
'-scale-to-x', MAX_WIDTH, '-scale-to-y', '-1',
'-r', DPI, '-f', page_number + 1, '-l', page_number + 1,
'-singlefile', Shellwords.escape(file_path), '-'
].join(' ')
Open3.popen3(command) do |_, stdout, _, _|
io.write(stdout.read)
io.rewind
end
page = Vips::Image.new_from_buffer(io.read, '')
io.rewind
ApplicationRecord.no_touching do ApplicationRecord.no_touching do
ActiveStorage::Attachment.create!( ActiveStorage::Attachment.create!(
blob: ActiveStorage::Blob.create_and_upload!( blob: blob,
io:, filename: "#{page_number}.jpg",
metadata: { analyzed: true, identified: true, width: page.width, height: page.height }
),
name: ATTACHMENT_NAME, name: ATTACHMENT_NAME,
record: attachment record: attachment
) )
end end
ensure
doc&.close
end end
end end
end end
+1 -1
View File
@@ -6,7 +6,7 @@ module Templates
only: %w[ only: %w[
id archived_at fields name preferences schema id archived_at fields name preferences schema
slug source submitters created_at updated_at slug source submitters created_at updated_at
author_id external_id folder_id author_id external_id folder_id shared_link
], ],
methods: %i[application_key folder_name], methods: %i[application_key folder_name],
include: { author: { only: %i[id email first_name last_name] } } include: { author: { only: %i[id email first_name last_name] } }
+8
View File
@@ -0,0 +1,8 @@
# frozen_string_literal: true
FactoryBot.define do
factory :template_access do
template
user
end
end
+13
View File
@@ -14,6 +14,7 @@ FactoryBot.define do
%w[text date checkbox radio signature number multiple select initials image file stamp cells phone payment] %w[text date checkbox radio signature number multiple select initials image file stamp cells phone payment]
end end
except_field_types { [] } except_field_types { [] }
private_access_user { nil }
end end
after(:create) do |template, ev| after(:create) do |template, ev|
@@ -343,5 +344,17 @@ FactoryBot.define do
template.save! template.save!
end end
trait :with_admin_only_access do
after(:create) do |template|
create(:template_access, template:, user_id: TemplateAccess::ADMIN_USER_ID)
end
end
trait :with_private_access do
after(:create) do |template, ev|
create(:template_access, template:, user: ev.private_access_user || template.author)
end
end
end end
end end
BIN
View File
Binary file not shown.
@@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper'
RSpec.describe ProcessSubmitterCompletionJob do RSpec.describe ProcessSubmitterCompletionJob do
let(:account) { create(:account) } let(:account) { create(:account) }
let(:user) { create(:user, account:) } let(:user) { create(:user, account:) }
@@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper'
RSpec.describe SendFormCompletedWebhookRequestJob do RSpec.describe SendFormCompletedWebhookRequestJob do
let(:account) { create(:account) } let(:account) { create(:account) }
let(:user) { create(:user, account:) } let(:user) { create(:user, account:) }
@@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper'
RSpec.describe SendFormDeclinedWebhookRequestJob do RSpec.describe SendFormDeclinedWebhookRequestJob do
let(:account) { create(:account) } let(:account) { create(:account) }
let(:user) { create(:user, account:) } let(:user) { create(:user, account:) }
@@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper'
RSpec.describe SendFormStartedWebhookRequestJob do RSpec.describe SendFormStartedWebhookRequestJob do
let(:account) { create(:account) } let(:account) { create(:account) }
let(:user) { create(:user, account:) } let(:user) { create(:user, account:) }
@@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper'
RSpec.describe SendFormViewedWebhookRequestJob do RSpec.describe SendFormViewedWebhookRequestJob do
let(:account) { create(:account) } let(:account) { create(:account) }
let(:user) { create(:user, account:) } let(:user) { create(:user, account:) }
@@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper'
RSpec.describe SendSubmissionArchivedWebhookRequestJob do RSpec.describe SendSubmissionArchivedWebhookRequestJob do
let(:account) { create(:account) } let(:account) { create(:account) }
let(:user) { create(:user, account:) } let(:user) { create(:user, account:) }
@@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper'
RSpec.describe SendSubmissionCompletedWebhookRequestJob do RSpec.describe SendSubmissionCompletedWebhookRequestJob do
let(:account) { create(:account) } let(:account) { create(:account) }
let(:user) { create(:user, account:) } let(:user) { create(:user, account:) }
@@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper'
RSpec.describe SendSubmissionCreatedWebhookRequestJob do RSpec.describe SendSubmissionCreatedWebhookRequestJob do
let(:account) { create(:account) } let(:account) { create(:account) }
let(:user) { create(:user, account:) } let(:user) { create(:user, account:) }
@@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper'
RSpec.describe SendTemplateCreatedWebhookRequestJob do RSpec.describe SendTemplateCreatedWebhookRequestJob do
let(:account) { create(:account) } let(:account) { create(:account) }
let(:user) { create(:user, account:) } let(:user) { create(:user, account:) }
@@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper'
RSpec.describe SendTemplateUpdatedWebhookRequestJob do RSpec.describe SendTemplateUpdatedWebhookRequestJob do
let(:account) { create(:account) } let(:account) { create(:account) }
let(:user) { create(:user, account:) } let(:user) { create(:user, account:) }
-2
View File
@@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper'
RSpec.describe Params::BaseValidator do RSpec.describe Params::BaseValidator do
let(:validator) { described_class.new({}) } let(:validator) { described_class.new({}) }
+1 -3
View File
@@ -1,8 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper' describe 'Submission API' do
describe 'Submission API', type: :request do
let(:account) { create(:account, :with_testing_account) } let(:account) { create(:account, :with_testing_account) }
let(:testing_account) { account.testing_accounts.first } let(:testing_account) { account.testing_accounts.first }
let(:author) { create(:user, account:) } let(:author) { create(:user, account:) }
+1 -3
View File
@@ -1,8 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper' describe 'Submitter API' do
describe 'Submitter API', type: :request do
let(:account) { create(:account, :with_testing_account) } let(:account) { create(:account, :with_testing_account) }
let(:testing_account) { account.testing_accounts.first } let(:testing_account) { account.testing_accounts.first }
let(:author) { create(:user, account:) } let(:author) { create(:user, account:) }
+24 -5
View File
@@ -1,8 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper' describe 'Templates API' do
describe 'Templates API', type: :request do
let(:account) { create(:account, :with_testing_account) } let(:account) { create(:account, :with_testing_account) }
let(:testing_account) { account.testing_accounts.first } let(:testing_account) { account.testing_accounts.first }
let(:author) { create(:user, account:) } let(:author) { create(:user, account:) }
@@ -85,13 +83,15 @@ describe 'Templates API', type: :request do
end end
describe 'PUT /api/templates' do describe 'PUT /api/templates' do
it 'update a template' do let(:template) do
template = create(:template, account:, create(:template, account:,
author:, author:,
folder:, folder:,
external_id: SecureRandom.base58(10), external_id: SecureRandom.base58(10),
preferences: template_preferences) preferences: template_preferences)
end
it 'updates a template' do
put "/api/templates/#{template.id}", headers: { 'x-auth-token': author.access_token.token }, params: { put "/api/templates/#{template.id}", headers: { 'x-auth-token': author.access_token.token }, params: {
name: 'Updated Template Name', name: 'Updated Template Name',
external_id: '123456' external_id: '123456'
@@ -108,6 +108,24 @@ describe 'Templates API', type: :request do
updated_at: template.updated_at updated_at: template.updated_at
}.to_json)) }.to_json))
end end
it "enables the template's shared link" do
expect do
put "/api/templates/#{template.id}", headers: { 'x-auth-token': author.access_token.token }, params: {
shared_link: true
}.to_json
end.to change { template.reload.shared_link }.from(false).to(true)
end
it "disables the template's shared link" do
template.update(shared_link: true)
expect do
put "/api/templates/#{template.id}", headers: { 'x-auth-token': author.access_token.token }, params: {
shared_link: false
}.to_json
end.to change { template.reload.shared_link }.from(true).to(false)
end
end end
describe 'DELETE /api/templates/:id' do describe 'DELETE /api/templates/:id' do
@@ -208,6 +226,7 @@ describe 'Templates API', type: :request do
name: 'sample-document' name: 'sample-document'
} }
], ],
shared_link: template.shared_link,
author_id: author.id, author_id: author.id,
archived_at: nil, archived_at: nil,
created_at: template.created_at, created_at: template.created_at,
+1 -3
View File
@@ -1,8 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper' describe 'Tools API' do
describe 'Tools API', type: :request do
let(:account) { create(:account) } let(:account) { create(:account) }
let(:author) { create(:user, account:) } let(:author) { create(:user, account:) }
let(:file_path) { Rails.root.join('spec/fixtures/sample-document.pdf') } let(:file_path) { Rails.root.join('spec/fixtures/sample-document.pdf') }
-2
View File
@@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Account Settings' do RSpec.describe 'Account Settings' do
let!(:account) { create(:account) } let!(:account) { create(:account) }
let!(:user) { create(:user, account:) } let!(:user) { create(:user, account:) }
-2
View File
@@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'API Settings' do RSpec.describe 'API Settings' do
let!(:account) { create(:account) } let!(:account) { create(:account) }
let!(:user) { create(:user, account:) } let!(:user) { create(:user, account:) }
-2
View File
@@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Dashboard Page' do RSpec.describe 'Dashboard Page' do
let!(:account) { create(:account) } let!(:account) { create(:account) }
let!(:user) { create(:user, account:) } let!(:user) { create(:user, account:) }
-2
View File
@@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Email Settings' do RSpec.describe 'Email Settings' do
let!(:account) { create(:account) } let!(:account) { create(:account) }
let!(:user) { create(:user, account:) } let!(:user) { create(:user, account:) }
-2
View File
@@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'PDF Signature Settings' do RSpec.describe 'PDF Signature Settings' do
let!(:account) { create(:account) } let!(:account) { create(:account) }
let!(:user) { create(:user, account:) } let!(:user) { create(:user, account:) }
-2
View File
@@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Newsletter' do RSpec.describe 'Newsletter' do
let(:user) { create(:user, account: create(:account)) } let(:user) { create(:user, account: create(:account)) }
@@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Notifications Settings' do RSpec.describe 'Notifications Settings' do
let(:user) { create(:user, account: create(:account)) } let(:user) { create(:user, account: create(:account)) }
@@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Personalization Settings', :js do RSpec.describe 'Personalization Settings', :js do
let!(:account) { create(:account) } let!(:account) { create(:account) }
let!(:user) { create(:user, account:) } let!(:user) { create(:user, account:) }
-2
View File
@@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Personalization' do RSpec.describe 'Personalization' do
let!(:account) { create(:account) } let!(:account) { create(:account) }
let!(:user) { create(:user, account:) } let!(:user) { create(:user, account:) }
+8 -2
View File
@@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Profile Settings' do RSpec.describe 'Profile Settings' do
let(:user) { create(:user, account: create(:account)) } let(:user) { create(:user, account: create(:account)) }
@@ -35,6 +33,14 @@ RSpec.describe 'Profile Settings' do
expect(user.last_name).to eq('Beckham') expect(user.last_name).to eq('Beckham')
expect(user.email).to eq('david.beckham@example.com') expect(user.email).to eq('david.beckham@example.com')
end end
it 'does not update if email is invalid' do
fill_in 'Email', with: 'devid+test@example'
all(:button, 'Update')[0].click
expect(page).to have_content('Email is invalid')
end
end end
context 'when changes password' do context 'when changes password' do
+10 -2
View File
@@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'App Setup' do RSpec.describe 'App Setup' do
let(:form_data) do let(:form_data) do
{ {
@@ -53,6 +51,16 @@ RSpec.describe 'App Setup' do
end end
context 'when invalid information' do context 'when invalid information' do
it 'does not setup the app if the email is invalid' do
fill_setup_form(form_data.merge(email: 'bob@example-com'))
expect do
click_button 'Submit'
end.not_to(change(User, :count))
expect(page).to have_content('Email is invalid')
end
it 'does not setup the app if the password is too short' do it 'does not setup the app if the password is too short' do
fill_setup_form(form_data.merge(password: 'pass')) fill_setup_form(form_data.merge(password: 'pass'))
+1 -3
View File
@@ -1,8 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper' RSpec.describe 'Sign In' do
RSpec.describe 'Sign In', type: :system do
let(:account) { create(:account) } let(:account) { create(:account) }
let!(:user) { create(:user, account:, email: 'john.dou@example.com', password: 'strong_password') } let!(:user) { create(:user, account:, email: 'john.dou@example.com', password: 'strong_password') }
+8 -6
View File
@@ -1,19 +1,19 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper' RSpec.describe 'Signing Form' do
RSpec.describe 'Signing Form', type: :system do
let(:account) { create(:account) } let(:account) { create(:account) }
let(:author) { create(:user, account:) } let(:author) { create(:user, account:) }
context 'when the template form link is opened' do context 'when the template form link is opened' do
let(:template) { create(:template, account:, author:, except_field_types: %w[phone payment stamp]) } let(:template) do
create(:template, shared_link: true, account:, author:, except_field_types: %w[phone payment stamp])
end
before do before do
visit start_form_path(slug: template.slug) visit start_form_path(slug: template.slug)
end end
it 'shows the email step', type: :system do it 'shows the email step' do
expect(page).to have_content('You have been invited to submit a form') expect(page).to have_content('You have been invited to submit a form')
expect(page).to have_content("Invited by #{account.name}") expect(page).to have_content("Invited by #{account.name}")
expect(page).to have_field('Email', type: 'email') expect(page).to have_field('Email', type: 'email')
@@ -813,7 +813,9 @@ RSpec.describe 'Signing Form', type: :system do
end end
context 'when the template requires multiple submitters' do context 'when the template requires multiple submitters' do
let(:template) { create(:template, submitter_count: 2, account:, author:, only_field_types: %w[text]) } let(:template) do
create(:template, shared_link: true, submitter_count: 2, account:, author:, only_field_types: %w[text])
end
context 'when default signer details are not defined' do context 'when default signer details are not defined' do
it 'shows an explanation error message if a logged-in user associated with the template account opens the link' do it 'shows an explanation error message if a logged-in user associated with the template account opens the link' do
-2
View File
@@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Storage Settings' do RSpec.describe 'Storage Settings' do
let!(:account) { create(:account) } let!(:account) { create(:account) }
let!(:user) { create(:user, account:) } let!(:user) { create(:user, account:) }
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more