@ -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
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class UserMailer < ApplicationMailer
|
class UserMailer < ApplicationMailer
|
||||||
def invitation_email(user)
|
def invitation_email(user, invited_by: nil)
|
||||||
@current_account = user.account
|
@current_account = invited_by&.account || user.account
|
||||||
@user = user
|
@user = user
|
||||||
@token = @user.send(:set_reset_password_token)
|
@token = @user.send(:set_reset_password_token)
|
||||||
|
|
||||||
mail(to: @user.friendly_name,
|
mail(to: @user.friendly_name,
|
||||||
subject: 'You have been invited to Docuseal')
|
subject: 'You are invited to DocuSeal')
|
||||||
end
|
end
|
||||||
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 |