| @ -0,0 +1,35 @@ | ||||
| # frozen_string_literal: true | ||||
| 
 | ||||
| module Api | ||||
|   class SubmittersAutocompleteController < ApiBaseController | ||||
|     load_and_authorize_resource :submitter, parent: false | ||||
| 
 | ||||
|     SELECT_COLUMNS = %w[email phone name].freeze | ||||
|     LIMIT = 100 | ||||
| 
 | ||||
|     def index | ||||
|       submitters = search_submitters(@submitters) | ||||
| 
 | ||||
|       values = submitters.limit(LIMIT).group(SELECT_COLUMNS.join(', ')).pluck(SELECT_COLUMNS.join(', ')) | ||||
| 
 | ||||
|       attrs = values.map { |row| SELECT_COLUMNS.zip(row).to_h } | ||||
|       attrs = attrs.uniq { |e| e[params[:field]] } if params[:field].present? | ||||
| 
 | ||||
|       render json: attrs | ||||
|     end | ||||
| 
 | ||||
|     private | ||||
| 
 | ||||
|     def search_submitters(submitters) | ||||
|       if SELECT_COLUMNS.include?(params[:field]) | ||||
|         column = Submitter.arel_table[params[:field].to_sym] | ||||
| 
 | ||||
|         term = "%#{params[:q].downcase}%" | ||||
| 
 | ||||
|         submitters.where(column.lower.matches(term)) | ||||
|       else | ||||
|         Submitters.search(submitters, params[:q]) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| end | ||||
| @ -0,0 +1,34 @@ | ||||
| # frozen_string_literal: true | ||||
| 
 | ||||
| module Api | ||||
|   class SubmittersController < ApiBaseController | ||||
|     load_and_authorize_resource :submitter | ||||
| 
 | ||||
|     def index | ||||
|       submitters = Submitters.search(@submitters, params[:q]) | ||||
| 
 | ||||
|       submitters = submitters.where(application_key: params[:application_key]) if params[:application_key].present? | ||||
|       submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present? | ||||
| 
 | ||||
|       submitters = paginate( | ||||
|         submitters.preload(:template, :submission, :submission_events, | ||||
|                            documents_attachments: :blob, attachments_attachments: :blob) | ||||
|       ) | ||||
| 
 | ||||
|       render json: { | ||||
|         data: submitters.map { |s| Submitters::SerializeForApi.call(s, with_template: true, with_events: true) }, | ||||
|         pagination: { | ||||
|           count: submitters.size, | ||||
|           next: submitters.last&.id, | ||||
|           prev: submitters.first&.id | ||||
|         } | ||||
|       } | ||||
|     end | ||||
| 
 | ||||
|     def show | ||||
|       Submissions::EnsureResultGenerated.call(@submitter) if @submitter.completed_at? | ||||
| 
 | ||||
|       render json: Submitters::SerializeForApi.call(@submitter, with_template: true, with_events: true) | ||||
|     end | ||||
|   end | ||||
| end | ||||
| @ -0,0 +1,16 @@ | ||||
| # frozen_string_literal: true | ||||
| 
 | ||||
| module Api | ||||
|   class TemplateFoldersAutocompleteController < ApiBaseController | ||||
|     load_and_authorize_resource :template_folder, parent: false | ||||
| 
 | ||||
|     LIMIT = 100 | ||||
| 
 | ||||
|     def index | ||||
|       template_folders = @template_folders.joins(:templates).where(templates: { deleted_at: nil }).distinct | ||||
|       template_folders = TemplateFolders.search(template_folders, params[:q]).limit(LIMIT) | ||||
| 
 | ||||
|       render json: template_folders.as_json(only: %i[name deleted_at]) | ||||
|     end | ||||
|   end | ||||
| end | ||||
| @ -0,0 +1,22 @@ | ||||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class EnquiriesController < ApplicationController | ||||
|   skip_before_action :authenticate_user! | ||||
|   skip_authorization_check | ||||
| 
 | ||||
|   def create | ||||
|     if params[:talk_to_sales] == 'on' | ||||
|       Faraday.post(Docuseal::ENQUIRIES_URL, | ||||
|                    enquiry_params.merge(type: :talk_to_sales).to_json, | ||||
|                    'Content-Type' => 'application/json') | ||||
|     end | ||||
| 
 | ||||
|     head :ok | ||||
|   end | ||||
| 
 | ||||
|   private | ||||
| 
 | ||||
|   def enquiry_params | ||||
|     params.require(:user).permit(:email) | ||||
|   end | ||||
| end | ||||
| @ -0,0 +1,13 @@ | ||||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class PasswordsController < Devise::PasswordsController | ||||
|   class Current < ActiveSupport::CurrentAttributes | ||||
|     attribute :user | ||||
|   end | ||||
| 
 | ||||
|   def update | ||||
|     super do |resource| | ||||
|       Current.user = resource | ||||
|     end | ||||
|   end | ||||
| end | ||||
| @ -0,0 +1,46 @@ | ||||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class PreviewDocumentPageController < ActionController::API | ||||
|   include ActiveStorage::SetCurrent | ||||
| 
 | ||||
|   FORMAT = Templates::ProcessDocument::FORMAT | ||||
| 
 | ||||
|   def show | ||||
|     attachment = ActiveStorage::Attachment.find_by(uuid: params[:attachment_uuid]) | ||||
| 
 | ||||
|     return head :not_found unless attachment | ||||
| 
 | ||||
|     preview_image = attachment.preview_images.joins(:blob).find_by(blob: { filename: "#{params[:id]}#{FORMAT}" }) | ||||
| 
 | ||||
|     return redirect_to preview_image.url, allow_other_host: true if preview_image | ||||
| 
 | ||||
|     file_path = | ||||
|       if attachment.service.name == :disk | ||||
|         ActiveStorage::Blob.service.path_for(attachment.key) | ||||
|       else | ||||
|         find_or_create_document_tempfile_path(attachment) | ||||
|       end | ||||
| 
 | ||||
|     io = Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i) | ||||
| 
 | ||||
|     render plain: io.tap(&:rewind).read | ||||
|   end | ||||
| 
 | ||||
|   def find_or_create_document_tempfile_path(attachment) | ||||
|     file_path = "#{Dir.tmpdir}/#{attachment.uuid}" | ||||
| 
 | ||||
|     File.open(file_path, File::RDWR | File::CREAT, 0o644) do |f| | ||||
|       f.flock(File::LOCK_EX) | ||||
| 
 | ||||
|       # rubocop:disable Style/ZeroLengthPredicate | ||||
|       if f.size.zero? | ||||
|         f.binmode | ||||
| 
 | ||||
|         f.write(attachment.download) | ||||
|       end | ||||
|       # rubocop:enable Style/ZeroLengthPredicate | ||||
|     end | ||||
| 
 | ||||
|     file_path | ||||
|   end | ||||
| end | ||||
| @ -0,0 +1,16 @@ | ||||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class SsoSettingsController < ApplicationController | ||||
|   before_action :load_encrypted_config | ||||
|   authorize_resource :encrypted_config, only: :index | ||||
|   authorize_resource :encrypted_config, parent: false, except: :index | ||||
| 
 | ||||
|   def index; end | ||||
| 
 | ||||
|   private | ||||
| 
 | ||||
|   def load_encrypted_config | ||||
|     @encrypted_config = | ||||
|       EncryptedConfig.find_or_initialize_by(account: current_account, key: 'saml_configs') | ||||
|   end | ||||
| end | ||||
| @ -0,0 +1,29 @@ | ||||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class TemplateFoldersController < ApplicationController | ||||
|   load_and_authorize_resource :template_folder | ||||
| 
 | ||||
|   def show | ||||
|     @templates = @template_folder.templates.active.preload(:author).order(id: :desc) | ||||
|     @templates = Templates.search(@templates, params[:q]) | ||||
| 
 | ||||
|     @pagy, @templates = pagy(@templates, items: 12) | ||||
|   end | ||||
| 
 | ||||
|   def edit; end | ||||
| 
 | ||||
|   def update | ||||
|     if @template_folder != current_account.default_template_folder && | ||||
|        @template_folder.update(template_folder_params) | ||||
|       redirect_to folder_path(@template_folder), notice: 'Folder name has been updated' | ||||
|     else | ||||
|       redirect_to folder_path(@template_folder), alert: 'Unable to rename folder' | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   private | ||||
| 
 | ||||
|   def template_folder_params | ||||
|     params.require(:template_folder).permit(:name) | ||||
|   end | ||||
| end | ||||
| @ -0,0 +1,23 @@ | ||||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class TemplatesFoldersController < ApplicationController | ||||
|   load_and_authorize_resource :template | ||||
| 
 | ||||
|   def edit; end | ||||
| 
 | ||||
|   def update | ||||
|     @template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:name]) | ||||
| 
 | ||||
|     if @template.save | ||||
|       redirect_back(fallback_location: template_path(@template), notice: 'Document template has been moved') | ||||
|     else | ||||
|       redirect_back(fallback_location: template_path(@template), notice: 'Unable to move template into folder') | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   private | ||||
| 
 | ||||
|   def template_folder_params | ||||
|     params.require(:template_folder).permit(:name) | ||||
|   end | ||||
| end | ||||
| @ -0,0 +1,37 @@ | ||||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class UserSignaturesController < ApplicationController | ||||
|   before_action :load_user_config | ||||
|   authorize_resource :user_config | ||||
| 
 | ||||
|   def edit; end | ||||
| 
 | ||||
|   def update | ||||
|     file = params[:file] | ||||
| 
 | ||||
|     return redirect_to settings_profile_index_path, notice: 'Unable to save signature' if file.blank? | ||||
| 
 | ||||
|     blob = ActiveStorage::Blob.create_and_upload!(io: file.open, | ||||
|                                                   filename: file.original_filename, | ||||
|                                                   content_type: file.content_type) | ||||
| 
 | ||||
|     attachment = ActiveStorage::Attachment.create!( | ||||
|       blob:, | ||||
|       name: 'signature', | ||||
|       record: current_user | ||||
|     ) | ||||
| 
 | ||||
|     if @user_config.update(value: attachment.uuid) | ||||
|       redirect_to settings_profile_index_path, notice: 'Signature has been saved' | ||||
|     else | ||||
|       redirect_to settings_profile_index_path, notice: 'Unable to save signature' | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   private | ||||
| 
 | ||||
|   def load_user_config | ||||
|     @user_config = | ||||
|       UserConfig.find_or_initialize_by(user: current_user, key: UserConfig::SIGNATURE_KEY) | ||||
|   end | ||||
| end | ||||
| @ -0,0 +1,43 @@ | ||||
| import autocomplete from 'autocompleter' | ||||
| 
 | ||||
| export default class extends HTMLElement { | ||||
|   connectedCallback () { | ||||
|     autocomplete({ | ||||
|       input: this.input, | ||||
|       preventSubmit: this.dataset.submitOnSelect === 'true' ? 0 : 1, | ||||
|       minLength: 0, | ||||
|       showOnFocus: true, | ||||
|       onSelect: this.onSelect, | ||||
|       render: this.render, | ||||
|       fetch: this.fetch | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   onSelect = (item) => { | ||||
|     this.input.value = item.name | ||||
|   } | ||||
| 
 | ||||
|   fetch = (text, resolve) => { | ||||
|     const queryParams = new URLSearchParams({ q: text }) | ||||
| 
 | ||||
|     fetch('/api/template_folders_autocomplete?' + queryParams).then(async (resp) => { | ||||
|       const items = await resp.json() | ||||
| 
 | ||||
|       resolve(items) | ||||
|     }).catch(() => { | ||||
|       resolve([]) | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   render = (item) => { | ||||
|     const div = document.createElement('div') | ||||
| 
 | ||||
|     div.textContent = item.name | ||||
| 
 | ||||
|     return div | ||||
|   } | ||||
| 
 | ||||
|   get input () { | ||||
|     return this.querySelector('input') | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,46 @@ | ||||
| import { target, targetable } from '@github/catalyst/lib/targetable' | ||||
| import { cropCanvasAndExportToPNG } from '../submission_form/crop_canvas' | ||||
| 
 | ||||
| export default targetable(class extends HTMLElement { | ||||
|   static [target.static] = ['canvas', 'input', 'clear', 'button'] | ||||
| 
 | ||||
|   async connectedCallback () { | ||||
|     this.canvas.width = this.canvas.parentNode.parentNode.clientWidth | ||||
|     this.canvas.height = this.canvas.parentNode.parentNode.clientWidth / 3 | ||||
| 
 | ||||
|     const { default: SignaturePad } = await import('signature_pad') | ||||
| 
 | ||||
|     this.pad = new SignaturePad(this.canvas) | ||||
| 
 | ||||
|     this.clear.addEventListener('click', (e) => { | ||||
|       e.preventDefault() | ||||
| 
 | ||||
|       this.pad.clear() | ||||
|     }) | ||||
| 
 | ||||
|     this.button.addEventListener('click', (e) => { | ||||
|       e.preventDefault() | ||||
| 
 | ||||
|       this.button.disabled = true | ||||
| 
 | ||||
|       this.submit() | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   async submit () { | ||||
|     const blob = await cropCanvasAndExportToPNG(this.canvas) | ||||
|     const file = new File([blob], 'signature.png', { type: 'image/png' }) | ||||
| 
 | ||||
|     const dataTransfer = new DataTransfer() | ||||
| 
 | ||||
|     dataTransfer.items.add(file) | ||||
| 
 | ||||
|     this.input.files = dataTransfer.files | ||||
| 
 | ||||
|     if (this.input.webkitEntries.length) { | ||||
|       this.input.dataset.file = `${dataTransfer.files[0].name}` | ||||
|     } | ||||
| 
 | ||||
|     this.closest('form').requestSubmit() | ||||
|   } | ||||
| }) | ||||
| @ -0,0 +1,5 @@ | ||||
| export default class extends HTMLElement { | ||||
|   connectedCallback () { | ||||
|     this.querySelector('form').requestSubmit() | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,67 @@ | ||||
| import autocomplete from 'autocompleter' | ||||
| 
 | ||||
| export default class extends HTMLElement { | ||||
|   connectedCallback () { | ||||
|     autocomplete({ | ||||
|       input: this.input, | ||||
|       preventSubmit: 1, | ||||
|       minLength: 1, | ||||
|       showOnFocus: true, | ||||
|       onSelect: this.onSelect, | ||||
|       render: this.render, | ||||
|       fetch: this.fetch | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   onSelect = (item) => { | ||||
|     const fields = ['email', 'name', 'phone'] | ||||
|     const submitterItemEl = this.closest('submitter-item') | ||||
| 
 | ||||
|     fields.forEach((field) => { | ||||
|       const input = submitterItemEl.querySelector(`submitters-autocomplete[data-field="${field}"] input`) | ||||
|       const textarea = submitterItemEl.querySelector(`submitters-autocomplete[data-field="${field}"] textarea`) | ||||
| 
 | ||||
|       if (input && item[field]) { | ||||
|         input.value = item[field] | ||||
|       } | ||||
| 
 | ||||
|       if (textarea && item[field]) { | ||||
|         textarea.value = textarea.value.replace(/[^;,\s]+$/, item[field] + ' ') | ||||
|       } | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   fetch = (text, resolve) => { | ||||
|     const q = text.split(/[;,\s]+/).pop().trim() | ||||
| 
 | ||||
|     if (q) { | ||||
|       const queryParams = new URLSearchParams({ q, field: this.dataset.field }) | ||||
| 
 | ||||
|       fetch('/api/submitters_autocomplete?' + queryParams).then(async (resp) => { | ||||
|         const items = await resp.json() | ||||
| 
 | ||||
|         if (q.length < 3) { | ||||
|           resolve(items.filter((e) => e[this.dataset.field].startsWith(q))) | ||||
|         } else { | ||||
|           resolve(items) | ||||
|         } | ||||
|       }).catch(() => { | ||||
|         resolve([]) | ||||
|       }) | ||||
|     } else { | ||||
|       resolve([]) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   render = (item) => { | ||||
|     const div = document.createElement('div') | ||||
| 
 | ||||
|     div.textContent = item[this.dataset.field] | ||||
| 
 | ||||
|     return div | ||||
|   } | ||||
| 
 | ||||
|   get input () { | ||||
|     return this.querySelector('input') || this.querySelector('textarea') | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,49 @@ | ||||
| function cropCanvasAndExportToPNG (canvas) { | ||||
|   const ctx = canvas.getContext('2d') | ||||
| 
 | ||||
|   const width = canvas.width | ||||
|   const height = canvas.height | ||||
| 
 | ||||
|   let topmost = height | ||||
|   let bottommost = 0 | ||||
|   let leftmost = width | ||||
|   let rightmost = 0 | ||||
| 
 | ||||
|   const imageData = ctx.getImageData(0, 0, width, height) | ||||
|   const pixels = imageData.data | ||||
| 
 | ||||
|   for (let y = 0; y < height; y++) { | ||||
|     for (let x = 0; x < width; x++) { | ||||
|       const pixelIndex = (y * width + x) * 4 | ||||
|       const alpha = pixels[pixelIndex + 3] | ||||
|       if (alpha !== 0) { | ||||
|         topmost = Math.min(topmost, y) | ||||
|         bottommost = Math.max(bottommost, y) | ||||
|         leftmost = Math.min(leftmost, x) | ||||
|         rightmost = Math.max(rightmost, x) | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   const croppedWidth = rightmost - leftmost + 1 | ||||
|   const croppedHeight = bottommost - topmost + 1 | ||||
| 
 | ||||
|   const croppedCanvas = document.createElement('canvas') | ||||
|   croppedCanvas.width = croppedWidth | ||||
|   croppedCanvas.height = croppedHeight | ||||
|   const croppedCtx = croppedCanvas.getContext('2d') | ||||
| 
 | ||||
|   croppedCtx.drawImage(canvas, leftmost, topmost, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight) | ||||
| 
 | ||||
|   return new Promise((resolve, reject) => { | ||||
|     croppedCanvas.toBlob((blob) => { | ||||
|       if (blob) { | ||||
|         resolve(blob) | ||||
|       } else { | ||||
|         reject(new Error('Failed to create a PNG blob.')) | ||||
|       } | ||||
|     }, 'image/png') | ||||
|   }) | ||||
| } | ||||
| 
 | ||||
| export { cropCanvasAndExportToPNG } | ||||
| @ -0,0 +1,73 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <div class="flex justify-between items-center w-full mb-2"> | ||||
|       <label | ||||
|         :for="field.uuid" | ||||
|         class="label text-2xl" | ||||
|       >{{ field.name || t('date') }} | ||||
|         <template v-if="!field.required">({{ t('optional') }})</template> | ||||
|       </label> | ||||
|       <button | ||||
|         class="btn btn-outline btn-sm !normal-case font-normal" | ||||
|         @click.prevent="setCurrentDate" | ||||
|       > | ||||
|         <IconCalendarCheck :width="16" /> | ||||
|         {{ t('set_today') }} | ||||
|       </button> | ||||
|     </div> | ||||
|     <div class="text-center"> | ||||
|       <input | ||||
|         ref="input" | ||||
|         v-model="value" | ||||
|         class="base-input !text-2xl text-center w-full" | ||||
|         :required="field.required" | ||||
|         type="date" | ||||
|         :name="`values[${field.uuid}]`" | ||||
|         @focus="$emit('focus')" | ||||
|       > | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| import { IconCalendarCheck } from '@tabler/icons-vue' | ||||
| 
 | ||||
| export default { | ||||
|   name: 'DateStep', | ||||
|   components: { | ||||
|     IconCalendarCheck | ||||
|   }, | ||||
|   inject: ['t'], | ||||
|   props: { | ||||
|     field: { | ||||
|       type: Object, | ||||
|       required: true | ||||
|     }, | ||||
|     modelValue: { | ||||
|       type: String, | ||||
|       required: false, | ||||
|       default: '' | ||||
|     } | ||||
|   }, | ||||
|   emits: ['update:model-value', 'focus'], | ||||
|   computed: { | ||||
|     value: { | ||||
|       set (value) { | ||||
|         this.$emit('update:model-value', value) | ||||
|       }, | ||||
|       get () { | ||||
|         return this.modelValue | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     setCurrentDate () { | ||||
|       const inputEl = this.$refs.input | ||||
| 
 | ||||
|       inputEl.valueAsDate = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000) | ||||
| 
 | ||||
|       inputEl.dispatchEvent(new Event('input', { bubbles: true })) | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| @ -0,0 +1,269 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <div class="flex justify-between items-center w-full mb-2"> | ||||
|       <label | ||||
|         class="label text-2xl" | ||||
|       >{{ field.name || t('initials') }}</label> | ||||
|       <div class="space-x-2 flex"> | ||||
|         <span | ||||
|           class="tooltip" | ||||
|           :data-tip="t('draw_initials')" | ||||
|         > | ||||
|           <a | ||||
|             id="type_text_button" | ||||
|             href="#" | ||||
|             class="btn btn-sm btn-circle" | ||||
|             :class="{ 'btn-neutral': isDrawInitials, 'btn-outline': !isDrawInitials }" | ||||
|             @click.prevent="toggleTextInput" | ||||
|           > | ||||
|             <IconSignature :width="16" /> | ||||
|           </a> | ||||
|         </span> | ||||
|         <a | ||||
|           v-if="modelValue || computedPreviousValue" | ||||
|           href="#" | ||||
|           class="btn btn-outline btn-sm" | ||||
|           @click.prevent="remove" | ||||
|         > | ||||
|           <IconReload :width="16" /> | ||||
|           {{ t('clear') }} | ||||
|         </a> | ||||
|         <a | ||||
|           v-else | ||||
|           href="#" | ||||
|           class="btn btn-outline btn-sm" | ||||
|           @click.prevent="clear" | ||||
|         > | ||||
|           <IconReload :width="16" /> | ||||
|           {{ t('clear') }} | ||||
|         </a> | ||||
|         <a | ||||
|           title="Minimize" | ||||
|           href="#" | ||||
|           class="py-1.5 inline md:hidden" | ||||
|           @click.prevent="$emit('minimize')" | ||||
|         > | ||||
|           <IconArrowsDiagonalMinimize2 | ||||
|             :width="20" | ||||
|             :height="20" | ||||
|           /> | ||||
|         </a> | ||||
|       </div> | ||||
|     </div> | ||||
|     <input | ||||
|       :value="modelValue || computedPreviousValue" | ||||
|       type="hidden" | ||||
|       :name="`values[${field.uuid}]`" | ||||
|     > | ||||
|     <img | ||||
|       v-if="modelValue || computedPreviousValue" | ||||
|       :src="attachmentsIndex[modelValue || computedPreviousValue].url" | ||||
|       class="mx-auto bg-white border border-base-300 rounded max-h-72" | ||||
|     > | ||||
|     <canvas | ||||
|       v-show="!modelValue && !computedPreviousValue" | ||||
|       ref="canvas" | ||||
|       class="bg-white border border-base-300 rounded" | ||||
|     /> | ||||
|     <input | ||||
|       v-if="!isDrawInitials && !modelValue && !computedPreviousValue" | ||||
|       id="initials_text_input" | ||||
|       ref="textInput" | ||||
|       class="base-input !text-2xl w-full mt-6 text-center" | ||||
|       :required="field.required && !isInitialsStarted" | ||||
|       :placeholder="`${t('type_initial_here')}...`" | ||||
|       type="text" | ||||
|       @focus="$emit('focus')" | ||||
|       @input="updateWrittenInitials" | ||||
|     > | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| import { cropCanvasAndExportToPNG } from './crop_canvas' | ||||
| import { IconReload, IconSignature, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue' | ||||
| import SignaturePad from 'signature_pad' | ||||
| 
 | ||||
| export default { | ||||
|   name: 'InitialsStep', | ||||
|   components: { | ||||
|     IconReload, | ||||
|     IconSignature, | ||||
|     IconArrowsDiagonalMinimize2 | ||||
|   }, | ||||
|   inject: ['baseUrl', 't'], | ||||
|   props: { | ||||
|     field: { | ||||
|       type: Object, | ||||
|       required: true | ||||
|     }, | ||||
|     submitterSlug: { | ||||
|       type: String, | ||||
|       required: true | ||||
|     }, | ||||
|     isDirectUpload: { | ||||
|       type: Boolean, | ||||
|       required: true, | ||||
|       default: false | ||||
|     }, | ||||
|     attachmentsIndex: { | ||||
|       type: Object, | ||||
|       required: false, | ||||
|       default: () => ({}) | ||||
|     }, | ||||
|     previousValue: { | ||||
|       type: String, | ||||
|       required: false, | ||||
|       default: '' | ||||
|     }, | ||||
|     modelValue: { | ||||
|       type: String, | ||||
|       required: false, | ||||
|       default: '' | ||||
|     } | ||||
|   }, | ||||
|   emits: ['attached', 'update:model-value', 'start', 'minimize', 'focus'], | ||||
|   data () { | ||||
|     return { | ||||
|       isInitialsStarted: !!this.previousValue, | ||||
|       isUsePreviousValue: true, | ||||
|       isDrawInitials: false | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     computedPreviousValue () { | ||||
|       if (this.isUsePreviousValue) { | ||||
|         return this.previousValue | ||||
|       } else { | ||||
|         return null | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   async mounted () { | ||||
|     this.$nextTick(() => { | ||||
|       if (this.$refs.canvas) { | ||||
|         this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth | ||||
|         this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth / 5 | ||||
|       } | ||||
| 
 | ||||
|       this.$refs.textInput?.focus() | ||||
|     }) | ||||
| 
 | ||||
|     if (this.isDirectUpload) { | ||||
|       import('@rails/activestorage') | ||||
|     } | ||||
| 
 | ||||
|     if (this.$refs.canvas) { | ||||
|       this.pad = new SignaturePad(this.$refs.canvas) | ||||
| 
 | ||||
|       this.pad.addEventListener('beginStroke', () => { | ||||
|         this.isInitialsStarted = true | ||||
| 
 | ||||
|         this.$emit('start') | ||||
|       }) | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     remove () { | ||||
|       this.$emit('update:model-value', '') | ||||
| 
 | ||||
|       this.isUsePreviousValue = false | ||||
|       this.isInitialsStarted = false | ||||
|     }, | ||||
|     clear () { | ||||
|       this.pad.clear() | ||||
| 
 | ||||
|       this.isInitialsStarted = false | ||||
| 
 | ||||
|       if (this.$refs.textInput) { | ||||
|         this.$refs.textInput.value = '' | ||||
|       } | ||||
|     }, | ||||
|     updateWrittenInitials (e) { | ||||
|       this.isInitialsStarted = true | ||||
| 
 | ||||
|       const canvas = this.$refs.canvas | ||||
|       const context = canvas.getContext('2d') | ||||
| 
 | ||||
|       const fontFamily = 'Arial' | ||||
|       const fontSize = '44px' | ||||
|       const fontStyle = 'italic' | ||||
|       const fontWeight = '' | ||||
| 
 | ||||
|       context.font = fontStyle + ' ' + fontWeight + ' ' + fontSize + ' ' + fontFamily | ||||
|       context.textAlign = 'center' | ||||
| 
 | ||||
|       context.clearRect(0, 0, canvas.width, canvas.height) | ||||
|       context.fillText(e.target.value, canvas.width / 2, canvas.height / 2 + 11) | ||||
|     }, | ||||
|     toggleTextInput () { | ||||
|       this.remove() | ||||
|       this.clear() | ||||
|       this.isDrawInitials = !this.isDrawInitials | ||||
| 
 | ||||
|       if (!this.isDrawInitials) { | ||||
|         this.$nextTick(() => { | ||||
|           this.$refs.textInput.focus() | ||||
| 
 | ||||
|           this.$emit('start') | ||||
|         }) | ||||
|       } | ||||
|     }, | ||||
|     async submit () { | ||||
|       if (this.modelValue || this.computedPreviousValue) { | ||||
|         if (this.computedPreviousValue) { | ||||
|           this.$emit('update:model-value', this.computedPreviousValue) | ||||
|         } | ||||
| 
 | ||||
|         return Promise.resolve({}) | ||||
|       } | ||||
| 
 | ||||
|       return new Promise((resolve) => { | ||||
|         cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => { | ||||
|           const file = new File([blob], 'initials.png', { type: 'image/png' }) | ||||
| 
 | ||||
|           if (this.isDirectUpload) { | ||||
|             const { DirectUpload } = await import('@rails/activestorage') | ||||
| 
 | ||||
|             new DirectUpload( | ||||
|               file, | ||||
|               '/direct_uploads' | ||||
|             ).create((_error, data) => { | ||||
|               fetch(this.baseUrl + '/api/attachments', { | ||||
|                 method: 'POST', | ||||
|                 body: JSON.stringify({ | ||||
|                   submitter_slug: this.submitterSlug, | ||||
|                   blob_signed_id: data.signed_id, | ||||
|                   name: 'attachments' | ||||
|                 }), | ||||
|                 headers: { 'Content-Type': 'application/json' } | ||||
|               }).then((resp) => resp.json()).then((attachment) => { | ||||
|                 this.$emit('update:model-value', attachment.uuid) | ||||
|                 this.$emit('attached', attachment) | ||||
| 
 | ||||
|                 return resolve(attachment) | ||||
|               }) | ||||
|             }) | ||||
|           } else { | ||||
|             const formData = new FormData() | ||||
| 
 | ||||
|             formData.append('file', file) | ||||
|             formData.append('submitter_slug', this.submitterSlug) | ||||
|             formData.append('name', 'attachments') | ||||
| 
 | ||||
|             return fetch(this.baseUrl + '/api/attachments', { | ||||
|               method: 'POST', | ||||
|               body: formData | ||||
|             }).then((resp) => resp.json()).then((attachment) => { | ||||
|               this.$emit('attached', attachment) | ||||
|               this.$emit('update:model-value', attachment.uuid) | ||||
| 
 | ||||
|               return resolve(attachment) | ||||
|             }) | ||||
|           } | ||||
|         }) | ||||
|       }) | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| @ -0,0 +1,120 @@ | ||||
| <template> | ||||
|   <label | ||||
|     v-if="field.name" | ||||
|     :for="field.uuid" | ||||
|     class="label text-2xl mb-2" | ||||
|   >{{ field.name }} | ||||
|     <template v-if="!field.required">({{ t('optional') }})</template> | ||||
|   </label> | ||||
|   <div | ||||
|     v-else | ||||
|     class="py-1" | ||||
|   /> | ||||
|   <div class="items-center flex"> | ||||
|     <input | ||||
|       v-if="!isTextArea" | ||||
|       :id="field.uuid" | ||||
|       v-model="text" | ||||
|       class="base-input !text-2xl w-full !pr-11 -mr-10" | ||||
|       :required="field.required" | ||||
|       :pattern="field.validation?.pattern" | ||||
|       :oninvalid="field.validation?.message ? `this.setCustomValidity(${JSON.stringify(field.validation.message)})` : ''" | ||||
|       :oninput="field.validation?.message ? `this.setCustomValidity('')` : ''" | ||||
|       :placeholder="`${t('type_here')}...${field.required ? '' : ` (${t('optional')})`}`" | ||||
|       type="text" | ||||
|       :name="`values[${field.uuid}]`" | ||||
|       @focus="$emit('focus')" | ||||
|     > | ||||
|     <textarea | ||||
|       v-if="isTextArea" | ||||
|       :id="field.uuid" | ||||
|       ref="textarea" | ||||
|       v-model="text" | ||||
|       class="base-textarea !text-2xl w-full" | ||||
|       :placeholder="`${t('type_here')}...${field.required ? '' : ` (${t('optional')})`}`" | ||||
|       :required="field.required" | ||||
|       :name="`values[${field.uuid}]`" | ||||
|       @input="resizeTextarea" | ||||
|       @focus="$emit('focus')" | ||||
|     /> | ||||
|     <div | ||||
|       v-if="!isTextArea" | ||||
|       class="tooltip" | ||||
|       :data-tip="t('toggle_multiline_text')" | ||||
|     > | ||||
|       <a | ||||
|         href="#" | ||||
|         class="btn btn-ghost btn-circle btn-sm" | ||||
|         @click.prevent="toggleTextArea" | ||||
|       > | ||||
|         <IconAlignBoxLeftTop /> | ||||
|       </a> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| import { IconAlignBoxLeftTop } from '@tabler/icons-vue' | ||||
| 
 | ||||
| export default { | ||||
|   name: 'TextStep', | ||||
|   components: { | ||||
|     IconAlignBoxLeftTop | ||||
|   }, | ||||
|   inject: ['t'], | ||||
|   props: { | ||||
|     field: { | ||||
|       type: Object, | ||||
|       required: true | ||||
|     }, | ||||
|     modelValue: { | ||||
|       type: String, | ||||
|       required: false, | ||||
|       default: '' | ||||
|     } | ||||
|   }, | ||||
|   emits: ['update:model-value', 'focus'], | ||||
|   data () { | ||||
|     return { | ||||
|       isTextArea: false | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     text: { | ||||
|       set (value) { | ||||
|         this.$emit('update:model-value', value) | ||||
|       }, | ||||
|       get () { | ||||
|         return this.modelValue | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   mounted () { | ||||
|     this.isTextArea = this.modelValue?.includes('\n') | ||||
| 
 | ||||
|     if (this.isTextArea) { | ||||
|       this.$nextTick(() => { | ||||
|         this.resizeTextarea() | ||||
|       }) | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     resizeTextarea () { | ||||
|       const textarea = this.$refs.textarea | ||||
| 
 | ||||
|       textarea.style.height = 'auto' | ||||
|       textarea.style.height = textarea.scrollHeight + 'px' | ||||
|     }, | ||||
|     toggleTextArea () { | ||||
|       this.isTextArea = true | ||||
| 
 | ||||
|       this.$nextTick(() => { | ||||
|         this.$refs.textarea.focus() | ||||
|         this.$refs.textarea.setSelectionRange(this.$refs.textarea.value.length, this.$refs.textarea.value.length) | ||||
| 
 | ||||
|         this.resizeTextarea() | ||||
|       }) | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| @ -0,0 +1,98 @@ | ||||
| <template> | ||||
|   <div class="fixed text-center w-full bottom-0 pr-6 mb-4"> | ||||
|     <span class="w-full bg-base-200 px-4 py-2 rounded-md inline-flex space-x-2 mx-auto items-center justify-between mb-2 z-20 md:hidden"> | ||||
|       <div class="flex items-center space-x-2"> | ||||
|         <component | ||||
|           :is="fieldIcons[drawField.type]" | ||||
|           :width="20" | ||||
|           :height="20" | ||||
|           class="inline" | ||||
|           :stroke-width="1.6" | ||||
|         /> | ||||
|         <span> Draw {{ fieldNames[drawField.type] }} Field </span> | ||||
|       </div> | ||||
|       <a | ||||
|         href="#" | ||||
|         class="link block text-center" | ||||
|         @click.prevent="$emit('cancel')" | ||||
|       > | ||||
|         Cancel | ||||
|       </a> | ||||
|     </span> | ||||
|     <FieldSubmitter | ||||
|       :model-value="selectedSubmitter.uuid" | ||||
|       :submitters="submitters" | ||||
|       :editable="editable" | ||||
|       :mobile-view="true" | ||||
|       @new-submitter="save" | ||||
|       @remove="removeSubmitter" | ||||
|       @name-change="save" | ||||
|       @update:model-value="$emit('change-submitter', submitters.find((s) => s.uuid === $event))" | ||||
|     /> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| import Field from './field' | ||||
| import FieldType from './field_type' | ||||
| import FieldSubmitter from './field_submitter' | ||||
| 
 | ||||
| export default { | ||||
|   name: 'MobileDrawField', | ||||
|   components: { | ||||
|     Field, | ||||
|     FieldSubmitter | ||||
|   }, | ||||
|   inject: ['save'], | ||||
|   props: { | ||||
|     drawField: { | ||||
|       type: Object, | ||||
|       required: true | ||||
|     }, | ||||
|     editable: { | ||||
|       type: Boolean, | ||||
|       required: false, | ||||
|       default: true | ||||
|     }, | ||||
|     submitters: { | ||||
|       type: Array, | ||||
|       required: true | ||||
|     }, | ||||
|     fields: { | ||||
|       type: Array, | ||||
|       required: true | ||||
|     }, | ||||
|     selectedSubmitter: { | ||||
|       type: Object, | ||||
|       required: true | ||||
|     } | ||||
|   }, | ||||
|   emits: ['change-submitter', 'cancel'], | ||||
|   computed: { | ||||
|     fieldNames: FieldType.computed.fieldNames, | ||||
|     fieldIcons: FieldType.computed.fieldIcons | ||||
|   }, | ||||
|   methods: { | ||||
|     removeSubmitter (submitter) { | ||||
|       [...this.fields].forEach((field) => { | ||||
|         if (field.submitter_uuid === submitter.uuid) { | ||||
|           this.removeField(field) | ||||
|         } | ||||
|       }) | ||||
| 
 | ||||
|       this.submitters.splice(this.submitters.indexOf(submitter), 1) | ||||
| 
 | ||||
|       if (this.selectedSubmitter === submitter) { | ||||
|         this.$emit('change-submitter', this.submitters[0]) | ||||
|       } | ||||
| 
 | ||||
|       this.save() | ||||
|     }, | ||||
|     removeField (field) { | ||||
|       this.fields.splice(this.fields.indexOf(field), 1) | ||||
| 
 | ||||
|       this.save() | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| @ -1,12 +1,12 @@ | ||||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class UserMailer < ApplicationMailer | ||||
|   def invitation_email(user) | ||||
|     @current_account = user.account | ||||
|   def invitation_email(user, invited_by: nil) | ||||
|     @current_account = invited_by&.account || user.account | ||||
|     @user = user | ||||
|     @token = @user.send(:set_reset_password_token) | ||||
| 
 | ||||
|     mail(to: @user.friendly_name, | ||||
|          subject: 'You have been invited to Docuseal') | ||||
|          subject: 'You are invited to DocuSeal') | ||||
|   end | ||||
| end | ||||
|  | ||||
| @ -0,0 +1,29 @@ | ||||
| # frozen_string_literal: true | ||||
| 
 | ||||
| # == Schema Information | ||||
| # | ||||
| # Table name: encrypted_user_configs | ||||
| # | ||||
| #  id         :bigint           not null, primary key | ||||
| #  key        :string           not null | ||||
| #  value      :text             not null | ||||
| #  created_at :datetime         not null | ||||
| #  updated_at :datetime         not null | ||||
| #  user_id    :bigint           not null | ||||
| # | ||||
| # Indexes | ||||
| # | ||||
| #  index_encrypted_user_configs_on_user_id          (user_id) | ||||
| #  index_encrypted_user_configs_on_user_id_and_key  (user_id,key) UNIQUE | ||||
| # | ||||
| # Foreign Keys | ||||
| # | ||||
| #  fk_rails_...  (user_id => users.id) | ||||
| # | ||||
| class EncryptedUserConfig < ApplicationRecord | ||||
|   belongs_to :user | ||||
| 
 | ||||
|   encrypts :value | ||||
| 
 | ||||
|   serialize :value, JSON | ||||
| end | ||||
| @ -0,0 +1,40 @@ | ||||
| # frozen_string_literal: true | ||||
| 
 | ||||
| # == Schema Information | ||||
| # | ||||
| # Table name: template_folders | ||||
| # | ||||
| #  id         :bigint           not null, primary key | ||||
| #  deleted_at :datetime | ||||
| #  name       :string           not null | ||||
| #  created_at :datetime         not null | ||||
| #  updated_at :datetime         not null | ||||
| #  account_id :bigint           not null | ||||
| #  author_id  :bigint           not null | ||||
| # | ||||
| # Indexes | ||||
| # | ||||
| #  index_template_folders_on_account_id  (account_id) | ||||
| #  index_template_folders_on_author_id   (author_id) | ||||
| # | ||||
| # Foreign Keys | ||||
| # | ||||
| #  fk_rails_...  (account_id => accounts.id) | ||||
| #  fk_rails_...  (author_id => users.id) | ||||
| # | ||||
| class TemplateFolder < ApplicationRecord | ||||
|   DEFAULT_NAME = 'Default' | ||||
| 
 | ||||
|   belongs_to :author, class_name: 'User' | ||||
|   belongs_to :account | ||||
| 
 | ||||
|   has_many :templates, dependent: :destroy, foreign_key: :folder_id, inverse_of: :folder | ||||
|   has_many :active_templates, -> { where(deleted_at: nil) }, | ||||
|            class_name: 'Template', dependent: :destroy, foreign_key: :folder_id, inverse_of: :folder | ||||
| 
 | ||||
|   scope :active, -> { where(deleted_at: nil) } | ||||
| 
 | ||||
|   def default? | ||||
|     name == DEFAULT_NAME | ||||
|   end | ||||
| end | ||||
| @ -0,0 +1,29 @@ | ||||
| # frozen_string_literal: true | ||||
| 
 | ||||
| # == Schema Information | ||||
| # | ||||
| # Table name: user_configs | ||||
| # | ||||
| #  id         :bigint           not null, primary key | ||||
| #  key        :string           not null | ||||
| #  value      :text             not null | ||||
| #  created_at :datetime         not null | ||||
| #  updated_at :datetime         not null | ||||
| #  user_id    :bigint           not null | ||||
| # | ||||
| # Indexes | ||||
| # | ||||
| #  index_user_configs_on_user_id          (user_id) | ||||
| #  index_user_configs_on_user_id_and_key  (user_id,key) UNIQUE | ||||
| # | ||||
| # Foreign Keys | ||||
| # | ||||
| #  fk_rails_...  (user_id => users.id) | ||||
| # | ||||
| class UserConfig < ApplicationRecord | ||||
|   SIGNATURE_KEY = 'signature' | ||||
| 
 | ||||
|   belongs_to :user | ||||
| 
 | ||||
|   serialize :value, JSON | ||||
| end | ||||
| After Width: | Height: | Size: 354 B | 
| After Width: | Height: | Size: 565 B | 
| After Width: | Height: | Size: 362 B | 
| After Width: | Height: | Size: 391 B | 
| After Width: | Height: | Size: 448 B | 
| After Width: | Height: | Size: 588 B | 
| After Width: | Height: | Size: 440 B |