Compare commits

...

40 Commits

Author SHA1 Message Date
Pete Matsyburka 94a13ffca2 do not use devise strategy for api auth 2023-12-26 01:56:00 +02:00
Pete Matsyburka 64c8707957 fix multiple signatures config 2023-12-25 13:16:59 +02:00
Pete Matsyburka d421a73988 fix default submitters order 2023-12-25 13:03:41 +02:00
Pete Matsyburka f7eef1f8d6 clone only active template schema docs 2023-12-24 20:44:01 +02:00
Pete Matsyburka 08c780d309 add status and documents to submission get api 2023-12-23 22:24:57 +02:00
Alex Turchyn baaf1f510f generate unique uuids for cloned templates 2023-12-23 21:30:46 +02:00
Pete Matsyburka 8898dcc054 fix archive submission api 2023-12-23 17:47:58 +02:00
Pete Matsyburka 33521c8aeb draw default area size on click 2023-12-23 12:08:46 +02:00
Pete Matsyburka 9db03bd7b9 fix sso page header 2023-12-23 12:08:46 +02:00
Pete Matsyburka bd600c1fba adjust builder header 2023-12-23 12:08:46 +02:00
Pete Matsyburka 82b18b08f1 set template name from first document 2023-12-23 12:08:46 +02:00
Pete Matsyburka 0aefe616b3 add archived add to submissions 2023-12-23 12:08:45 +02:00
Pete Matsyburka 59a66bbd16 draw stamp when added 2023-12-22 16:39:43 +02:00
Pete Matsyburka 155c708e4f update errors controller 2023-12-22 16:36:15 +02:00
Pete Matsyburka 5502c2ab1b fix phone tooltip placement 2023-12-22 16:32:48 +02:00
Pete Matsyburka e89b42de40 add stamp field type 2023-12-22 13:53:51 +02:00
Pete Matsyburka 2562c4f432 add option to toggle single digital sign 2023-12-21 23:29:06 +02:00
Pete Matsyburka 17323074ca fix images document sign 2023-12-21 23:13:32 +02:00
Pete Matsyburka 8a4c5c5e09 remove users utils module 2023-12-21 17:54:39 +02:00
Pete Matsyburka 3033faff0d fix time format 2023-12-21 10:02:57 +02:00
Pete Matsyburka 0e6aab0e8d add builder section props 2023-12-20 22:13:19 +02:00
Pete Matsyburka 75e3dd839a fix document name editable prop 2023-12-20 21:58:47 +02:00
Pete Matsyburka 830e6eb88d hardcode signature size when using tsa 2023-12-20 21:25:27 +02:00
Pete Matsyburka 3c8fae78d1 use alpine3.18 2023-12-20 00:45:31 +02:00
Pete Matsyburka 441c1d7044 add builder on upload callback 2023-12-19 19:27:07 +02:00
Pete Matsyburka 203f4633f7 return 422 when submit archived submission 2023-12-19 15:51:56 +02:00
Pete Matsyburka e558f4d9fb add ability to set date field format 2023-12-17 23:51:44 +02:00
Pete Matsyburka 990e4020e9 add submitter update endpoint 2023-12-17 18:39:10 +02:00
Pete Matsyburka 1ad3e45519 add form button props 2023-12-17 16:10:17 +02:00
Pete Matsyburka 442ced8381 adjust text 2023-12-17 15:22:06 +02:00
Pete Matsyburka ba46202a4d add pdf processor class 2023-12-16 23:12:20 +02:00
Pete Matsyburka 1c77a1ee90 adjust email variables 2023-12-15 21:41:50 +02:00
Pete Matsyburka ea74b59535 add ID email variables 2023-12-15 21:41:50 +02:00
Pete Matsyburka 0e9343d9e2 process payment fields 2023-12-15 21:41:50 +02:00
Pete Matsyburka af4cf7257f extra sign in links 2023-12-14 16:47:42 +02:00
Pete Matsyburka c8d4ee58f8 reduce field area opacity 2023-12-13 17:50:13 +02:00
Pete Matsyburka 65e57c22e5 adjust integration emails 2023-12-13 15:46:42 +02:00
Pete Matsyburka 7351ae0a77 adjust integration submissions 2023-12-11 21:57:09 +02:00
Pete Matsyburka f1e0d5d137 fix result generation on missing page 2023-12-11 18:28:24 +02:00
Pete Matsyburka 8a1b71688c fix signing form with area on missing page 2023-12-11 15:18:47 +02:00
64 changed files with 1589 additions and 379 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ COPY ./app/views ./app/views
RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker
FROM ruby:3.2.2-alpine as app
FROM ruby:3.2.2-alpine3.18 as app
ENV RAILS_ENV=production
ENV BUNDLE_WITHOUT="development:test"
-4
View File
@@ -21,10 +21,6 @@ gem 'jwt'
gem 'lograge'
gem 'mysql2', require: false
gem 'oj'
gem 'omniauth-github'
gem 'omniauth-google-oauth2'
gem 'omniauth-microsoft-office365'
gem 'omniauth-rails_csrf_protection'
gem 'pagy'
gem 'pdf-reader'
gem 'pg', require: false
-40
View File
@@ -245,7 +245,6 @@ GEM
signet (>= 0.16, < 2.a)
hashdiff (1.0.1)
hashery (2.1.2)
hashie (5.0.0)
hexapdf (0.34.1)
cmdparse (~> 3.0, >= 3.0.3)
geom2d (~> 0.4, >= 0.4.1)
@@ -296,7 +295,6 @@ GEM
minitest (5.20.0)
msgpack (1.7.2)
multi_json (1.15.0)
multi_xml (0.6.0)
multipart-post (2.3.0)
mysql2 (0.5.5)
net-http-persistent (4.0.2)
@@ -316,35 +314,7 @@ GEM
racc (~> 1.4)
nokogiri (1.15.4-arm64-darwin)
racc (~> 1.4)
oauth2 (2.0.9)
faraday (>= 0.17.3, < 3.0)
jwt (>= 1.0, < 3.0)
multi_xml (~> 0.5)
rack (>= 1.2, < 4)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
oj (3.16.0)
omniauth (2.1.1)
hashie (>= 3.4.6)
rack (>= 2.2.3)
rack-protection
omniauth-github (2.0.1)
omniauth (~> 2.0)
omniauth-oauth2 (~> 1.8)
omniauth-google-oauth2 (1.1.1)
jwt (>= 2.0)
oauth2 (~> 2.0.6)
omniauth (~> 2.0)
omniauth-oauth2 (~> 1.8.0)
omniauth-microsoft-office365 (0.0.8)
omniauth
omniauth-oauth2
omniauth-oauth2 (1.8.0)
oauth2 (>= 1.4, < 3)
omniauth (~> 2.0)
omniauth-rails_csrf_protection (1.0.1)
actionpack (>= 4.2)
omniauth (~> 2.0)
openssl (3.2.0)
orm_adapter (0.5.0)
os (1.1.4)
@@ -378,8 +348,6 @@ GEM
nio4r (~> 2.0)
racc (1.7.1)
rack (2.2.8)
rack-protection (3.1.0)
rack (~> 2.2, >= 2.2.4)
rack-proxy (0.7.6)
rack
rack-test (2.1.0)
@@ -520,9 +488,6 @@ GEM
simplecov-html (0.12.3)
simplecov_json_formatter (0.1.4)
smart_properties (1.17.0)
snaky_hash (2.0.1)
hashie
version_gem (~> 1.1, >= 1.1.1)
sqlite3 (1.6.3)
mini_portile2 (~> 2.8.0)
sqlite3 (1.6.3-arm64-darwin)
@@ -543,7 +508,6 @@ GEM
uber (0.1.0)
unicode-display_width (2.5.0)
uniform_notifier (1.16.0)
version_gem (1.1.3)
warden (1.2.9)
rack (>= 2.0.9)
web-console (4.2.0)
@@ -595,10 +559,6 @@ DEPENDENCIES
lograge
mysql2
oj
omniauth-github
omniauth-google-oauth2
omniauth-microsoft-office365
omniauth-rails_csrf_protection
pagy
pdf-reader
pg
@@ -6,7 +6,8 @@ class AccountConfigsController < ApplicationController
ALLOWED_KEYS = [
AccountConfig::ALLOW_TYPED_SIGNATURE,
AccountConfig::FORCE_MFA
AccountConfig::FORCE_MFA,
AccountConfig::ESIGNING_PREFERENCE_KEY
].freeze
def create
@@ -33,6 +33,17 @@ module Api
result
end
def authenticate_user!
@current_user ||=
if request.headers['X-Auth-Token'].present?
sha256 = Digest::SHA256.hexdigest(request.headers['X-Auth-Token'])
User.joins(:access_token).find_by(access_token: { sha256: })
end
render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user
end
def current_account
current_user&.account
end
+26 -59
View File
@@ -31,24 +31,33 @@ module Api
end
def show
serialized_subbmitters =
@submission.submitters.preload(documents_attachments: :blob, attachments_attachments: :blob).map do |submitter|
Submissions::EnsureResultGenerated.call(submitter) if submitter.completed_at?
submitters = @submission.submitters.preload(documents_attachments: :blob, attachments_attachments: :blob)
Submitters::SerializeForApi.call(submitter)
end
serialized_submitters = submitters.map do |submitter|
Submissions::EnsureResultGenerated.call(submitter) if submitter.completed_at?
Submitters::SerializeForApi.call(submitter)
end
json = @submission.as_json(
serialize_params.deep_merge(
include: {
submission_events: {
only: %i[id submitter_id event_type event_timestamp]
}
}
include: { submission_events: { only: %i[id submitter_id event_type event_timestamp] } }
)
)
json[:submitters] = serialized_subbmitters
if submitters.all?(&:completed_at?)
last_submitter = submitters.max_by(&:completed_at)
json[:documents] = serialized_submitters.find { |e| e['id'] == last_submitter.id }['documents']
json[:status] = 'completed'
json[:completed_at] = last_submitter.completed_at
else
json[:documents] = []
json[:status] = 'pending'
json[:completed_at] = nil
end
json[:submitters] = serialized_submitters
render json:
end
@@ -69,7 +78,7 @@ module Api
def destroy
@submission.update!(deleted_at: Time.current)
render json: @submission.as_json(only: %i[id deleted_at])
render json: @submission.as_json(only: %i[id], methods: %i[archived_at])
end
private
@@ -85,7 +94,8 @@ module Api
emails:,
params:)
else
submissions_attrs, attachments = normalize_submissions_params!(submissions_params, template)
submissions_attrs, attachments =
Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_params, template)
submissions = Submissions.create_from_submitters(
template:,
@@ -97,7 +107,8 @@ module Api
params:
)
save_default_value_attachments!(attachments, submissions.flat_map(&:submitters))
Submissions::NormalizeParamUtils.save_default_value_attachments!(attachments,
submissions.flat_map(&:submitters))
submissions
end
@@ -106,7 +117,7 @@ module Api
def serialize_params
{
only: %i[id source submitters_order created_at updated_at],
methods: %i[audit_log_url],
methods: %i[audit_log_url archived_at],
include: {
submitters: { only: %i[id slug uuid name email phone
completed_at opened_at sent_at
@@ -133,49 +144,5 @@ module Api
]
).fetch(key, [])
end
def normalize_submissions_params!(submissions_params, template)
attachments = []
Array.wrap(submissions_params).each do |submission|
submission[:submitters].each_with_index do |submitter, index|
default_values = submitter[:values] || {}
submitter[:fields]&.each { |f| default_values[f[:name]] = f[:default_value] if f[:default_value].present? }
next if default_values.blank?
values, new_attachments =
Submitters::NormalizeValues.call(template,
default_values,
submitter[:role] || template.submitters[index]['name'],
throw_errors: true)
attachments.push(*new_attachments)
submitter[:values] = values
end
end
[submissions_params, attachments]
end
def save_default_value_attachments!(attachments, submitters)
return if attachments.blank?
attachments_index = attachments.index_by(&:uuid)
submitters.each do |submitter|
submitter.values.to_a.each do |_, value|
attachment = attachments_index[value]
next unless attachment
attachment.record = submitter
attachment.save!
end
end
end
end
end
@@ -30,5 +30,79 @@ module Api
render json: Submitters::SerializeForApi.call(@submitter, with_template: true, with_events: true)
end
def update
if @submitter.completed_at?
return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_entity
end
role = @submitter.submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name']
normalized_params, new_attachments =
Submissions::NormalizeParamUtils.normalize_submitter_params!(submitter_params.merge(role:), @submitter.template,
for_submitter: @submitter)
Submissions::CreateFromSubmitters.maybe_set_template_fields(@submitter.submission,
[normalized_params],
submitter_uuid: @submitter.uuid)
assign_submitter_attrs(@submitter, normalized_params)
ApplicationRecord.transaction do
Submissions::NormalizeParamUtils.save_default_value_attachments!(new_attachments, [@submitter])
@submitter.save!
@submitter.submission.save!
end
if @submitter.completed_at?
ProcessSubmitterCompletionJob.perform_later(@submitter)
elsif normalized_params[:send_email] || normalized_params[:send_sms]
Submitters.send_signature_requests([@submitter])
end
render json: Submitters::SerializeForApi.call(@submitter, with_template: false, with_events: false)
end
def submitter_params
submitter_params = params.key?(:submitter) ? params.require(:submitter) : params
submitter_params.permit(
:send_email, :send_sms, :uuid, :name, :email, :role, :completed, :phone, :application_key,
{ values: {}, readonly_fields: [], message: %i[subject body],
fields: [%i[name default_value readonly validation_pattern invalid_message]] }
)
end
private
def assign_submitter_attrs(submitter, attrs)
submitter.email = Submissions.normalize_email(attrs[:email]) if attrs.key?(:email)
submitter.phone = attrs[:phone].to_s.gsub(/[^0-9+]/, '') if attrs.key?(:phone)
submitter.values = submitter.values.merge(attrs[:values].to_unsafe_h) if attrs[:values]
submitter.completed_at = attrs[:completed] ? Time.current : submitter.completed_at
submitter.application_key = attrs[:application_key] if attrs.key?(:application_key)
assign_preferences(submitter, attrs)
submitter
end
def assign_preferences(submitter, attrs)
submitter_preferences = Submitters.normalize_preferences(submitter.account, current_user, attrs)
if submitter_preferences.key?('send_email')
submitter.preferences['send_email'] = submitter_preferences['send_email']
end
submitter.preferences['send_sms'] = submitter_preferences['send_sms'] if submitter_preferences.key?('send_sms')
return unless submitter_preferences.key?('email_message_uuid')
submitter.preferences['email_message_uuid'] = submitter_preferences['email_message_uuid']
submitter
end
end
end
@@ -7,23 +7,18 @@ module Api
def create
authorize!(:manage, @template)
template = current_account.templates.new(source: :api)
cloned_template = Templates::Clone.call(@template,
author: current_user,
name: params[:name],
application_key: params[:application_key],
folder_name: params[:folder_name])
template.application_key = params[:application_key]
template.name = params[:name] || "#{@template.name} (Clone)"
template.account = @template.account
template.author = current_user
template.assign_attributes(@template.slice(:folder_id, :fields, :schema, :submitters))
cloned_template.source = :api
cloned_template.save!
if params[:folder_name].present?
template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
end
Templates::CloneAttachments.call(template: cloned_template, original_template: @template)
template.save!
Templates::CloneAttachments.call(template:, original_template: @template)
render json: template.as_json(serialize_params)
render json: cloned_template.as_json(serialize_params)
end
private
+3 -1
View File
@@ -58,7 +58,9 @@ module Api
schema: [%i[attachment_uuid name]],
submitters: [%i[name uuid]],
fields: [[:uuid, :submitter_uuid, :name, :type, :required, :readonly, :default_value,
{ options: [%i[value uuid]], areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]]
{ preferences: {},
options: [%i[value uuid]],
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]]
)
end
end
+5 -1
View File
@@ -8,7 +8,11 @@ class ErrorsController < ActionController::Base
'/templates/html',
'/api/templates/html',
'/templates/pdf',
'/api/templates/pdf'
'/api/templates/pdf',
'/templates/doc',
'/api/templates/doc',
'/templates/docx',
'/api/templates/docx'
].freeze
def show
@@ -1,16 +0,0 @@
# frozen_string_literal: true
class OmniauthCallbacksController < Devise::OmniauthCallbacksController
def google_oauth2
@user = Users.from_omniauth(request.env['omniauth.auth'])
if @user.persisted?
flash[:notice] = I18n.t('devise.omniauth_callbacks.success', kind: 'Google')
sign_in_and_redirect @user, event: :authentication
else
redirect_to new_registration_path(oauth_callback: true, user: @user.slice(:email, :first_name, :last_name)),
notice: 'Please complete registration with Google auth'
end
end
end
@@ -42,6 +42,12 @@ class SubmitFormController < ApplicationController
return render json: { error: 'Form has been completed already.' }, status: :unprocessable_entity
end
if submitter.template.deleted_at? || submitter.submission.deleted_at?
Rollbar.info("Archived template: #{submitter.template.id}") if defined?(Rollbar)
return render json: { error: 'Form has been archived.' }, status: :unprocessable_entity
end
Submitters::SubmitValues.call(submitter, params, request)
head :ok
+9 -4
View File
@@ -42,10 +42,15 @@ class TemplatesController < ApplicationController
end
def create
@template.account = current_account
@template.author = current_user
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
@template.assign_attributes(@base_template.slice(:fields, :schema, :submitters)) if @base_template
if @base_template
@template = Templates::Clone.call(@base_template, author: current_user,
name: params.dig(:template, :name),
folder_name: params[:folder_name])
else
@template.account = current_account
@template.author = current_user
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
end
if @template.save
Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template
@@ -30,6 +30,7 @@ class TimestampServerController < ApplicationController
reason: 'Test',
certificate: pkcs.certificate,
key: pkcs.key,
signature_size: 10_000,
certificate_chain: pkcs.ca_certs || [],
timestamp_handler: Submissions::TimestampHandler.new(tsa_url: url))
end
+1
View File
@@ -86,6 +86,7 @@ window.customElements.define('template-builder', class extends HTMLElement {
backgroundColor: '#faf7f5',
withPhone: this.dataset.withPhone === 'true',
withLogo: this.dataset.withLogo !== 'false',
withPayment: this.dataset.withPayment !== 'false',
acceptFileTypes: this.dataset.acceptFileTypes,
isDirectUpload: this.dataset.isDirectUpload === 'true'
})
-17
View File
@@ -75,23 +75,6 @@ button[disabled] .enabled {
@apply select base-input w-full font-normal;
}
.tooltip-bottom-end:before {
transform: translateX(-95%);
top: var(--tooltip-offset);
left: 100%;
right: auto;
bottom: auto;
}
.tooltip-bottom-end:after {
transform: translateX(-25%);
border-color: transparent transparent var(--tooltip-color) transparent;
top: var(--tooltip-tail-offset);
left: 50%;
right: auto;
bottom: auto;
}
.autocomplete {
background: white;
z-index: 1000;
-1
View File
@@ -10,7 +10,6 @@ window.customElements.define('submission-form', class extends HTMLElement {
this.app = createApp(Form, {
submitter: JSON.parse(this.dataset.submitter),
authenticityToken: this.dataset.authenticityToken,
canSendEmail: this.dataset.canSendEmail === 'true',
isDirectUpload: this.dataset.isDirectUpload === 'true',
goToLast: this.dataset.goToLast === 'true',
+60 -6
View File
@@ -2,7 +2,7 @@
<div
class="flex absolute lg:text-base"
:style="computedStyle"
:class="{ 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars, 'cursor-default': !submittable, 'bg-red-100 border cursor-pointer ': submittable, 'border-red-100': !isActive && submittable, 'bg-opacity-70': !isActive && !isValueSet && submittable, 'border-red-500 border-dashed z-10': isActive && submittable, 'bg-opacity-30': (isActive || isValueSet) && submittable }"
:class="{ 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars, 'cursor-default': !submittable, 'bg-red-100 border cursor-pointer ': submittable, 'border-red-100': !isActive && submittable, 'bg-opacity-80': !isActive && !isValueSet && submittable, 'border-red-500 border-dashed z-10': isActive && submittable, 'bg-opacity-40': (isActive || isValueSet) && submittable }"
>
<div
v-if="!isActive && !isValueSet && field.type !== 'checkbox' && submittable && !area.option_uuid"
@@ -42,6 +42,11 @@
class="object-contain mx-auto"
:src="image.url"
>
<img
v-else-if="field.type === 'stamp' && stamp"
class="object-contain mx-auto"
:src="stamp.url"
>
<img
v-else-if="field.type === 'signature' && signature"
class="object-contain mx-auto"
@@ -53,7 +58,7 @@
:src="initials.url"
>
<div
v-else-if="field.type === 'file'"
v-else-if="field.type === 'file' || field.type === 'payment'"
class="px-0.5 flex flex-col justify-center"
>
<a
@@ -158,7 +163,7 @@
</template>
<script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper } from '@tabler/icons-vue'
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp } from '@tabler/icons-vue'
export default {
name: 'FieldArea',
@@ -225,13 +230,15 @@ export default {
signature: 'Signature',
date: 'Date',
image: 'Image',
stamp: 'Stamp',
initials: 'Initials',
file: 'File',
select: 'Select',
checkbox: 'Checkbox',
radio: 'Radio',
multiple: 'Multiple Select',
phone: 'Phone'
phone: 'Phone',
payment: 'Payment'
}
},
fieldIcons () {
@@ -245,9 +252,11 @@ export default {
select: IconSelect,
checkbox: IconCheckbox,
radio: IconCircleDot,
stamp: IconRubberStamp,
cells: IconColumns3,
multiple: IconChecks,
phone: IconPhoneCheck
phone: IconPhoneCheck,
payment: IconCreditCard
}
},
image () {
@@ -257,6 +266,13 @@ export default {
return null
}
},
stamp () {
if (this.field.type === 'stamp') {
return this.attachmentsIndex[this.modelValue]
} else {
return null
}
},
signature () {
if (this.field.type === 'signature') {
return this.attachmentsIndex[this.modelValue]
@@ -271,9 +287,15 @@ export default {
return null
}
},
locale () {
return Intl.DateTimeFormat().resolvedOptions()?.locale
},
formattedDate () {
if (this.field.type === 'date' && this.modelValue) {
return new Intl.DateTimeFormat([], { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }).format(new Date(this.modelValue))
return this.formatDate(
new Date(this.modelValue),
this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY')
)
} else {
return ''
}
@@ -281,6 +303,8 @@ export default {
attachments () {
if (this.field.type === 'file') {
return (this.modelValue || []).map((uuid) => this.attachmentsIndex[uuid])
} else if (this.field.type === 'payment') {
return [this.attachmentsIndex[this.modelValue]].filter(Boolean)
} else {
return []
}
@@ -311,6 +335,36 @@ export default {
}
},
methods: {
formatDate (date, format) {
const monthFormats = {
M: 'numeric',
MM: '2-digit',
MMM: 'short',
MMMM: 'long'
}
const dayFormats = {
D: 'numeric',
DD: '2-digit'
}
const yearFormats = {
YYYY: 'numeric',
YY: '2-digit'
}
const parts = new Intl.DateTimeFormat([], {
day: dayFormats[format.match(/D+/)],
month: monthFormats[format.match(/M+/)],
year: yearFormats[format.match(/Y+/)],
timeZone: 'UTC'
}).formatToParts(date)
return format
.replace(/D+/, parts.find((p) => p.type === 'day').value)
.replace(/M+/, parts.find((p) => p.type === 'month').value)
.replace(/Y+/, parts.find((p) => p.type === 'year').value)
},
updateMultipleSelectValue (value) {
if (this.modelValue?.includes(value)) {
const newValue = [...this.modelValue]
+1
View File
@@ -12,6 +12,7 @@
:key="areaIndex"
>
<Teleport
v-if="findPageElementForArea(area)"
:to="findPageElementForArea(area)"
>
<FieldArea
+12 -2
View File
@@ -21,7 +21,7 @@
</span>
</a>
<button
v-if="canSendEmail && !isDemo"
v-if="canSendEmail && !isDemo && withSendCopyButton"
class="white-button !h-auto flex items-center space-x-1 w-full"
:disabled="isSendingCopy"
@click.prevent="sendCopyToEmail"
@@ -36,7 +36,7 @@
</span>
</button>
<button
v-if="!isWebView"
v-if="!isWebView && withDownloadButton"
class="base-button flex items-center space-x-1 w-full"
:disabled="isDownloading"
@click.prevent="download"
@@ -115,6 +115,16 @@ export default {
required: false,
default: true
},
withDownloadButton: {
type: Boolean,
required: false,
default: true
},
withSendCopyButton: {
type: Boolean,
required: false,
default: true
},
withConfetti: {
type: Boolean,
required: false,
+48 -13
View File
@@ -206,7 +206,7 @@
</div>
</div>
<ImageStep
v-else-if="currentField.type === 'image'"
v-else-if="currentField.type === 'image' || currentField.type === 'stamp'"
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:field="currentField"
@@ -266,8 +266,22 @@
@focus="$refs.areas.scrollIntoField(currentField)"
@submit="submitStep"
/>
<PaymentStep
v-else-if="currentField.type === 'payment'"
ref="currentStep"
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:field="currentField"
:submitter-slug="submitterSlug"
@attached="attachments.push($event)"
@focus="$refs.areas.scrollIntoField(currentField)"
@submit="submitStep"
/>
</div>
<div class="mt-6 md:mt-8">
<div
v-if="currentField.type !== 'payment' || submittedValues[currentField.uuid]"
class="mt-6 md:mt-8"
>
<button
ref="submitButton"
type="submit"
@@ -303,6 +317,8 @@
:is-demo="isDemo"
:attribution="attribution"
:completed-button="completedButton"
:with-send-copy-button="withSendCopyButton"
:with-download-button="withDownloadButton"
:with-confetti="withConfetti"
:can-send-email="canSendEmail && !!submitter.email"
:submitter-slug="submitterSlug"
@@ -331,6 +347,7 @@ import InitialsStep from './initials_step'
import AttachmentStep from './attachment_step'
import MultiSelectStep from './multi_select_step'
import PhoneStep from './phone_step'
import PaymentStep from './payment_step'
import TextStep from './text_step'
import DateStep from './date_step'
import FormCompleted from './completed'
@@ -351,6 +368,7 @@ export default {
IconArrowsDiagonal,
TextStep,
PhoneStep,
PaymentStep,
IconArrowsDiagonalMinimize2,
FormCompleted
},
@@ -402,11 +420,6 @@ export default {
required: false,
default: () => []
},
authenticityToken: {
type: String,
required: false,
default: ''
},
backgroundColor: {
type: String,
required: false,
@@ -442,6 +455,16 @@ export default {
required: false,
default: () => ({})
},
withSendCopyButton: {
type: Boolean,
required: false,
default: true
},
withDownloadButton: {
type: Boolean,
required: false,
default: true
},
completedButton: {
type: Object,
required: false,
@@ -463,6 +486,12 @@ export default {
currentStepFields () {
return this.stepFields[this.currentStep]
},
queryParams () {
return new URLSearchParams(window.location.search)
},
authenticityToken () {
return document.querySelector('meta[name="csrf-token"]')?.content
},
submitterSlug () {
return this.submitter.slug
},
@@ -525,7 +554,13 @@ export default {
}
})
if (this.goToLast) {
if (this.queryParams.get('field_uuid')) {
const stepIndex = this.stepFields.findIndex((fields) => {
return fields.some((f) => f.uuid === this.queryParams.get('field_uuid'))
})
this.currentStep = Math.max(stepIndex, 0)
} else if (this.goToLast) {
const requiredEmptyStepIndex = this.stepFields.indexOf(this.stepFields.find((fields) => fields.some((f) => f.required && !this.submittedValues[f.uuid])))
const lastFilledStepIndex = this.stepFields.indexOf([...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.submittedValues[f.uuid]))) + 1
@@ -570,7 +605,7 @@ export default {
methods: {
t,
maybeTrackEmailClick () {
const queryParams = new URLSearchParams(window.location.search)
const { queryParams } = this
if (queryParams.has('t')) {
const t = queryParams.get('t')
@@ -594,7 +629,7 @@ export default {
}
},
maybeTrackSmsClick () {
const queryParams = new URLSearchParams(window.location.search)
const { queryParams } = this
if (queryParams.has('c')) {
const c = queryParams.get('c')
@@ -667,7 +702,7 @@ export default {
async submitStep () {
this.isSubmitting = true
const stepPromise = ['signature', 'phone', 'initials'].includes(this.currentField.type)
const stepPromise = ['signature', 'phone', 'initials', 'payment'].includes(this.currentField.type)
? this.$refs.currentStep.submit
: () => Promise.resolve({})
@@ -684,7 +719,7 @@ export default {
}
await this.saveStep(formData).then(async (response) => {
if (response.status === 422) {
if (response.status === 422 || response.status === 500) {
const data = await response.json()
alert(data.error || 'Value is invalid')
@@ -714,7 +749,7 @@ export default {
}
}
}).catch(error => {
console.error('Error submitting form:', error)
console.error(error)
}).finally(() => {
this.isSubmitting = false
})
+27 -9
View File
@@ -38,7 +38,9 @@ const en = {
type: 'Type',
type_text: 'Type text',
date: 'Date',
email_has_been_sent: 'Email has been sent'
email_has_been_sent: 'Email has been sent',
processing: 'Processing',
pay_with_strip: 'Pay with Stripe'
}
const es = {
@@ -81,7 +83,9 @@ const es = {
draw: 'Dibujar',
type: 'Escribir',
type_text: 'Escribir texto',
email_has_been_sent: 'El correo electrónico ha sido enviado'
email_has_been_sent: 'El correo electrónico ha sido enviado',
processing: 'Procesando',
pay_with_strip: 'Pagar con Stripe'
}
const it = {
@@ -124,7 +128,9 @@ const it = {
type: 'Inserisci',
type_text: 'Inserisci testo',
toggle_multiline_text: 'Attiva Testo Multilinea',
email_has_been_sent: "L'email è stata inviata"
email_has_been_sent: "L'email è stata inviata",
processing: 'Elaborazione',
pay_with_strip: 'Paga con Stripe'
}
const de = {
@@ -167,7 +173,9 @@ const de = {
type: 'Eingeben',
type_text: 'Text eingeben',
toggle_multiline_text: 'Mehrzeiligen Text umschalten',
email_has_been_sent: 'Die E-Mail wurde gesendet'
email_has_been_sent: 'Die E-Mail wurde gesendet',
processing: 'Verarbeitung',
pay_with_strip: 'Mit Stripe bezahlen'
}
const fr = {
@@ -210,7 +218,9 @@ const fr = {
type: 'Saisir',
type_text: 'Saisir du texte',
toggle_multiline_text: 'Basculer le Texte Multiligne',
email_has_been_sent: "L'email a été envoyé"
email_has_been_sent: "L'email a été envoyé",
processing: 'Traitement',
pay_with_strip: 'Paiement avec Stripe'
}
const pl = {
@@ -253,7 +263,9 @@ const pl = {
type: 'Wprowadź',
type_text: 'Wprowadź tekst',
toggle_multiline_text: 'Przełącz Tekst Wielolinijkowy',
email_has_been_sent: 'E-mail został wysłany'
email_has_been_sent: 'E-mail został wysłany',
processing: 'Przetwarzanie',
pay_with_strip: 'Płatność za pomocą Stripe'
}
const uk = {
@@ -296,7 +308,9 @@ const uk = {
type: 'Текст',
type_text: 'Введіть текст',
toggle_multiline_text: 'Перемкнути Багаторядковий Текст',
email_has_been_sent: 'Електронний лист був відправлений'
email_has_been_sent: 'Електронний лист був відправлений',
processing: 'Обробка',
pay_with_strip: 'Сплатити за допомогою Stripe'
}
const cs = {
@@ -339,7 +353,9 @@ const cs = {
type: 'Zadat',
type_text: 'Zadat text',
toggle_multiline_text: 'Přepnout Víceřádkový Text',
email_has_been_sent: 'E-mail byl odeslán'
email_has_been_sent: 'E-mail byl odeslán',
processing: 'Zpracování',
pay_with_strip: 'Zaplacení přes Stripe'
}
const pt = {
@@ -382,7 +398,9 @@ const pt = {
type: 'Inserir',
type_text: 'Inserir texto',
toggle_multiline_text: 'Alternar Texto Multilinha',
email_has_been_sent: 'Email enviado'
email_has_been_sent: 'Email enviado',
processing: 'Processamento',
pay_with_strip: 'Pagar com Stripe'
}
const i18n = { en, es, it, de, fr, pl, uk, cs, pt }
@@ -0,0 +1,181 @@
<template>
<label
v-if="!modelValue && !sessionId"
:for="field.uuid"
class="label text-2xl mb-2"
>{{ field.name || defaultName }}
</label>
<div>
<input
type="text"
:value="modelValue"
hidden
:name="`values[${field.uuid}]`"
class="hidden"
>
<div
v-if="modelValue && !sessionId"
class=" text-2xl mb-2"
>
Already paid
</div>
<div v-else>
<button
v-if="sessionId"
disabled
class="base-button w-full"
>
<IconLoader
width="22"
class="animate-spin"
/>
<span>
{{ t('processing') }}...
</span>
</button>
<button
v-else
:id="field.uuid"
class="btn bg-[#7B73FF] text-white hover:bg-[#0A2540] text-lg w-full"
:class="{ disabled: isCreatingCheckout }"
:disabled="isCreatingCheckout"
@click.prevent="startCheckout"
>
<IconInnerShadowTop
v-if="isCreatingCheckout"
width="22"
class="animate-spin"
/>
<IconBrandStripe
v-else
width="22"
/>
<span>
{{ t('pay_with_strip') }}
</span>
</button>
</div>
</div>
</template>
<script>
import { IconBrandStripe, IconInnerShadowTop, IconLoader } from '@tabler/icons-vue'
export default {
name: 'PaymentStep',
components: {
IconBrandStripe,
IconInnerShadowTop,
IconLoader
},
inject: ['baseUrl', 't'],
props: {
modelValue: {
type: String,
required: false,
default: ''
},
field: {
type: Object,
required: true
},
submitterSlug: {
type: String,
required: true
}
},
emits: ['focus', 'submit', 'update:model-value', 'attached'],
data () {
return {
isCreatingCheckout: false
}
},
computed: {
queryParams () {
return new URLSearchParams(window.location.search)
},
sessionId () {
return this.queryParams.get('stripe_session_id')
},
defaultName () {
const { price, currency } = this.field.preferences || {}
const formattedPrice = new Intl.NumberFormat([], {
style: 'currency',
currency
}).format(price)
return `Pay ${formattedPrice}`
}
},
mounted () {
if (this.sessionId) {
this.$emit('submit')
}
},
methods: {
async submit () {
if (this.sessionId) {
return fetch((this.baseUrl || '/embed').replace('/embed', '/api/stripe_payments/' + this.sessionId), {
method: 'PUT',
body: JSON.stringify({
submitter_slug: this.submitterSlug
}),
headers: { 'Content-Type': 'application/json' }
}).then(async (resp) => {
if (resp.status === 422 || resp.status === 500) {
const data = await resp.json()
alert(data.error || 'Unexpected error')
return Promise.reject(new Error(data.error))
}
const attachment = await resp.json()
window.history.replaceState({}, document.title, window.location.pathname)
this.$emit('update:model-value', attachment.uuid)
this.$emit('attached', attachment)
return resp
})
} else {
return Promise.resolve({})
}
},
startCheckout () {
this.isCreatingCheckout = true
fetch((this.baseUrl || '/embed').replace('/embed', '/api/stripe_payments'), {
method: 'POST',
body: JSON.stringify({
submitter_slug: this.submitterSlug,
field_uuid: this.field.uuid
}),
headers: { 'Content-Type': 'application/json' }
}).then(async (resp) => {
if (resp.status === 422 || resp.status === 500) {
const data = await resp.json()
alert(data.message || 'Unexpected error')
return Promise.reject(new Error(data.message))
}
const { url } = await resp.json()
const link = document.createElement('a')
link.href = url
if (url) {
link.click()
}
}).finally(() => {
this.isCreatingCheckout = false
})
}
}
}
</script>
+20 -20
View File
@@ -218,30 +218,30 @@ export default {
},
borderColors () {
return [
'border-red-500/50',
'border-sky-500/50',
'border-emerald-500/50',
'border-yellow-300/50',
'border-purple-600/50',
'border-pink-500/50',
'border-cyan-500/50',
'border-orange-500/50',
'border-lime-500/50',
'border-indigo-500/50'
'border-red-500/80',
'border-sky-500/80',
'border-emerald-500/80',
'border-yellow-300/80',
'border-purple-600/80',
'border-pink-500/80',
'border-cyan-500/80',
'border-orange-500/80',
'border-lime-500/80',
'border-indigo-500/80'
]
},
bgColors () {
return [
'bg-red-100/50',
'bg-sky-100/50',
'bg-emerald-100/50',
'bg-yellow-100/50',
'bg-purple-100/50',
'bg-pink-100/50',
'bg-cyan-100/50',
'bg-orange-100/50',
'bg-lime-100/50',
'bg-indigo-100/50'
'bg-red-100/80',
'bg-sky-100/80',
'bg-emerald-100/80',
'bg-yellow-100/80',
'bg-purple-100/80',
'bg-pink-100/80',
'bg-cyan-100/80',
'bg-orange-100/80',
'bg-lime-100/80',
'bg-indigo-100/80'
]
},
isSelected () {
+112 -3
View File
@@ -5,7 +5,8 @@
>
<div
v-if="$slots.buttons || withTitle"
class="flex justify-between py-1.5 items-center pr-4 sticky top-0 z-10"
class="flex justify-between py-1.5 items-center pr-4 top-0 z-10"
:class="{ sticky: withStickySubmitters }"
:style="{ backgroundColor }"
>
<div class="flex items-center space-x-3">
@@ -86,6 +87,7 @@
:class="$slots.buttons || withTitle ? 'md:max-h-[calc(100%_-_60px)]' : 'md:max-h-[100%]'"
>
<div
v-if="withDocumentsList"
ref="previews"
:style="{ 'display': isBreakpointLg ? 'none' : 'initial' }"
class="overflow-y-auto overflow-x-hidden w-52 flex-none pr-3 mt-0.5 pt-0.5 hidden lg:block"
@@ -216,6 +218,7 @@
</FieldType>
</div>
<div
v-if="withFieldsList"
class="relative w-80 flex-none mt-1 pr-4 pl-0.5 hidden md:block"
:class="drawField ? 'overflow-hidden' : 'overflow-auto'"
>
@@ -244,6 +247,7 @@
:fields="template.fields"
:submitters="template.submitters"
:selected-submitter="selectedSubmitter"
:default-submitters="defaultSubmitters"
:default-fields="defaultFields"
:with-sticky-submitters="withStickySubmitters"
:editable="editable"
@@ -301,6 +305,7 @@ export default {
baseFetch: this.baseFetch,
backgroundColor: this.backgroundColor,
withPhone: this.withPhone,
withPayment: this.withPayment,
selectedAreaRef: computed(() => this.selectedAreaRef)
}
},
@@ -349,6 +354,13 @@ export default {
required: false,
default: true
},
onUpload: {
type: Function,
required: false,
default () {
return () => {}
}
},
withStickySubmitters: {
type: Boolean,
required: false,
@@ -364,11 +376,26 @@ export default {
required: false,
default: true
},
withFieldsList: {
type: Boolean,
required: false,
default: true
},
withDocumentsList: {
type: Boolean,
required: false,
default: true
},
withPhone: {
type: Boolean,
required: false,
default: false
},
withPayment: {
type: Boolean,
required: false,
default: false
},
fetchOptions: {
type: Object,
required: false,
@@ -415,11 +442,20 @@ export default {
}
},
created () {
const existingSubmittersUuids = this.defaultSubmitters.map((name) => {
return this.template.submitters.find(e => e.name === name)?.uuid
})
this.defaultSubmitters.forEach((name, index) => {
const submitter = (this.template.submitters[index] ||= {})
submitter.name = name
submitter.uuid ||= v4()
if (existingSubmittersUuids.filter(Boolean).length) {
submitter.uuid = existingSubmittersUuids[index] || v4()
} else {
submitter.uuid ||= v4()
}
})
this.selectedSubmitter = this.template.submitters[0]
@@ -436,6 +472,12 @@ export default {
window.addEventListener('keydown', this.onKeyDown)
window.addEventListener('resize', this.onWindowResize)
this.$nextTick(() => {
if (document.location.search?.includes('stripe_connect_success')) {
document.querySelector('form[action="/auth/stripe_connect"]')?.closest('.dropdown')?.querySelector('label')?.focus()
}
})
},
unmounted () {
document.removeEventListener('keyup', this.onKeyUp)
@@ -461,6 +503,16 @@ export default {
field.options = [{ value: '', uuid: v4() }]
}
if (type === 'stamp') {
field.readonly = true
}
if (type === 'date') {
field.preferences = {
format: Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'
}
}
this.drawField = field
this.drawOption = null
},
@@ -569,6 +621,41 @@ export default {
area.option_uuid = this.drawOption.uuid
}
if (area.w === 0 || area.h === 0) {
const previousField = [...this.template.fields].reverse().find((f) => f.type === this.drawField.type && f !== this.drawField)
if (this.selectedField?.type === this.drawField.type) {
area.w = this.selectedAreaRef.value.w
area.h = this.selectedAreaRef.value.h
} else if (previousField?.areas?.length) {
area.w = previousField.areas[0].w
area.h = previousField.areas[0].h
} else {
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
const pageMask = documentRef.pageRefs[area.page].$refs.mask
if (this.drawField.type === 'checkbox' || this.drawOption) {
area.w = pageMask.clientWidth / 30 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 30 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight)
} else if (this.drawField.type === 'image') {
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 5 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight)
} else if (this.drawField.type === 'signature' || this.drawField.type === 'stamp') {
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 5 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight) / 2
} else if (this.drawField.type === 'initials') {
area.w = pageMask.clientWidth / 10 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 35 / pageMask.clientWidth)
} else {
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 35 / pageMask.clientWidth)
}
}
area.x -= area.w / 2
area.y -= area.h / 2
}
this.drawField.areas ||= []
this.drawField.areas.push(area)
@@ -637,6 +724,16 @@ export default {
field.options = [{ value: '', uuid: v4() }]
}
if (field.type === 'stamp') {
field.readonly = true
}
if (field.type === 'date') {
field.preferences = {
format: Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'
}
}
const fieldArea = {
x: (area.x - 6) / area.maskW,
y: area.y / area.maskH,
@@ -663,7 +760,7 @@ export default {
w: area.maskW / 5 / area.maskW,
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH)
}
} else if (field.type === 'signature') {
} else if (field.type === 'signature' || field.type === 'stamp') {
baseArea = {
w: area.maskW / 5 / area.maskW,
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH) / 2
@@ -707,6 +804,14 @@ export default {
this.scrollIntoDocument(schema[0])
})
if (this.template.name === 'New Document') {
this.template.name = this.template.schema[0].name
}
if (this.onUpload) {
this.onUpload(this.template)
}
this.save()
},
updateName (value) {
@@ -740,6 +845,10 @@ export default {
})
})
if (this.onUpload) {
this.onUpload(this.template)
}
this.save()
},
moveDocument (item, direction) {
+102 -5
View File
@@ -65,8 +65,12 @@
:stroke-width="1.6"
/>
</button>
<PaymentSettings
v-if="field.type === 'payment'"
:field="field"
/>
<span
v-if="!defaultField"
v-else-if="!defaultField"
class="dropdown dropdown-end"
>
<label
@@ -107,6 +111,33 @@
Default value
</label>
</div>
<div
v-if="field.type === 'date'"
class="py-1.5 px-1 relative"
@click.stop
>
<select
v-model="field.preferences.format"
placeholder="Format"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0"
@change="save"
>
<option
v-for="format in dateFormats"
:key="format"
:value="format"
>
{{ formatDate(new Date(), format) }}
</option>
</select>
<label
:style="{ backgroundColor: backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
Format
</label>
</div>
<li
v-if="field.type != 'phone'"
@click.stop
@@ -216,6 +247,7 @@
class="w-full input input-primary input-xs text-sm bg-transparent !pr-7 -mr-6"
type="text"
required
:placeholder="`Option ${index + 1}`"
@blur="save"
>
<button
@@ -233,6 +265,7 @@
v-else
v-model="option.value"
class="w-full input input-primary input-xs text-sm bg-transparent"
:placeholder="`Option ${index + 1}`"
type="text"
required
@focus="maybeFocusOnOptionArea(option)"
@@ -261,6 +294,7 @@
<script>
import Contenteditable from './contenteditable'
import FieldType from './field_type'
import PaymentSettings from './payment_settings'
import { IconShape, IconNewSection, IconTrashX, IconCopy, IconSettings } from '@tabler/icons-vue'
import { v4 } from 'uuid'
@@ -270,6 +304,7 @@ export default {
Contenteditable,
IconSettings,
IconShape,
PaymentSettings,
IconNewSection,
IconTrashX,
IconCopy,
@@ -295,23 +330,85 @@ export default {
emits: ['set-draw', 'remove', 'scroll-to'],
data () {
return {
isNameFocus: false
isNameFocus: false,
showPaymentModal: false
}
},
computed: {
fieldNames: FieldType.computed.fieldNames,
dateFormats () {
return [
'MM/DD/YYYY',
'DD/MM/YYYY',
'YYYY-MM-DD',
'DD-MM-YYYY',
'DD.MM.YYYY',
'MMM D, YYYY',
'MMMM D, YYYY',
'D MMM YYYY',
'D MMMM YYYY'
]
},
defaultName () {
const typeIndex = this.template.fields.filter((f) => f.type === this.field.type).indexOf(this.field)
if (this.field.type === 'payment' && this.field.preferences?.price) {
const { price, currency } = this.field.preferences || {}
const suffix = { multiple: 'Select', radio: 'Group' }[this.field.type] || 'Field'
const formattedPrice = new Intl.NumberFormat([], {
style: 'currency',
currency
}).format(price)
return `${this.fieldNames[this.field.type]} ${suffix} ${typeIndex + 1}`
return `${this.fieldNames[this.field.type]} ${formattedPrice}`
} else {
const typeIndex = this.template.fields.filter((f) => f.type === this.field.type).indexOf(this.field)
const suffix = { multiple: 'Select', radio: 'Group' }[this.field.type] || 'Field'
return `${this.fieldNames[this.field.type]} ${suffix} ${typeIndex + 1}`
}
},
areas () {
return this.field.areas || []
}
},
created () {
this.field.preferences ||= {}
if (this.field.type === 'date') {
this.field.preferences.format ||=
(Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY')
}
},
methods: {
formatDate (date, format) {
const monthFormats = {
M: 'numeric',
MM: '2-digit',
MMM: 'short',
MMMM: 'long'
}
const dayFormats = {
D: 'numeric',
DD: '2-digit'
}
const yearFormats = {
YYYY: 'numeric',
YY: '2-digit'
}
const parts = new Intl.DateTimeFormat([], {
day: dayFormats[format.match(/D+/)],
month: monthFormats[format.match(/M+/)],
year: yearFormats[format.match(/Y+/)]
}).formatToParts(date)
return format
.replace(/D+/, parts.find((p) => p.type === 'day').value)
.replace(/M+/, parts.find((p) => p.type === 'month').value)
.replace(/Y+/, parts.find((p) => p.type === 'year').value)
},
copyToAllPages (field) {
const areaString = JSON.stringify(field.areas[0])
@@ -25,7 +25,7 @@
v-for="(icon, type) in fieldIcons"
:key="type"
>
<li v-if="withPhone || type !== 'phone'">
<li v-if="withPhone || withPayment || !['phone', 'payment'].includes(type)">
<a
href="#"
class="text-sm py-1 px-2"
@@ -46,11 +46,11 @@
</template>
<script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper } from '@tabler/icons-vue'
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp } from '@tabler/icons-vue'
export default {
name: 'FiledTypeDropdown',
inject: ['withPhone'],
inject: ['withPhone', 'withPayment'],
props: {
modelValue: {
type: String,
@@ -92,6 +92,8 @@ export default {
multiple: 'Multiple',
radio: 'Radio',
cells: 'Cells',
stamp: 'Stamp',
payment: 'Payment',
phone: 'Phone'
}
},
@@ -103,11 +105,13 @@ export default {
date: IconCalendarEvent,
image: IconPhoto,
file: IconPaperclip,
select: IconSelect,
checkbox: IconCheckbox,
radio: IconCircleDot,
select: IconSelect,
multiple: IconChecks,
cells: IconColumns3,
stamp: IconRubberStamp,
payment: IconCreditCard,
phone: IconPhoneCheck
}
}
+21 -6
View File
@@ -5,7 +5,7 @@
class="w-full rounded-lg"
:class="{ 'bg-base-100': withStickySubmitters }"
:submitters="submitters"
:editable="editable"
:editable="editable && !defaultSubmitters.length"
@new-submitter="save"
@remove="removeSubmitter"
@name-change="save"
@@ -71,7 +71,7 @@
:key="type"
>
<button
v-if="withPhone || type != 'phone'"
v-if="(withPhone || type != 'phone') && (withPayment || type != 'payment')"
draggable="true"
class="group flex items-center justify-center border border-dashed border-base-300 hover:border-base-content/20 w-full rounded relative"
:style="{ backgroundColor: backgroundColor }"
@@ -90,8 +90,8 @@
</div>
</button>
<div
v-else
class="tooltip tooltip-bottom-end flex"
v-else-if="type == 'phone'"
class="tooltip tooltip-bottom flex"
data-tip="Unlock SMS-verified phone number field with paid plan. Use text field for phone numbers without verification."
>
<a
@@ -152,7 +152,7 @@ export default {
IconDrag,
IconLock
},
inject: ['save', 'backgroundColor', 'withPhone'],
inject: ['save', 'backgroundColor', 'withPhone', 'withPayment'],
props: {
fields: {
type: Array,
@@ -168,6 +168,11 @@ export default {
required: false,
default: () => []
},
defaultSubmitters: {
type: Array,
required: false,
default: () => []
},
withStickySubmitters: {
type: Boolean,
required: false,
@@ -256,9 +261,19 @@ export default {
field.options = [{ value: '', uuid: v4() }]
}
if (type === 'stamp') {
field.readonly = true
}
if (type === 'date') {
field.preferences = {
format: Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'
}
}
this.fields.push(field)
if (['signature', 'initials', 'cells'].includes(type)) {
if (['signature', 'initials', 'cells', 'stamp'].includes(type)) {
this.$emit('set-draw', { field })
}
@@ -0,0 +1,237 @@
<template>
<span
class="dropdown dropdown-end"
:class="{ 'dropdown-open': (!field.preferences?.price || !isConnected) && !isLoading }"
>
<label
tabindex="0"
title="Settings"
class="cursor-pointer text-transparent group-hover:text-base-content"
>
<IconSettings
:width="18"
:stroke-width="1.6"
/>
</label>
<ul
tabindex="0"
class="mt-1.5 dropdown-content menu menu-xs p-2 shadow bg-base-100 rounded-box w-52 z-10"
draggable="true"
@dragstart.prevent.stop
@click="closeDropdown"
>
<div
class="py-1.5 px-1 relative"
@click.stop
>
<select
v-model="field.preferences.currency"
placeholder="Price"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0"
@change="save"
>
<option
v-for="currency in currencies"
:key="currency"
:value="currency"
>
{{ currency }}
</option>
</select>
<label
:style="{ backgroundColor: backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
Currency
</label>
</div>
<div
class="py-1.5 px-1 relative"
@click.stop
>
<input
v-model="field.preferences.price"
type="number"
placeholder="Price"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0"
@blur="save"
>
<label
v-if="field.preferences.price"
:style="{ backgroundColor: backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
Price
</label>
</div>
<div
v-if="!isConnected || isOauthSuccess"
class="py-1.5 px-1 relative"
@click.stop
>
<div
v-if="isConnected && isOauthSuccess"
class="text-sm text-center"
>
<IconCircleCheck
class="inline text-green-600 w-4 h-4"
/>
Stripe Connected
</div>
<form
v-if="!isConnected"
data-turbo="false"
action="/auth/stripe_connect"
accept-charset="UTF-8"
target="_blank"
method="post"
>
<input
type="hidden"
name="state"
:value="oauthState"
autocomplete="off"
>
<input
type="hidden"
name="redirect_uri"
:value="redirectUri"
autocomplete="off"
>
<input
type="hidden"
name="scope"
value="read_write"
autocomplete="off"
>
<input
type="hidden"
name="authenticity_token"
:value="authenticityToken"
autocomplete="off"
>
<button
type="submit"
:disabled="isLoading"
class="btn bg-[#7B73FF] hover:bg-[#0A2540] btn-sm text-white w-full"
>
<span
v-if="isLoading"
class="flex items-center space-x-1"
>
<IconInnerShadowTop
class="w-4 h-4 animate-spin inline"
/>
<span>
Connect Stripe
</span>
</span>
<span
v-else
class="flex items-center space-x-1"
>
<IconBrandStripe
class="w-4 h-4 inline"
/>
<span>
Connect Stripe
</span>
</span>
</button>
</form>
</div>
</ul>
</span>
</template>
<script>
import { IconSettings, IconCircleCheck, IconBrandStripe, IconInnerShadowTop } from '@tabler/icons-vue'
import { ref } from 'vue'
const isConnected = ref(false)
export default {
name: 'PaymentSettings',
components: {
IconSettings,
IconCircleCheck,
IconInnerShadowTop,
IconBrandStripe
},
inject: ['backgroundColor', 'save'],
props: {
field: {
type: Object,
required: true
}
},
data () {
return {
isLoading: false
}
},
computed: {
isConnected: () => isConnected.value,
isOauthSuccess () {
return document.location.search?.includes('stripe_connect_success')
},
redirectUri () {
return document.location.origin + '/auth/stripe_connect/callback'
},
currencies () {
return ['USD', 'EUR', 'GBP']
},
authenticityToken () {
return document.querySelector('meta[name="csrf-token"]')?.content
},
oauthState () {
const params = new URLSearchParams('')
params.set('redir', document.location.href)
return params.toString()
},
defaultCurrency () {
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone
if (userTimezone.startsWith('Europe')) {
return 'EUR'
} else if (userTimezone.includes('London') || userTimezone.includes('Belfast')) {
return 'GBP'
} else {
return 'USD'
}
}
},
created () {
this.field.preferences ||= {}
},
mounted () {
this.field.preferences.currency ||= this.defaultCurrency
if (!this.isConnected) {
this.checkStatus()
}
},
methods: {
checkStatus () {
this.isLoading = true
fetch('/api/stripe_connect').then(async (resp) => {
const { status } = await resp.json()
if (status === 'connected') {
isConnected.value = true
}
}).finally(() => {
this.isLoading = false
})
},
closeDropdown () {
document.activeElement.blur()
}
}
}
</script>
@@ -67,6 +67,7 @@
<Contenteditable
:model-value="item.name"
:icon-width="16"
:editable="editable"
style="max-width: 95%"
class="mx-auto"
@update:model-value="onUpdateName"
+14 -7
View File
@@ -25,10 +25,12 @@ class SubmitterMailer < ApplicationMailer
DEFAULT_INVITATION_SUBJECT
end
mail(to: @submitter.friendly_name,
from: from_address_for_submitter(submitter),
subject:,
reply_to: (submitter.submission.created_by_user || submitter.template.author)&.friendly_name)
mail(
to: @submitter.friendly_name,
from: from_address_for_submitter(submitter),
subject:,
reply_to: (submitter.submission.created_by_user || submitter.template.author)&.friendly_name&.sub(/\+\w+@/, '@')
)
end
def completed_email(submitter, user, bcc: nil)
@@ -53,7 +55,7 @@ class SubmitterMailer < ApplicationMailer
end
mail(from: from_address_for_submitter(submitter),
to: user.friendly_name,
to: user.role == 'integration' ? user.friendly_name.sub(/\+\w+@/, '@') : user.friendly_name,
bcc:,
subject:)
end
@@ -98,7 +100,7 @@ class SubmitterMailer < ApplicationMailer
attachments[submitter.submission.audit_trail.filename.to_s] = audit_trail_data if audit_trail_data
file_fields = submitter.submission.template_fields.select { |e| e['type'] == 'file' }
file_fields = submitter.submission.template_fields.select { |e| e['type'].in?(%w[file payment]) }
if file_fields.pluck('submitter_uuid').uniq.size == 1
storage_attachments =
@@ -125,6 +127,11 @@ class SubmitterMailer < ApplicationMailer
end
def from_address_for_submitter(submitter)
submitter.submission.created_by_user&.friendly_name || submitter.submission.template.author.friendly_name
if submitter.submission.source.in?(%w[api embed]) &&
(from_email = AccountConfig.find_by(account: submitter.account, key: 'integration_from_email')&.value.presence)
from_email
else
(submitter.submission.created_by_user || submitter.submission.template.author).friendly_name
end
end
end
+1
View File
@@ -29,6 +29,7 @@ class AccountConfig < ApplicationRecord
ALLOW_TYPED_SIGNATURE = 'allow_typed_signature'
SUBMITTER_REMAILERS = 'submitter_reminders'
FORM_COMPLETED_BUTTON_KEY = 'form_completed_button'
ESIGNING_PREFERENCE_KEY = 'esigning_preference'
DEFAULT_VALUES = {
SUBMITTER_INVITATION_EMAIL_KEY => {
+4
View File
@@ -71,6 +71,10 @@ class Submission < ApplicationRecord
preserved: 'preserved'
}, scope: false, prefix: true
def archived_at
deleted_at
end
def audit_trail_url
return if audit_trail.blank?
-4
View File
@@ -59,10 +59,6 @@ class User < ApplicationRecord
devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable
if Docuseal.multitenant?
devise :registerable, :omniauthable, omniauth_providers: %i[google_oauth2 microsoft_office365 github]
end
attribute :role, :string, default: ADMIN_ROLE
attribute :uuid, :string, default: -> { SecureRandom.uuid }
+1
View File
@@ -33,5 +33,6 @@
<% end %>
</div>
<% end %>
<%= render 'extra_links' %>
<%= render 'devise/shared/links' %>
</div>
+22 -1
View File
@@ -121,7 +121,28 @@
<%= f.button button_title(title: 'Save', disabled_with: 'Updating'), class: 'base-button' %>
</div>
<% end %>
</div>
<% end %>
<% if !Docuseal.multitenant? %>
<% account_config = AccountConfig.where(account: current_account, key: AccountConfig::ESIGNING_PREFERENCE_KEY).first_or_initialize(value: 'multiple') %>
<% if can?(:manage, account_config) %>
<div class="px-1 mt-8 max-w-xl">
<div class="flex justify-between items-end mb-4 mt-8">
<h2 class="text-3xl font-bold">Preferences</h2>
</div>
<% if can?(:manage, account_config) %>
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
<%= f.hidden_field :key %>
<div class="flex items-center justify-between py-2.5">
<span>
Apply multiple PDF digital signatures in the document per each signer
</span>
<%= f.check_box :value, { class: 'toggle', checked: account_config.value == 'multiple', onchange: 'this.form.requestSubmit()' }, 'multiple', 'single' %>
</div>
<% end %>
<% end %>
</div>
<% end %>
</div>
<% end %>
</div>
</div>
@@ -5,7 +5,7 @@
<%= render 'email_stats' %>
<%= render 'bcc_form', config: @bcc_config %>
<div class="flex justify-between items-end mb-4 mt-8">
<h2 class="text-3xl font-bold">Signature Email Reminders</h2>
<h2 class="text-3xl font-bold">Sign Request Email Reminders</h2>
</div>
<%= render 'reminder_banner' %>
<%= render 'reminder_form', config: @reminder_config %>
+1 -1
View File
@@ -1,7 +1,7 @@
<div class="flex flex-wrap space-y-4 md:flex-nowrap md:space-y-0">
<%= render 'shared/settings_nav' %>
<div class="flex-grow max-w-xl mx-auto">
<h1 class="text-4xl font-bold mb-4">SMS</h1>
<h1 class="text-4xl font-bold mb-4">SAML SSO</h1>
<%= render 'placeholder' %>
</div>
<div class="w-0 md:w-52"></div>
+3 -3
View File
@@ -1,7 +1,7 @@
<field-value class="flex absolute text-[1.5vw] lg:text-base" style="width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%">
<% if field['type'].in?(['signature', 'image', 'initials']) %>
<% if field['type'].in?(['signature', 'image', 'initials', 'stamp']) %>
<img class="object-contain mx-auto" src="<%= attachments_index[value].url %>" loading="lazy">
<% elsif field['type'] == 'file' %>
<% elsif field['type'].in?(['file', 'payment']) %>
<autosize-field></autosize-field>
<div class="px-0.5 flex flex-col justify-center">
<% Array.wrap(value).each do |val| %>
@@ -34,7 +34,7 @@
<% elsif field['type'] == 'date' %>
<autosize-field></autosize-field>
<div class="flex items-center px-0.5">
<%= l(Date.parse(value), format: :long, locale: local_assigns[:locale]) %>
<%= TimeUtils.format_date_string(value, field.dig('preferences', 'format'), local_assigns[:locale]) %>
</div>
<% else %>
<autosize-field></autosize-field>
+3 -3
View File
@@ -146,9 +146,9 @@
<div class="w-full bg-base-300 py-1">
<img class="object-contain mx-auto" height="<%= attachments_index[value].metadata['height'] %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy">
</div>
<% elsif field['type'] == 'image' %>
<% elsif field['type'].in?(['image', 'stamp']) %>
<img class="object-contain mx-auto max-h-28" height="<%= attachments_index[value].metadata['height'] %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy">
<% elsif field['type'] == 'file' %>
<% elsif field['type'] == 'file' || field['type'] == 'payment' %>
<div class="flex flex-col justify-center">
<% Array.wrap(value).each do |val| %>
<a target="_blank" class="flex items-center space-x-1" href="<%= attachments_index[val].url %>">
@@ -162,7 +162,7 @@
<% elsif field['type'] == 'checkbox' %>
<%= svg_icon('check', class: 'w-6 h-6') %>
<% elsif field['type'] == 'date' %>
<%= l(Date.parse(value), locale: @submission.template.account.locale, format: :long) %>
<%= TimeUtils.format_date_string(value, field.dig('preferences', 'format'), @submission.template.account.locale) %>
<% else %>
<%= Array.wrap(value).join(', ') %>
<% end %>
@@ -1,4 +1,4 @@
<% data_attachments = attachments_index.values.select { |e| e.record_id == submitter.id }.to_json(only: %i[uuid], methods: %i[url filename content_type]) %>
<% data_fields = (submitter.submission.template_fields || submitter.submission.template.fields).select { |f| f['submitter_uuid'] == submitter.uuid }.to_json %>
<% configs = Submitters::FormConfigs.call(submitter) %>
<submission-form data-is-demo="<%= Docuseal.demo? %>" data-completed-button="<%= configs[:completed_button].to_json %>" data-go-to-last="<%= submitter.opened_at? %>" data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(Struct.new(:id).new(@submitter.submission.template.account_id)) %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-authenticity-token="<%= form_authenticity_token %>" data-values="<%= submitter.values.to_json %>" data-with-typed-signature="<%= configs[:with_typed_signature] %>"></submission-form>
<submission-form data-is-demo="<%= Docuseal.demo? %>" data-completed-button="<%= configs[:completed_button].to_json %>" data-go-to-last="<%= submitter.opened_at? %>" data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(Struct.new(:id).new(@submitter.submission.template.account_id)) %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-values="<%= submitter.values.to_json %>" data-with-typed-signature="<%= configs[:with_typed_signature] %>"></submission-form>
@@ -1,6 +1,7 @@
<% if @email_config || @body.present? %>
<%= auto_link(simple_format(h(ReplaceEmailVariables.call(@body.presence || @email_config.value['body'], submitter: @submitter)))) %>
<% if !(@body.presence || @email_config.value['body']).include?(ReplaceEmailVariables::SUBMITTER_LINK) %>
<% body = (@body.presence || @email_config.value['body']).to_s %>
<%= auto_link(simple_format(h(ReplaceEmailVariables.call(body, submitter: @submitter)))) %>
<% if !body.include?(ReplaceEmailVariables::SUBMITTER_LINK) && !body.include?(ReplaceEmailVariables::SUBMITTER_ID) && !body.include?(ReplaceEmailVariables::SUBMISSION_LINK) && !body.include?(ReplaceEmailVariables::TEMPLATE_ID) %>
<p><%= link_to nil, submit_form_url(slug: @submitter.slug, t: SubmissionEvents.build_tracking_param(@submitter, 'click_email')) %></p>
<% end %>
<% else %>
+1 -1
View File
@@ -6,7 +6,7 @@
<div class="pb-6 pt-1 space-y-1">
<p class="flex items-center space-x-1 text-xs text-base-content/60">
<%= svg_icon('user', class: 'w-4 h-4') %>
<span><%= template.author.full_name.presence || template.author.email %></span>
<span><%= template.author.full_name.presence || template.author.email.to_s.sub(/\+\w+@/, '@') %></span>
</p>
<p class="flex text-xs text-base-content/60">
<span class="flex items-center space-x-1 w-1/2">
+3 -18
View File
@@ -1,9 +1,5 @@
# frozen_string_literal: true
require_relative '../../lib/auth_with_token_strategy'
Warden::Strategies.add(:auth_token, AuthWithTokenStrategy)
Devise.otp_allowed_drift = 60.seconds
# Assuming you have not yet modified this file, each configuration option below
@@ -14,7 +10,6 @@ Devise.otp_allowed_drift = 60.seconds
#
# Use this hook to configure devise mailer, warden hooks and so forth.
# Many of these configuration options can be set straight in your model.
# rubocop:disable Metrics/BlockLength
Devise.setup do |config|
config.warden do |manager|
manager.default_strategies(scope: :user).unshift(:two_factor_authenticatable)
@@ -274,24 +269,13 @@ Devise.setup do |config|
# The default HTTP method used to sign out a resource. Default is :delete.
config.sign_out_via = :delete
# ==> OmniAuth
# Add a new OmniAuth provider. Check the wiki for more information on setting
# up on your models and hooks.
config.omniauth :google_oauth2, ENV.fetch('GOOGLE_CLIENT_ID', nil), ENV.fetch('GOOGLE_CLIENT_SECRET', nil), {}
config.omniauth :microsoft_office365, ENV.fetch('OFFICE365_CLIENT_ID', nil),
ENV.fetch('OFFICE365_CLIENT_SECRET', nil), {}
if ENV['GITHUB_CLIENT_ID']
config.omniauth :github, ENV.fetch('GITHUB_CLIENT_ID', nil), ENV.fetch('GITHUB_CLIENT_SECRET', nil), {}
end
# ==> Warden configuration
# If you want to use other strategies, that are not supported by Devise, or
# change the failure app, you can configure them inside the config.warden block.
#
config.warden do |manager|
# manager.intercept_401 = false
manager.default_strategies(scope: :user).unshift(:auth_token)
# manager.default_strategies(scope: :user).unshift(:auth_token)
end
# ==> Mountable engine configurations
@@ -322,5 +306,6 @@ Devise.setup do |config|
# When set to false, does not sign a user in automatically after their password is
# changed. Defaults to true, so a user is signed in automatically after changing a password.
# config.sign_in_after_change_password = true
ActiveSupport.run_load_hooks(:devise_config, config)
end
# rubocop:enable Metrics/BlockLength
+2 -2
View File
@@ -10,7 +10,7 @@ Rails.application.routes.draw do
path: '/', only: %i[sessions passwords omniauth_callbacks],
controllers: begin
options = { sessions: 'sessions', passwords: 'passwords' }
options[:omniauth_callbacks] = 'omniauth_callbacks' if Docuseal.multitenant?
options[:omniauth_callbacks] = 'omniauth_callbacks' if User.devise_modules.include?(:omniauthable)
options
end
@@ -35,7 +35,7 @@ Rails.application.routes.draw do
resources :template_folders_autocomplete, only: %i[index]
resources :submitter_email_clicks, only: %i[create]
resources :submitter_form_views, only: %i[create]
resources :submitters, only: %i[index show]
resources :submitters, only: %i[index show update]
resources :submissions, only: %i[index show create destroy] do
collection do
resources :emails, only: %i[create], controller: 'submissions', as: :submissions_emails
-19
View File
@@ -1,19 +0,0 @@
# frozen_string_literal: true
class AuthWithTokenStrategy < Devise::Strategies::Base
def valid?
request.headers['X-Auth-Token'].present?
end
def authenticate!
sha256 = Digest::SHA256.hexdigest(request.headers['X-Auth-Token'])
user = User.joins(:access_token).find_by(access_token: { sha256: })
if user
success!(user)
else
fail!('Invalid token')
end
end
end
+15 -3
View File
@@ -9,14 +9,26 @@ module PdfIcons
module_function
def check_io
@check_io ||= StringIO.new(PATH.join('check.png').read)
StringIO.new(check_data)
end
def paperclip_io
@paperclip_io ||= StringIO.new(PATH.join('paperclip.png').read)
StringIO.new(paperclip_data)
end
def logo_io
@logo_io ||= StringIO.new(PATH.join('logo.png').read)
StringIO.new(logo_data)
end
def check_data
@check_data ||= PATH.join('check.png').read
end
def paperclip_data
@paperclip_data ||= PATH.join('paperclip.png').read
end
def logo_data
@logo_data ||= PATH.join('logo.png').read
end
end
+47
View File
@@ -0,0 +1,47 @@
# frozen_string_literal: true
class PdfProcessor < HexaPDF::Content::Processor
attr_reader :result, :contents
attr_accessor :handler
def initialize(page)
super
@contents = ''.b
@result = []
@serializer = HexaPDF::Serializer.new
end
def process(operator, operands = [])
super
contents << @operators[operator].serialize(
@serializer,
*handler.call(self, operator, operands)
)
end
def self.call(data, pdf_handler, result_handler, acc = {})
doc = HexaPDF::Document.new(io: StringIO.new(data))
doc.pages.each do |page|
processor = PdfProcessor.new(page)
processor.handler = pdf_handler
page.process_contents(processor)
page.contents = processor.contents
processor.result.each do |item|
result_handler.call(item, page, acc)
end
end
new_io = StringIO.new
doc.write(new_io)
[new_io.tap(&:rewind).read, acc]
end
end
+10 -2
View File
@@ -1,24 +1,31 @@
# frozen_string_literal: true
module ReplaceEmailVariables
TEMAPLTE_NAME = '{{template.name}}'
TEMPLATE_NAME = '{{template.name}}'
TEMPLATE_ID = '{{template.id}}'
SUBMITTER_LINK = '{{submitter.link}}'
ACCOUNT_NAME = '{{account.name}}'
SUBMITTER_EMAIL = '{{submitter.email}}'
SUBMITTER_NAME = '{{submitter.name}}'
SUBMITTER_ID = '{{submitter.id}}'
SUBMISSION_LINK = '{{submission.link}}'
SUBMISSION_ID = '{{submission.id}}'
SUBMISSION_SUBMITTERS = '{{submission.submitters}}'
DOCUMENTS_LINKS = '{{documents.links}}'
DOCUMENTS_LINK = '{{documents.link}}'
module_function
# rubocop:disable Metrics
def call(text, submitter:, tracking_event_type: 'click_email')
submitter_link = build_submitter_link(submitter, tracking_event_type)
submission_link = build_submission_link(submitter.submission) if submitter.submission
text = text.gsub(TEMAPLTE_NAME, submitter.template.name) if submitter.template
text = text.gsub(TEMPLATE_NAME, submitter.template.name) if submitter.template
text = text.gsub(TEMPLATE_ID, submitter.template.id.to_s) if submitter.template
text = text.gsub(SUBMITTER_ID, submitter.id.to_s)
text = text.gsub(SUBMISSION_ID, submitter.submission.id.to_s) if submitter.submission
text = text.gsub(SUBMITTER_EMAIL, submitter.email) if submitter.email
text = text.gsub(SUBMITTER_NAME, submitter.name || submitter.email || submitter.phone)
text = text.gsub(SUBMITTER_LINK, submitter_link)
@@ -33,6 +40,7 @@ module ReplaceEmailVariables
text
end
# rubocop:enable Metrics
def build_documents_links_text(submitter)
Rails.application.routes.url_helpers.submissions_preview_url(
+3 -3
View File
@@ -39,11 +39,11 @@ module Submissions
end
end
def maybe_set_template_fields(submission, submitters_attrs)
template_fields = submission.template.fields.deep_dup
def maybe_set_template_fields(submission, submitters_attrs, submitter_uuid: nil)
template_fields = (submission.template_fields || submission.template.fields).deep_dup
submitters_attrs.each_with_index do |submitter_attrs, index|
submitter_uuid = find_submitter_uuid(submission.template, submitter_attrs, index)
submitter_uuid ||= find_submitter_uuid(submission.template, submitter_attrs, index)
process_readonly_fields_param(submitter_attrs[:readonly_fields], template_fields, submitter_uuid)
+30 -8
View File
@@ -22,6 +22,12 @@ module Submissions
VERIFIED_TEXT = 'Verified'
UNVERIFIED_TEXT = 'Unverified'
CURRENCY_SYMBOLS = {
'USD' => '$',
'EUR' => '€',
'GBP' => '£'
}.freeze
module_function
# rubocop:disable Metrics
@@ -70,9 +76,11 @@ module Submissions
composer.column(columns: 1) do |column|
column.text("Envelope ID: #{submission.id}", font_size: 12, padding: [20, 0, 10, 0], position: :float)
column.formatted_text([
{ link: verify_url, text: 'Verify', style: :link }
], font_size: 9, padding: [22, 0, 10, 0], position: :float, align: :right)
unless submission.source_embed?
column.formatted_text([{ link: verify_url, text: 'Verify', style: :link }],
font_size: 9, padding: [22, 0, 10, 0], position: :float, align: :right)
end
end
composer.draw_box(divider)
@@ -184,7 +192,7 @@ module Submissions
}
].compact_blank, line_spacing: 1.8, padding: [0, 0, 5, 0]
),
if field['type'].in?(%w[image signature initials])
if field['type'].in?(%w[image signature initials stamp])
attachment = submitter.attachments.find { |a| a.uuid == value }
image = Vips::Image.new_from_buffer(attachment.download, '').autorot
@@ -198,7 +206,15 @@ module Submissions
composer.image(io, width:, height:, margin: [0, 0, 10, 0])
composer.formatted_text_box([{ text: '' }])
elsif field['type'] == 'file'
elsif field['type'].in?(%w[file payment])
if field['type'] == 'payment'
unit = CURRENCY_SYMBOLS[field['preferences']['currency']]
price = ApplicationController.helpers.number_to_currency(field['preferences']['price'], unit:)
composer.formatted_text_box([{ text: "Paid #{price}\n" }], padding: [0, 0, 10, 0])
end
composer.formatted_text_box(
Array.wrap(value).map do |uuid|
attachment = submitter.attachments.find { |a| a.uuid == uuid }
@@ -212,7 +228,10 @@ module Submissions
elsif field['type'] == 'checkbox'
composer.formatted_text_box([{ text: value.to_s.titleize }], padding: [0, 0, 10, 0])
else
value = I18n.l(Date.parse(value), format: :long, locale: account.locale) if field['type'] == 'date'
if field['type'] == 'date'
value = TimeUtils.format_date_string(value, field.dig('preferences', 'format'), account.locale)
end
value = value.join(', ') if value.is_a?(Array)
composer.formatted_text_box([{ text: value.to_s.presence || 'n/a' }], padding: [0, 0, 10, 0])
@@ -238,7 +257,7 @@ module Submissions
if event.event_type.include?('sms') || event.event_type.include?('phone')
submitter.phone
else
(submitter.name || submitter.email || submitter.phone)
submitter.name || submitter.email || submitter.phone
end
]
)
@@ -258,7 +277,10 @@ module Submissions
certificate_chain: pkcs.ca_certs || []
}
sign_params[:timestamp_handler] = Submissions::TimestampHandler.new(tsa_url:) if tsa_url
if tsa_url
sign_params[:timestamp_handler] = Submissions::TimestampHandler.new(tsa_url:)
sign_params[:signature_size] = 10_000
end
composer.document.sign(io, **sign_params)
+65 -23
View File
@@ -11,7 +11,8 @@ module Submissions
end
INFO_CREATOR = "#{Docuseal.product_name} (#{Docuseal::PRODUCT_URL})".freeze
SIGN_REASON = 'Signed by %<email>s with DocuSeal.co'
SIGN_REASON = 'Signed by %<name>s with DocuSeal.co'
SIGN_SIGNLE_REASON = 'Digitally signed with DocuSeal.co'
TEXT_LEFT_MARGIN = 1
TEXT_TOP_MARGIN = 1
@@ -39,7 +40,12 @@ module Submissions
field.fetch('areas', []).each do |area|
pdf = pdfs_index[area['attachment_uuid']]
next if pdf.nil?
page = pdf.pages[area['page']]
next if page.nil?
page.rotate(0, flatten: true) if page[:Rotate] != 0
page[:Annots] ||= []
@@ -61,7 +67,7 @@ module Submissions
canvas.font(FONT_NAME, size: font_size)
case field['type']
when 'image', 'signature', 'initials'
when 'image', 'signature', 'initials', 'stamp'
attachment = submitter.attachments.find { |a| a.uuid == value }
image = Vips::Image.new_from_buffer(attachment.download, '').autorot
@@ -80,7 +86,7 @@ module Submissions
width: image.width * scale,
height: image.height * scale
)
when 'file'
when 'file', 'payment'
items = Array.wrap(value).each_with_object([]) do |uuid, acc|
attachment = submitter.attachments.find { |a| a.uuid == uuid }
@@ -159,7 +165,9 @@ module Submissions
height - (area['y'] * height))
end
else
value = I18n.l(Date.parse(value), format: :default, locale: account.locale) if field['type'] == 'date'
if field['type'] == 'date'
value = TimeUtils.format_date_string(value, field.dig('preferences', 'format'), account.locale)
end
text = HexaPDF::Layout::TextFragment.create(Array.wrap(value).join(', '), font: pdf.fonts.add(FONT_NAME),
font_size:)
@@ -193,9 +201,9 @@ module Submissions
submitter.submission.template_schema.map do |item|
pdf = pdfs_index[item['attachment_uuid']]
attachment = save_signed_pdf(pdf:, submitter:, pkcs:, tsa_url:,
uuid: item['attachment_uuid'],
name: item['name'])
attachment = save_pdf(pdf:, submitter:, pkcs:, tsa_url:,
uuid: item['attachment_uuid'],
name: item['name'])
image_pdfs << pdf if original_documents.find { |a| a.uuid == item['attachment_uuid'] }.image?
@@ -210,9 +218,10 @@ module Submissions
end
images_pdf_result =
save_signed_pdf(
save_pdf(
pdf: images_pdf,
submitter:,
tsa_url:,
pkcs:,
uuid: images_pdf_uuid(original_documents.select(&:image?)),
name: template.name
@@ -222,27 +231,34 @@ module Submissions
end
# rubocop:enable Metrics
def save_signed_pdf(pdf:, submitter:, pkcs:, tsa_url:, uuid:, name:)
def save_pdf(pdf:, submitter:, pkcs:, tsa_url:, uuid:, name:)
io = StringIO.new
pdf.trailer.info[:Creator] = info_creator
sign_params = {
reason: sign_reason(submitter.email),
certificate: pkcs.certificate,
key: pkcs.key,
certificate_chain: pkcs.ca_certs || []
}
sign_reason = fetch_sign_reason(submitter)
sign_params[:timestamp_handler] = Submissions::TimestampHandler.new(tsa_url:) if tsa_url
if sign_reason
sign_params = {
reason: sign_reason,
certificate: pkcs.certificate,
key: pkcs.key,
certificate_chain: pkcs.ca_certs || []
}
pdf.sign(io, **sign_params)
if tsa_url
sign_params[:timestamp_handler] = Submissions::TimestampHandler.new(tsa_url:)
sign_params[:signature_size] = 10_000
end
pdf.sign(io, **sign_params)
else
pdf.write(io, incremental: true)
end
ActiveStorage::Attachment.create!(
uuid:,
blob: ActiveStorage::Blob.create_and_upload!(
io: StringIO.new(io.string), filename: "#{name}.pdf"
),
blob: ActiveStorage::Blob.create_and_upload!(io: StringIO.new(io.string), filename: "#{name}.pdf"),
metadata: { sha256: Base64.urlsafe_encode64(Digest::SHA256.digest(io.string)) },
name: 'documents',
record: submitter
@@ -263,7 +279,7 @@ module Submissions
Submissions::EnsureResultGenerated.call(latest_submitter) if latest_submitter
documents = latest_submitter&.documents&.preload(:blob).to_a.presence
documents ||= submitter.submission.template.documents.preload(:blob)
documents ||= submitter.submission.template_schema_documents.preload(:blob)
documents.to_h do |attachment|
pdf =
@@ -297,8 +313,34 @@ module Submissions
pdf
end
def sign_reason(email)
format(SIGN_REASON, email:)
def sign_reason(name)
format(SIGN_REASON, name:)
end
def single_sign_reason
SIGN_SIGNLE_REASON
end
def fetch_sign_reason(submitter)
reason_name = submitter.email || submitter.name || submitter.phone
return sign_reason(reason_name) if Docuseal.multitenant?
config =
if Docuseal.multitenant?
AccountConfig.where(account: submitter.account, key: AccountConfig::ESIGNING_PREFERENCE_KEY)
.first_or_initialize(value: 'multiple')
else
AccountConfig.where(key: AccountConfig::ESIGNING_PREFERENCE_KEY)
.first_or_initialize(value: 'multiple')
end
return sign_reason(reason_name) if config.value == 'multiple'
return single_sign_reason if !submitter.submission.submitters.exists?(completed_at: nil) &&
submitter.completed_at == submitter.submission.submitters.maximum(:completed_at)
nil
end
def info_creator
+61
View File
@@ -0,0 +1,61 @@
# frozen_string_literal: true
module Submissions
module NormalizeParamUtils
module_function
def normalize_submissions_params!(submissions_params, template)
attachments = []
Array.wrap(submissions_params).each do |submission|
submission[:submitters].each_with_index do |submitter, index|
_, new_attachments = normalize_submitter_params!(submitter, template, index)
attachments.push(*new_attachments)
end
end
[submissions_params, attachments]
end
def normalize_submitter_params!(submitter_params, template, index = nil, for_submitter: nil)
default_values = submitter_params[:values] || {}
submitter_params[:fields]&.each do |f|
default_values[f[:name]] = f[:default_value] if f[:default_value].present?
end
return submitter_params if default_values.blank?
values, new_attachments =
Submitters::NormalizeValues.call(template,
default_values,
submitter_name: submitter_params[:role] ||
template.submitters.dig(index, 'name'),
for_submitter:,
throw_errors: true)
submitter_params[:values] = values
[submitter_params, new_attachments]
end
def save_default_value_attachments!(attachments, submitters)
return if attachments.blank?
attachments_index = attachments.index_by(&:uuid)
submitters.each do |submitter|
submitter.values.each_value do |value|
attachment = attachments_index[value]
next unless attachment
attachment.record = submitter
attachment.save!
end
end
end
end
end
+89
View File
@@ -0,0 +1,89 @@
# frozen_string_literal: true
module Submitters
module CreateStampAttachment
WIDTH = 400
HEIGHT = 200
TRANSPARENT_PIXEL = "\x89PNG\r\n\u001A\n\u0000\u0000\u0000\rIHDR\u0000" \
"\u0000\u0000\u0001\u0000\u0000\u0000\u0001\b\u0004" \
"\u0000\u0000\u0000\xB5\u001C\f\u0002\u0000\u0000\u0000" \
"\vIDATx\xDAc\xFC_\u000F\u0000\u0002\x83\u0001\x804\xC3ڨ" \
"\u0000\u0000\u0000\u0000IEND\xAEB`\x82"
module_function
def call(submitter)
image = generate_stamp_image(submitter)
image_data = image.write_to_buffer('.png')
checksum = Digest::MD5.base64digest(image_data)
attachment = submitter.attachments.joins(:blob).find_by(blob: { checksum: })
attachment || ActiveStorage::Attachment.create!(
blob: ActiveStorage::Blob.create_and_upload!(io: StringIO.new(image_data), filename: 'stamp.png'),
metadata: { analyzed: true, identified: true, width: image.width, height: image.height },
name: 'attachments',
record: submitter
)
end
# rubocop:disable Metrics
def generate_stamp_image(submitter)
logo = Vips::Image.new_from_buffer(load_logo(submitter).read, '')
logo = logo.resize([WIDTH / logo.width.to_f, HEIGHT / logo.height.to_f].min)
base_layer = Vips::Image.black(WIDTH, HEIGHT).new_from_image([255, 255, 255]).copy(interpretation: :srgb)
opacity_layer = Vips::Image.new_from_buffer(TRANSPARENT_PIXEL, '').resize(WIDTH)
text = build_text_image(submitter)
text_layer = text.new_from_image([0, 0, 0]).copy(interpretation: :srgb)
text_layer = text_layer.bandjoin(text)
base_layer = base_layer.composite(logo, 'over',
x: (WIDTH - logo.width) / 2,
y: (HEIGHT - logo.height) / 2)
base_layer = base_layer.composite(opacity_layer, 'over')
base_layer.composite(text_layer, 'over',
x: (WIDTH - text_layer.width) / 2,
y: (HEIGHT - text_layer.height) / 2)
end
# rubocop:enable Metrics
def build_text_image(submitter)
time = I18n.l(submitter.completed_at.in_time_zone(submitter.account.timezone), format: :long,
locale: submitter.account.locale)
timezone = TimeUtils.timezone_abbr(submitter.account.timezone, submitter.completed_at)
name = if submitter.name.present? && submitter.email.present?
"#{submitter.name} #{submitter.email}"
else
submitter.name || submitter.email || submitter.phone
end
role = if submitter.submission.template_submitters.size > 1
item = submitter.submission.template_submitters.find { |e| e['uuid'] == submitter.uuid }
"Role: #{item['name']}\n"
else
''
end
text = %(<span size="90">Digitally signed by: <b>#{name}</b>\n#{role}#{time} #{timezone}</span>)
Vips::Image.text(text, width: WIDTH, height: HEIGHT)
end
def load_logo(_submitter)
PdfIcons.logo_io
end
end
end
+27 -13
View File
@@ -9,12 +9,8 @@ module Submitters
module_function
def call(template, values, submitter_name, throw_errors: false)
submitter =
template.submitters.find { |e| e['name'] == submitter_name } ||
raise(UnknownSubmitterName, "Unknown submitter: #{submitter_name}")
fields = template.fields.select { |e| e['submitter_uuid'] == submitter['uuid'] }
def call(template, values, submitter_name: nil, for_submitter: nil, throw_errors: false)
fields = fetch_fields(template, submitter_name:, for_submitter:)
fields_uuid_index = fields.index_by { |e| e['uuid'] }
fields_name_index = build_fields_index(fields)
@@ -31,7 +27,7 @@ module Submitters
next if key.blank?
if fields_uuid_index[key]['type'].in?(%w[initials signature image file])
new_value, new_attachments = normalize_attachment_value(value, template.account)
new_value, new_attachments = normalize_attachment_value(value, template.account, for_submitter)
attachments.push(*new_attachments)
@@ -44,27 +40,45 @@ module Submitters
[normalized_values, attachments]
end
def fetch_fields(template, submitter_name: nil, for_submitter: nil)
if submitter_name
submitter =
template.submitters.find { |e| e['name'] == submitter_name } ||
raise(UnknownSubmitterName, "Unknown submitter: #{submitter_name}")
end
fields = for_submitter&.submission&.template_fields || template.fields
fields.select { |e| e['submitter_uuid'] == (for_submitter&.uuid || submitter['uuid']) }
end
def build_fields_index(fields)
fields.index_by { |e| e['name'] }.merge(fields.index_by { |e| e['name'].to_s.parameterize.underscore })
end
def normalize_attachment_value(value, account)
def normalize_attachment_value(value, account, for_submitter = nil)
if value.is_a?(Array)
new_attachments = value.map { |v| build_attachment(v, account) }
new_attachments = value.map { |v| find_or_build_attachment(v, account, for_submitter) }
[new_attachments.map(&:uuid), new_attachments]
else
new_attachment = build_attachment(value, account)
new_attachment = find_or_build_attachment(value, account, for_submitter)
[new_attachment.uuid, new_attachment]
end
end
def build_attachment(value, account)
ActiveStorage::Attachment.new(
blob: find_or_create_blobs(account, value),
def find_or_build_attachment(value, account, for_submitter = nil)
blob = find_or_create_blobs(account, value)
attachment = for_submitter.attachments.find_by(blob_id: blob.id) if for_submitter
attachment ||= ActiveStorage::Attachment.new(
blob:,
name: 'attachments'
)
attachment
end
def find_or_create_blobs(account, url)
+2 -1
View File
@@ -19,7 +19,8 @@ module Submitters
serialize_params = {
include: {},
only: %i[id slug uuid name email phone completed_at application_key
opened_at sent_at created_at updated_at]
opened_at sent_at created_at updated_at],
methods: %i[status]
}
serialize_params[:include][:template] = { only: %i[id name created_at updated_at] } if with_template
+7 -1
View File
@@ -74,6 +74,12 @@ module Submitters
default_values = submitter.submission.template_fields.each_with_object({}) do |field, acc|
next if field['submitter_uuid'] != submitter.uuid
if field['type'] == 'stamp'
acc[field['uuid']] ||= Submitters::CreateStampAttachment.call(submitter).uuid
next
end
value = field['default_value']
next if value.blank?
@@ -99,7 +105,7 @@ module Submitters
def replace_default_variables(value, attrs, template, with_time: false)
return if value.blank?
value.gsub(VARIABLE_REGEXP) do |e|
value.to_s.gsub(VARIABLE_REGEXP) do |e|
case key = ::Regexp.last_match(1)
when 'time'
if with_time
+42
View File
@@ -0,0 +1,42 @@
# frozen_string_literal: true
module Templates
module Clone
module_function
def call(original_template, author:, application_key: nil, name: nil, folder_name: nil)
original_template_account = original_template.account
template = original_template_account.templates.new
template.application_key = application_key
template.author = author
template.name = name || "#{original_template.name} (Clone)"
template.assign_attributes(original_template.slice(:folder_id, :schema))
template.folder = TemplateFolders.find_or_create_by_name(author, folder_name) if folder_name.present?
submitter_uuids_replacements = {}
cloned_submitters = original_template['submitters'].deep_dup
cloned_fields = original_template['fields'].deep_dup
cloned_submitters.each do |submitter|
new_submitter_uuid = SecureRandom.uuid
submitter_uuids_replacements[submitter['uuid']] = new_submitter_uuid
submitter['uuid'] = new_submitter_uuid
end
cloned_fields.each do |field|
field['uuid'] = SecureRandom.uuid
field['submitter_uuid'] = submitter_uuids_replacements[field['submitter_uuid']]
end
template.assign_attributes(fields: cloned_fields, submitters: cloned_submitters)
template
end
end
end
+36 -11
View File
@@ -5,23 +5,48 @@ module Templates
module_function
def call(template:, original_template:)
original_template.documents.preload(:preview_images_attachments).each do |document|
schema_uuids_replacements = {}
cloned_schema = original_template['schema'].deep_dup
cloned_fields = template['fields'].deep_dup
cloned_schema.each do |schema_item|
new_schema_item_uuid = SecureRandom.uuid
schema_uuids_replacements[schema_item['attachment_uuid']] = new_schema_item_uuid
schema_item['attachment_uuid'] = new_schema_item_uuid
end
cloned_fields.each do |field|
next if field['areas'].blank?
field['areas'].each do |area|
area['attachment_uuid'] = schema_uuids_replacements[area['attachment_uuid']]
end
end
template.update!(schema: cloned_schema, fields: cloned_fields)
original_template.schema_documents.preload(:preview_images_attachments).each do |document|
new_document = ActiveStorage::Attachment.create!(
uuid: document.uuid,
uuid: schema_uuids_replacements[document.uuid],
blob_id: document.blob_id,
name: 'documents',
record: template
)
ApplicationRecord.no_touching do
document.preview_images_attachments.each do |preview_image|
ActiveStorage::Attachment.create!(
uuid: preview_image.uuid,
blob_id: preview_image.blob_id,
name: 'preview_images',
record: new_document
)
end
clone_document_preview_images_attachments(document:, new_document:)
end
end
def clone_document_preview_images_attachments(document:, new_document:)
ApplicationRecord.no_touching do
document.preview_images_attachments.each do |preview_image|
ActiveStorage::Attachment.create!(
blob_id: preview_image.blob_id,
name: 'preview_images',
record: new_document
)
end
end
end
+36
View File
@@ -1,6 +1,26 @@
# frozen_string_literal: true
module TimeUtils
MONTH_FORMATS = {
'M' => '%-m',
'MM' => '%m',
'MMM' => '%b',
'MMMM' => '%B'
}.freeze
DAY_FORMATS = {
'D' => '%-d',
'DD' => '%d'
}.freeze
YEAR_FORMATS = {
'YYYY' => '%Y',
'YY' => '%y'
}.freeze
DEFAULT_DATE_FORMAT_US = 'MM/DD/YYYY'
DEFAULT_DATE_FORMAT = 'DD/MM/YYYY'
module_function
def timezone_abbr(timezone, time = Time.current)
@@ -10,4 +30,20 @@ module TimeUtils
tz_info.abbreviation(time)
end
def format_date_string(string, format, locale)
date = Date.parse(string.to_s)
format ||= locale.to_s.ends_with?('US') ? DEFAULT_DATE_FORMAT_US : DEFAULT_DATE_FORMAT
i18n_format = format.sub(/D+/, DAY_FORMATS[format[/D+/]])
.sub(/M+/, MONTH_FORMATS[format[/M+/]])
.sub(/Y+/, YEAR_FORMATS[format[/Y+/]])
I18n.l(date, format: i18n_format, locale:)
rescue Date::Error => e
Rollbar.error(e) if defined?(Rollbar)
string
end
end
-24
View File
@@ -1,24 +0,0 @@
# frozen_string_literal: true
module Users
module_function
def from_omniauth(oauth)
user = User.find_by(email: oauth.info.email.to_s.downcase)
return user if user
case oauth['provider'].to_s
when 'google_oauth2'
User.new(email: oauth.info.email,
first_name: oauth.extra.id_info.given_name,
last_name: oauth.extra.id_info.family_name)
when 'microsoft_office365'
User.new(email: oauth.info.email,
first_name: oauth.info.first_name,
last_name: oauth.info.last_name)
when 'github'
User.new(email: oauth.info.email, first_name: oauth.info.name)
end
end
end
+16
View File
@@ -0,0 +1,16 @@
# frozen_string_literal: true
FactoryBot.define do
factory :template_folder do
account
author factory: %i[user]
name { Faker::Book.title }
trait :with_templates do
after(:create) do |template_folder|
create_list(:template, 2, folder: template_folder, account: template_folder.account)
end
end
end
end
+65
View File
@@ -74,5 +74,70 @@ RSpec.describe 'Template' do
expect(page).to have_current_path(edit_template_path(template), ignore_query: true)
end
end
it 'clone a template and move it to a new folder' do
click_link 'Clone'
within '#modal' do
fill_in 'template[name]', with: 'New Template Name'
click_link 'Change Folder'
fill_in 'folder_name', with: 'New Folder Name'
expect do
click_button 'Submit'
end.to change { Template.active.count }.by(1).and change { TemplateFolder.active.count }.by(1)
template = Template.last
expect(template.name).to eq('New Template Name')
expect(template.folder.name).to eq('New Folder Name')
expect(page).to have_current_path(edit_template_path(template), ignore_query: true)
end
end
it 'clones a template and moves it to an existing folder' do
template_folder = create(:template_folder, :with_templates, account:, author: user)
click_link 'Clone'
within '#modal' do
template_folder.reload
fill_in 'template[name]', with: 'New Template Name'
click_link 'Change Folder'
end
within '.autocomplete' do
find('div', text: template_folder.name).click
end
within '#modal' do
expect do
click_button 'Submit'
end.not_to(change { TemplateFolder.active.count })
end
template = Template.last
expect(template.name).to eq('New Template Name')
expect(template.folder.name).to eq(template_folder.name)
expect(page).to have_current_path(edit_template_path(template), ignore_query: true)
end
it 'moves a template' do
find('[data-tip="Move"]', visible: false).hover
find('[data-tip="Move"]').click
within '#modal' do
fill_in 'name', with: 'New Folder Name'
expect do
click_button 'Move'
end.to change { TemplateFolder.active.count }.by(1)
template_folder = TemplateFolder.last
expect(template_folder.name).to eq('New Folder Name')
expect(page).to have_current_path(template_path(template), ignore_query: true)
end
end
end
end