diff --git a/Gemfile.lock b/Gemfile.lock index 27ceef50..de5ce993 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -75,7 +75,7 @@ GEM securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) - addressable (2.8.9) + addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) annotaterb (4.22.0) activerecord (>= 6.0.0) @@ -388,7 +388,7 @@ GEM rack (3.2.6) rack-proxy (0.7.7) rack - rack-session (2.1.1) + rack-session (2.1.2) base64 (>= 0.1.0) rack (>= 3.0.0) rack-test (2.2.0) diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index b24c6b45..49314f32 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -53,7 +53,9 @@ class AccountsController < ApplicationController # rubocop:disable Layout/LineLength render turbo_stream: turbo_stream.replace( :account_delete_button, - html: helpers.tag.p(I18n.t('your_account_removal_request_will_be_processed_within_2_months_please_contact_us_if_you_want_to_keep_your_account')) + html: helpers.tag.p(I18n.t('your_account_will_be_permanently_deleted_within_2_months_please_contact_us_if_you_want_to_keep_your_account')) + + helpers.tag.br + + helpers.tag.p(I18n.t('your_email_address_has_been_released_immediately_you_can_now_be_added_to_your_company_team_without_waiting_for_the_deletion_period_to_end')) ) # rubocop:enable Layout/LineLength end diff --git a/app/controllers/api/attachments_controller.rb b/app/controllers/api/attachments_controller.rb index f00552cd..c81aa0f9 100644 --- a/app/controllers/api/attachments_controller.rb +++ b/app/controllers/api/attachments_controller.rb @@ -10,6 +10,12 @@ module Api def create submitter = Submitter.find_by!(slug: params[:submitter_slug]) + unless can_upload?(submitter) + Rollbar.error("Can't upload: #{submitter.id}") if defined?(Rollbar) + + return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content + end + if params[:type].in?(%w[initials signature]) image = Vips::Image.new_from_file(params[:file].path) @@ -40,6 +46,14 @@ module Api render json: { error: e.message }, status: :unprocessable_content end + def can_upload?(submitter) + !submitter.declined_at? && + !submitter.completed_at? && + !submitter.submission.archived_at? && + !submitter.submission.expired? && + !submitter.submission.template&.archived_at? + end + def build_new_cookie_signatures_json(submitter, attachment) values = begin diff --git a/app/controllers/api/submissions_controller.rb b/app/controllers/api/submissions_controller.rb index 77b4e615..b8ffa5ff 100644 --- a/app/controllers/api/submissions_controller.rb +++ b/app/controllers/api/submissions_controller.rb @@ -156,7 +156,7 @@ module Api params:) else submissions_attrs, attachments = - Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_params, template) + Submissions::NormalizeParamUtils.normalize_submissions_params!(submissions_params, template, purpose: :api) submissions = Submissions.create_from_submitters( template:, diff --git a/app/controllers/api/submitters_controller.rb b/app/controllers/api/submitters_controller.rb index 8f6d77fc..71dad23a 100644 --- a/app/controllers/api/submitters_controller.rb +++ b/app/controllers/api/submitters_controller.rb @@ -50,7 +50,8 @@ module Api normalized_params, new_attachments = Submissions::NormalizeParamUtils.normalize_submitter_params!( submitter_params.merge(role:), @submitter.template || Template.new(submitters: submission.template_submitters, account: @submitter.account), - for_submitter: @submitter + for_submitter: @submitter, + purpose: :api ) Submissions::CreateFromSubmitters.maybe_set_template_fields(submission, [normalized_params], diff --git a/app/controllers/submissions_debug_controller.rb b/app/controllers/submissions_debug_controller.rb index 4a6e8b9d..b9a137d0 100644 --- a/app/controllers/submissions_debug_controller.rb +++ b/app/controllers/submissions_debug_controller.rb @@ -9,7 +9,7 @@ class SubmissionsDebugController < ApplicationController def index @submitter = Submitter.preload({ attachments_attachments: :blob }, submission: { template: { documents_attachments: :blob } }) - .find_by(slug: params[:submitter_slug]) + .find_by(slug: params[:submit_form_slug]) respond_to do |f| f.html do diff --git a/app/controllers/submissions_download_controller.rb b/app/controllers/submissions_download_controller.rb index 39dee165..74287451 100644 --- a/app/controllers/submissions_download_controller.rb +++ b/app/controllers/submissions_download_controller.rb @@ -1,92 +1,25 @@ # frozen_string_literal: true class SubmissionsDownloadController < ApplicationController - skip_before_action :authenticate_user! - skip_authorization_check - - TTL = 40.minutes - FILES_TTL = 5.minutes + load_and_authorize_resource :submission def index - @submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present? - - signature_valid = - if @submitter&.slug == params[:submitter_slug] - true - else - @submitter = nil - end - - @submitter ||= Submitter.find_by!(slug: params[:submitter_slug]) - - Submissions::EnsureResultGenerated.call(@submitter) - - last_submitter = @submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last + last_submitter = @submission.submitters.where.not(completed_at: nil).order(:completed_at).last return head :not_found unless last_submitter Submissions::EnsureResultGenerated.call(last_submitter) - if !signature_valid && !current_user_submitter?(last_submitter) - return head :not_found unless Submitters::AuthorizedForForm.call(@submitter, current_user, request) - - if last_submitter.completed_at < TTL.ago - Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar) - - return head :not_found - end - end - if params[:combined] == 'true' - respond_with_combined(last_submitter) - else - render json: build_urls(last_submitter) - end - end + url = Submitters.build_combined_url(last_submitter) - private - - def respond_with_combined(submitter) - url = build_combined_url(submitter) - - if url - render json: [url] + if url + render json: [url] + else + head :not_found + end else - head :not_found + render json: Submitters.build_document_urls(last_submitter) end end - - def current_user_submitter?(submitter) - current_user && current_ability.can?(:read, submitter) - end - - def build_urls(submitter) - filename_format = AccountConfig.find_or_initialize_by(account_id: submitter.account_id, - key: AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY)&.value - - Submitters.select_attachments_for_download(submitter).map do |attachment| - ActiveStorage::Blob.proxy_path( - attachment.blob, - expires_at: FILES_TTL.from_now.to_i, - filename: Submitters.build_document_filename(submitter, attachment.blob, filename_format) - ) - end - end - - def build_combined_url(submitter) - return if submitter.submission.submitters.exists?(completed_at: nil) - return if submitter.submission.submitters.order(:completed_at).last != submitter - - attachment = submitter.submission.combined_document_attachment - attachment ||= Submissions::EnsureCombinedGenerated.call(submitter) - - filename_format = AccountConfig.find_or_initialize_by(account_id: submitter.account_id, - key: AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY)&.value - - ActiveStorage::Blob.proxy_path( - attachment.blob, - expires_at: FILES_TTL.from_now.to_i, - filename: Submitters.build_document_filename(submitter, attachment.blob, filename_format) - ) - end end diff --git a/app/controllers/submissions_preview_controller.rb b/app/controllers/submissions_preview_controller.rb index 6163c36b..ed83c407 100644 --- a/app/controllers/submissions_preview_controller.rb +++ b/app/controllers/submissions_preview_controller.rb @@ -10,13 +10,15 @@ class SubmissionsPreviewController < ApplicationController TTL = 40.minutes def show - submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present? + @sig_submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present? signature_valid = - if submitter && submitter.submission.slug == params[:slug] - @submission = submitter.submission + if @sig_submitter && @sig_submitter.submission.slug == params[:slug] + @submission = @sig_submitter.submission true + else + @sig_submitter = nil end @submission ||= Submission.find_by!(slug: params[:slug]) @@ -36,7 +38,7 @@ class SubmissionsPreviewController < ApplicationController @submission = Submissions.preload_with_pages(@submission) - render 'submissions/show', layout: 'plain' + render 'submissions/show', layout: 'plain', locals: { is_preview: true } end def completed diff --git a/app/controllers/submissions_preview_download_controller.rb b/app/controllers/submissions_preview_download_controller.rb new file mode 100644 index 00000000..da50785a --- /dev/null +++ b/app/controllers/submissions_preview_download_controller.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +class SubmissionsPreviewDownloadController < ApplicationController + skip_before_action :authenticate_user! + skip_authorization_check + + TTL = 40.minutes + + def index + @submission = Submission.find_by!(slug: params[:submission_slug] || params[:submissions_preview_slug]) + + last_submitter = @submission.submitters.where.not(completed_at: nil).order(:completed_at).last + + return head :not_found unless last_submitter + + Submissions::EnsureResultGenerated.call(last_submitter) + + unless current_user_submission?(@submission) + if use_2fa?(@submission) + Rollbar.info("2FA download error: #{last_submitter.id}") if defined?(Rollbar) + + return head :not_found + end + + if last_submitter.completed_at < TTL.ago + Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar) + + return head :not_found + end + end + + if params[:combined] == 'true' + respond_with_combined(last_submitter) + else + render json: Submitters.build_document_urls(last_submitter) + end + end + + private + + def respond_with_combined(submitter) + url = Submitters.build_combined_url(submitter) + + if url + render json: [url] + else + head :not_found + end + end + + def current_user_submission?(submission) + current_user && current_ability.can?(:read, submission) + end + + def use_2fa?(submission) + return true if submission.submitters.any? do |e| + e.preferences['require_phone_2fa'] || e.preferences['require_email_2fa'] + end + return true if submission.template&.preferences&.dig('require_phone_2fa') + return true if submission.template&.preferences&.dig('require_email_2fa') + + false + end +end diff --git a/app/controllers/submit_form_completed_download_controller.rb b/app/controllers/submit_form_completed_download_controller.rb new file mode 100644 index 00000000..19dfafb1 --- /dev/null +++ b/app/controllers/submit_form_completed_download_controller.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +class SubmitFormCompletedDownloadController < ApplicationController + skip_before_action :authenticate_user! + skip_authorization_check + + TTL = 40.minutes + FILES_TTL = 5.minutes + + def index + @submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present? + + signature_valid = + if @submitter&.slug == submitter_slug + true + else + @submitter = nil + end + + @submitter ||= Submitter.find_by!(slug: submitter_slug) + + Submissions::EnsureResultGenerated.call(@submitter) + + last_submitter = @submitter.submission.submitters.where.not(completed_at: nil).order(:completed_at).last + + return head :not_found unless last_submitter + + Submissions::EnsureResultGenerated.call(last_submitter) + + if !signature_valid && !current_user_submitter?(last_submitter) + unless Submitters::AuthorizedForForm.call(@submitter, current_user, request) + Rollbar.info("2FA download error: #{last_submitter.id}") if defined?(Rollbar) + + return head :not_found + end + + if last_submitter.completed_at < TTL.ago + Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar) + + return head :not_found + end + end + + if params[:combined] == 'true' + respond_with_combined(last_submitter) + else + render json: Submitters.build_document_urls(last_submitter) + end + end + + private + + def submitter_slug + params[:submit_form_slug] || params[:submitter_slug] || params[:submitter_id] + end + + def respond_with_combined(submitter) + url = Submitters.build_combined_url(submitter) + + if url + render json: [url] + else + head :not_found + end + end + + def current_user_submitter?(submitter) + current_user && current_ability.can?(:read, submitter) + end +end diff --git a/app/controllers/submit_form_download_controller.rb b/app/controllers/submit_form_download_controller.rb index af9cbeb4..e1c22eba 100644 --- a/app/controllers/submit_form_download_controller.rb +++ b/app/controllers/submit_form_download_controller.rb @@ -9,7 +9,7 @@ class SubmitFormDownloadController < ApplicationController def index @submitter = Submitter.find_by!(slug: params[:submit_form_slug]) - return redirect_to submitter_download_index_path(@submitter.slug) if @submitter.completed_at? + return redirect_to submit_form_documents_path(@submitter.slug) if @submitter.completed_at? return head :unprocessable_content if @submitter.declined_at? || @submitter.submission.archived_at? || diff --git a/app/controllers/submitters_download_controller.rb b/app/controllers/submitters_download_controller.rb new file mode 100644 index 00000000..c4588ca2 --- /dev/null +++ b/app/controllers/submitters_download_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class SubmittersDownloadController < ApplicationController + load_and_authorize_resource :submitter + + def index + Submissions::EnsureResultGenerated.call(@submitter) + + render json: Submitters.build_document_urls(@submitter) + end +end diff --git a/app/controllers/submitters_send_email_controller.rb b/app/controllers/submitters_send_email_controller.rb index f3eb3187..51c7c1b7 100644 --- a/app/controllers/submitters_send_email_controller.rb +++ b/app/controllers/submitters_send_email_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class SubmittersSendEmailController < ApplicationController - load_and_authorize_resource :submitter, id_param: :submitter_slug, find_by: :slug + load_and_authorize_resource :submitter def create if Docuseal.multitenant? && SubmissionEvent.exists?(submitter: @submitter, diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 9affdf4c..2d8f818f 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -16,7 +16,19 @@ class UsersController < ApplicationController @users.active.where.not(role: 'integration') end - @pagy, @users = pagy(@users.preload(account: :account_accesses).where(account: current_account).order(id: :desc)) + @users = @users.preload(account: :account_accesses).where(account: current_account).order(id: :desc) + + respond_to do |format| + format.html do + @pagy, @users = pagy(@users) + end + + if current_ability.can?(:manage, current_account) + format.csv do + send_data Users.generate_csv(@users), filename: "users-#{Time.current.iso8601}.csv", type: 'text/csv' + end + end + end end def new; end diff --git a/app/javascript/form.js b/app/javascript/form.js index 00ec8743..f8561dc0 100644 --- a/app/javascript/form.js +++ b/app/javascript/form.js @@ -31,6 +31,7 @@ safeRegisterElement('submission-form', class extends HTMLElement { isDemo: this.dataset.isDemo === 'true', attribution: this.dataset.attribution !== 'false', scrollPadding: this.dataset.scrollPadding || '-80px', + signatureText: this.dataset.signatureText, language: this.dataset.language, dryRun: this.dataset.dryRun === 'true', expand: ['true', 'false'].includes(this.dataset.expand) ? this.dataset.expand === 'true' : null, diff --git a/app/javascript/submission_form/calculator.js b/app/javascript/submission_form/calculator.js new file mode 100644 index 00000000..55cc185f --- /dev/null +++ b/app/javascript/submission_form/calculator.js @@ -0,0 +1,874 @@ +/* + * Portions of this file are derived from the Dentaku gem + * https://github.com/rubysolo/dentaku + * + * MIT License + * + * Copyright (c) 2012 Solomon White + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * Modifications and JavaScript port + * Copyright (c) 2026 DocuSeal, LLC + */ + +class CalculatorError extends Error { constructor (msg) { super(msg); this.name = 'CalculatorError' } } +class ParseError extends CalculatorError { constructor (msg) { super(msg); this.name = 'ParseError' } } +class TokenizerError extends CalculatorError { constructor (msg) { super(msg); this.name = 'TokenizerError' } } +class ArgumentError extends CalculatorError { constructor (msg) { super(msg); this.name = 'ArgumentError' } } +class ZeroDivisionError extends CalculatorError { constructor () { super('divided by 0'); this.name = 'ZeroDivisionError' } } +class UnboundVariableError extends CalculatorError { + constructor (vars) { + super('no value provided for variables: ' + vars.join(', ')) + this.name = 'UnboundVariableError' + this.unbound = vars + } +} + +class Token { + constructor (category, value, raw) { + this.category = category + this.value = value + this.raw = raw == null ? String(value) : String(raw) + } + + is (cat) { return this.category === cat } +} + +const OPERATOR_NAMES = { + '^': 'pow', + '+': 'add', + '-': 'subtract', + '*': 'multiply', + '/': 'divide', + '%': 'mod', + '|': 'bitor', + '&': 'bitand', + '<<': 'bitshiftleft', + '>>': 'bitshiftright' +} +const COMPARATOR_NAMES = { + '<=': 'le', + '>=': 'ge', + '!=': 'ne', + '<>': 'ne', + '<': 'lt', + '>': 'gt', + '==': 'eq', + '=': 'eq' +} +const GROUPING_NAMES = { '(': 'open', ')': 'close', ',': 'comma' } + +function buildScanners (caseSensitive) { + return [ + (s) => { + const m = /^\s+/.exec(s) + return m ? { tokens: [], length: m[0].length } : null + }, + (s) => { + const m = /^null\b/i.exec(s) + return m ? { tokens: [new Token('null', null, m[0])], length: m[0].length } : null + }, + (s) => { + const m = /^((?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?)\b/.exec(s) + if (!m) return null + return { tokens: [new Token('numeric', Number(m[0]), m[0])], length: m[0].length } + }, + (s) => { + const m = /^0x[0-9a-f]+\b/i.exec(s) + if (!m) return null + return { tokens: [new Token('numeric', parseInt(m[0].slice(2), 16), m[0])], length: m[0].length } + }, + (s) => { + const m = /^"([^"]*)"/.exec(s) + if (!m) return null + return { tokens: [new Token('string', m[1], m[0])], length: m[0].length } + }, + (s) => { + const m = /^'([^']*)'/.exec(s) + if (!m) return null + return { tokens: [new Token('string', m[1], m[0])], length: m[0].length } + }, + (s, last) => { + if (s[0] !== '-') return null + const ok = last == null || + last.is('operator') || + last.is('comparator') || + last.is('combinator') || + last.value === 'open' || + last.value === 'comma' || + last.value === 'array_start' + if (!ok) return null + return { tokens: [new Token('operator', 'negate', '-')], length: 1 } + }, + (s) => { + const m = /^(and|or|&&|\|\|)\s/i.exec(s) + if (!m) return null + const raw = m[1].toLowerCase() + const name = raw === '&&' ? 'and' : raw === '||' ? 'or' : raw + return { tokens: [new Token('combinator', name, m[0])], length: m[0].length } + }, + (s) => { + const m = /^(<<|>>|\^|\+|-|\*|\/|%|\||&)/.exec(s) + if (!m) return null + return { tokens: [new Token('operator', OPERATOR_NAMES[m[0]], m[0])], length: m[0].length } + }, + (s) => { + const m = /^(\(|\)|,)/.exec(s) + if (!m) return null + return { tokens: [new Token('grouping', GROUPING_NAMES[m[0]], m[0])], length: m[0].length } + }, + (s) => { + if (s[0] === '{') return { tokens: [new Token('array', 'array_start', '{')], length: 1 } + if (s[0] === '}') return { tokens: [new Token('array', 'array_end', '}')], length: 1 } + return null + }, + (s) => { + const m = /^(<=|>=|!=|<>|==|=|<|>)/.exec(s) + if (!m) return null + return { tokens: [new Token('comparator', COMPARATOR_NAMES[m[0]], m[0])], length: m[0].length } + }, + (s) => { + const m = /^(true|false)\b/i.exec(s) + if (!m) return null + return { tokens: [new Token('logical', m[0].toLowerCase() === 'true', m[0])], length: m[0].length } + }, + (s) => { + const m = /^(\w+!?)\s*\(/.exec(s) + if (!m) return null + const name = m[1].toLowerCase() + return { + tokens: [ + new Token('function', name, m[1]), + new Token('grouping', 'open', '(') + ], + length: m[0].length + } + }, + (s) => { + const m = /^[A-Za-z_][\w.]*\b/.exec(s) + if (!m) return null + const value = caseSensitive ? m[0] : m[0].toLowerCase() + return { tokens: [new Token('identifier', value, m[0])], length: m[0].length } + }, + (s) => { + const m = /^`([^`]*)`/.exec(s) + if (!m) return null + return { tokens: [new Token('identifier', m[1], m[0])], length: m[0].length } + } + ] +} + +function tokenize (input, options) { + options = options || {} + let remaining = String(input).replace(/\/\*[\s\S]*?\*\//g, '') + const scanners = buildScanners(options.caseSensitive === true) + const tokens = [] + let nesting = 0 + + while (remaining.length > 0) { + let matched = false + for (let i = 0; i < scanners.length; i++) { + const result = scanners[i](remaining, tokens.length ? tokens[tokens.length - 1] : null) + if (result) { + for (const tok of result.tokens) { + if (tok.category === 'grouping' && tok.value === 'open') nesting++ + if (tok.category === 'grouping' && tok.value === 'close') { + nesting-- + if (nesting < 0) throw new TokenizerError('too many closing parentheses') + } + tokens.push(tok) + } + remaining = remaining.slice(result.length) + matched = true + break + } + } + if (!matched) throw new TokenizerError("parse error at: '" + remaining + "'") + } + if (nesting > 0) throw new TokenizerError('too many opening parentheses') + return tokens +} + +function uniq (arr) { + const seen = new Set() + const out = [] + for (const x of arr) { if (!seen.has(x)) { seen.add(x); out.push(x) } } + return out +} + +function toNumber (val) { + if (typeof val === 'number') return val + if (typeof val === 'boolean') return val ? 1 : 0 + if (val == null) throw new ArgumentError("'" + val + "' is not coercible to numeric") + if (typeof val === 'string') { + if (val.length === 0) throw new ArgumentError("'' is not coercible to numeric") + const n = Number(val) + if (isNaN(n)) throw new ArgumentError("'" + val + "' is not coercible to numeric") + return n + } + throw new ArgumentError("'" + val + "' is not coercible to numeric") +} + +class Node { + static precedence = 0 + static arity = null + static rightAssociative = false + static resolveClass () { return this } + static minParamCount () { return this.arity } + static maxParamCount () { return this.arity } + value () { return null } + dependencies () { return [] } + get type () { return null } +} + +class NilNode extends Node { value () { return null } } + +class Numeric extends Node { + constructor (token) { super(); this.v = token.value } + value () { return this.v } + get type () { return 'numeric' } +} + +class Logical extends Node { + constructor (token) { super(); this.v = token.value } + value () { return this.v } + get type () { return 'logical' } +} + +class StringNode extends Node { + constructor (token) { super(); this.v = token.value } + value () { return this.v } + get type () { return 'string' } +} + +class Identifier extends Node { + constructor (token) { super(); this.ident = token.value } + value (ctx) { + if (ctx && (this.ident in ctx)) { + const v = ctx[this.ident] + if (v instanceof Node) return v.value(ctx) + if (typeof v === 'function') return v() + return v + } + throw new UnboundVariableError([this.ident]) + } + + dependencies (ctx) { + if (ctx && (this.ident in ctx)) { + const v = ctx[this.ident] + return v instanceof Node ? v.dependencies(ctx) : [] + } + return [this.ident] + } +} + +class Grouping extends Node { + constructor (node) { super(); this.node = node } + value (ctx) { return this.node.value(ctx) } + get type () { return this.node.type } + dependencies (ctx) { return this.node.dependencies(ctx) } +} + +class Operation extends Node { + constructor (left, right) { super(); this.left = left; this.right = right } + dependencies (ctx) { + const l = this.left ? this.left.dependencies(ctx) : [] + const r = this.right ? this.right.dependencies(ctx) : [] + return uniq(l.concat(r)) + } +} + +class Arithmetic extends Operation { get type () { return 'numeric' } } + +class Addition extends Arithmetic { + static precedence = 10 + value (ctx) { + const l = this.left.value(ctx) + const r = this.right.value(ctx) + if (typeof l === 'string' || typeof r === 'string') return String(l) + String(r) + return toNumber(l) + toNumber(r) + } +} + +class Subtraction extends Arithmetic { + static precedence = 10 + value (ctx) { return toNumber(this.left.value(ctx)) - toNumber(this.right.value(ctx)) } +} + +class Multiplication extends Arithmetic { + static precedence = 20 + value (ctx) { return toNumber(this.left.value(ctx)) * toNumber(this.right.value(ctx)) } +} + +class Division extends Arithmetic { + static precedence = 20 + value (ctx) { + const r = toNumber(this.right.value(ctx)) + if (r === 0) throw new ZeroDivisionError() + return toNumber(this.left.value(ctx)) / r + } +} + +class Modulo extends Arithmetic { + static arity = 2 + static precedence = 20 + static resolveClass (nextTok) { + if (!nextTok) return Percentage + if (nextTok.category === 'operator') return Percentage + if (nextTok.category === 'comparator') return Percentage + if (nextTok.category === 'combinator') return Percentage + if (nextTok.category === 'grouping' && (nextTok.value === 'close' || nextTok.value === 'comma')) return Percentage + if (nextTok.category === 'array' && nextTok.value === 'array_end') return Percentage + return Modulo + } + + value (ctx) { + const r = toNumber(this.right.value(ctx)) + if (r === 0) throw new ZeroDivisionError() + return toNumber(this.left.value(ctx)) % r + } +} + +class Percentage extends Arithmetic { + static arity = 1 + static precedence = 30 + constructor (child) { super(child, null) } + value (ctx) { return toNumber(this.left.value(ctx)) * 0.01 } + dependencies (ctx) { return this.left.dependencies(ctx) } +} + +class Exponentiation extends Arithmetic { + static precedence = 30 + static rightAssociative = true + value (ctx) { return Math.pow(toNumber(this.left.value(ctx)), toNumber(this.right.value(ctx))) } +} + +class Negation extends Arithmetic { + static arity = 1 + static precedence = 40 + static rightAssociative = true + constructor (node) { super(node, null) } + value (ctx) { return -toNumber(this.left.value(ctx)) } + dependencies (ctx) { return this.left.dependencies(ctx) } +} + +class BitwiseOr extends Operation { + static precedence = 10 + value (ctx) { return toNumber(this.left.value(ctx)) | toNumber(this.right.value(ctx)) } + get type () { return 'numeric' } +} +class BitwiseAnd extends Operation { + static precedence = 10 + value (ctx) { return toNumber(this.left.value(ctx)) & toNumber(this.right.value(ctx)) } + get type () { return 'numeric' } +} +class BitwiseShiftLeft extends Operation { + static precedence = 10 + value (ctx) { return toNumber(this.left.value(ctx)) << toNumber(this.right.value(ctx)) } + get type () { return 'numeric' } +} +class BitwiseShiftRight extends Operation { + static precedence = 10 + value (ctx) { return toNumber(this.left.value(ctx)) >> toNumber(this.right.value(ctx)) } + get type () { return 'numeric' } +} + +function cmpCast (v) { + if (typeof v === 'string' && /^-?\d*\.?\d+$/.test(v)) return Number(v) + return v +} +class Comparator extends Operation { + static precedence = 5 + get type () { return 'logical' } +} +class LessThan extends Comparator { value (ctx) { return cmpCast(this.left.value(ctx)) < cmpCast(this.right.value(ctx)) } } +class LessThanOrEqual extends Comparator { value (ctx) { return cmpCast(this.left.value(ctx)) <= cmpCast(this.right.value(ctx)) } } +class GreaterThan extends Comparator { value (ctx) { return cmpCast(this.left.value(ctx)) > cmpCast(this.right.value(ctx)) } } +class GreaterThanOrEqual extends Comparator { value (ctx) { return cmpCast(this.left.value(ctx)) >= cmpCast(this.right.value(ctx)) } } +class NotEqual extends Comparator { + value (ctx) { + const l = cmpCast(this.left.value(ctx)) + const r = cmpCast(this.right.value(ctx)) + return l !== r + } +} +class Equal extends Comparator { + value (ctx) { + const l = cmpCast(this.left.value(ctx)) + const r = cmpCast(this.right.value(ctx)) + return l === r + } +} + +class Combinator extends Operation { + static precedence = 0 + get type () { return 'logical' } +} +class And extends Combinator { value (ctx) { return Boolean(this.left.value(ctx)) && Boolean(this.right.value(ctx)) } } +class Or extends Combinator { value (ctx) { return Boolean(this.left.value(ctx)) || Boolean(this.right.value(ctx)) } } + +class ArrayNode extends Node { + static arity = null + static minParamCount () { return 0 } + static maxParamCount () { return Infinity } + constructor (...items) { + super() + this.items = items + } + + value (ctx) { return this.items.map((i) => i.value(ctx)) } + dependencies (ctx) { + return uniq([].concat.apply([], this.items.map((i) => i.dependencies(ctx)))) + } + + get type () { return 'array' } +} + +class FunctionNode extends Node { + static arity = null + static minParamCount () { return 0 } + static maxParamCount () { return Infinity } + constructor (...args) { + super() + this.args = args + } + + dependencies (ctx) { + return uniq([].concat.apply([], this.args.map((a) => a.dependencies(ctx)))) + } +} + +const FUNCTION_REGISTRY = Object.create(null) +function registerFunctionClass (name, cls) { FUNCTION_REGISTRY[name.toLowerCase()] = cls } +function getFunctionClass (name) { return FUNCTION_REGISTRY[name.toLowerCase()] } + +function defineFunction (name, spec) { + const min = spec.min == null ? 0 : spec.min + const max = spec.max == null ? Infinity : spec.max + const call = spec.call + const type = spec.type || 'numeric' + class F extends FunctionNode { + static arity = (min === max) ? min : null + static minParamCount () { return min } + static maxParamCount () { return max } + value (ctx) { + const vals = this.args.map((a) => a.value(ctx)) + return call(vals, ctx) + } + + get type () { return type } + } + registerFunctionClass(name, F) + return F +} + +function flattenNumeric (vals) { + const out = []; + (function walk (v) { + if (Array.isArray(v)) v.forEach(walk) + else out.push(toNumber(v)) + })(vals) + return out +} + +defineFunction('abs', { min: 1, max: 1, call: (v) => Math.abs(toNumber(v[0])) }) +defineFunction('min', { min: 1, call: (v) => Math.min.apply(null, flattenNumeric(v)) }) +defineFunction('max', { min: 1, call: (v) => Math.max.apply(null, flattenNumeric(v)) }) +defineFunction('sum', { min: 1, call: (v) => flattenNumeric(v).reduce((a, b) => a + b, 0) }) +defineFunction('avg', { + min: 1, + call: (v) => { + const nums = flattenNumeric(v) + if (nums.length === 0) return 0 + return nums.reduce((a, b) => a + b, 0) / nums.length + } +}) +defineFunction('count', { + min: 1, + max: 1, + call: (v) => Array.isArray(v[0]) ? v[0].length : (v[0] == null ? 0 : 1) +}) +defineFunction('round', { + min: 1, + max: 2, + call: (v) => { + const n = toNumber(v[0]) + const p = v.length > 1 ? toNumber(v[1]) : 0 + const f = Math.pow(10, p) + return Math.round(n * f) / f + } +}) +defineFunction('roundup', { + min: 1, + max: 2, + call: (v) => { + const n = toNumber(v[0]) + const p = v.length > 1 ? toNumber(v[1]) : 0 + const f = Math.pow(10, p) + return (n < 0 ? -Math.floor(-n * f) : Math.ceil(n * f)) / f + } +}) +defineFunction('rounddown', { + min: 1, + max: 2, + call: (v) => { + const n = toNumber(v[0]) + const p = v.length > 1 ? toNumber(v[1]) : 0 + const f = Math.pow(10, p) + return (n < 0 ? -Math.ceil(-n * f) : Math.floor(n * f)) / f + } +}) + +const MATH_FNS = { + sqrt: Math.sqrt, + cbrt: Math.cbrt, + sin: Math.sin, + cos: Math.cos, + tan: Math.tan, + asin: Math.asin, + acos: Math.acos, + atan: Math.atan, + sinh: Math.sinh, + cosh: Math.cosh, + tanh: Math.tanh, + exp: Math.exp, + log: Math.log, + log2: Math.log2, + log10: Math.log10, + floor: Math.floor, + ceil: Math.ceil +} +Object.keys(MATH_FNS).forEach((name) => { + const fn = MATH_FNS[name] + defineFunction(name, { min: 1, max: 1, call: (v) => fn(toNumber(v[0])) }) +}) +defineFunction('atan2', { min: 2, max: 2, call: (v) => Math.atan2(toNumber(v[0]), toNumber(v[1])) }) +defineFunction('hypot', { min: 1, call: (v) => Math.hypot.apply(null, flattenNumeric(v)) }) +defineFunction('pow', { min: 2, max: 2, call: (v) => Math.pow(toNumber(v[0]), toNumber(v[1])) }) +defineFunction('pi', { min: 0, max: 0, call: () => Math.PI }) +defineFunction('e', { min: 0, max: 0, call: () => Math.E }) + +defineFunction('not', { min: 1, max: 1, type: 'logical', call: (v) => !v[0] }) +defineFunction('and', { min: 1, type: 'logical', call: (v) => v.every((x) => Boolean(x)) }) +defineFunction('or', { min: 1, type: 'logical', call: (v) => v.some((x) => Boolean(x)) }) +defineFunction('xor', { + min: 2, + type: 'logical', + call: (v) => v.reduce((acc, x) => acc !== Boolean(x), false) +}) + +class IfFunction extends FunctionNode { + static arity = 3 + static minParamCount () { return 3 } + static maxParamCount () { return 3 } + value (ctx) { + return this.args[0].value(ctx) ? this.args[1].value(ctx) : this.args[2].value(ctx) + } + + get type () { return this.args[1].type } + dependencies (ctx) { + try { + return this.args[0].value(ctx) ? this.args[1].dependencies(ctx) : this.args[2].dependencies(ctx) + } catch (e) { + return uniq([].concat.apply([], this.args.map((a) => a.dependencies(ctx)))) + } + } +} +registerFunctionClass('if', IfFunction) + +defineFunction('length', { min: 1, max: 1, call: (v) => v[0] == null ? 0 : String(v[0]).length }) +defineFunction('upcase', { min: 1, max: 1, type: 'string', call: (v) => String(v[0]).toUpperCase() }) +defineFunction('downcase', { min: 1, max: 1, type: 'string', call: (v) => String(v[0]).toLowerCase() }) +defineFunction('concat', { min: 1, type: 'string', call: (v) => v.map((x) => String(x)).join('') }) +defineFunction('left', { + min: 2, + max: 2, + type: 'string', + call: (v) => String(v[0]).slice(0, toNumber(v[1])) +}) +defineFunction('right', { + min: 2, + max: 2, + type: 'string', + call: (v) => { + const n = toNumber(v[1]) + return n === 0 ? '' : String(v[0]).slice(-n) + } +}) +defineFunction('mid', { + min: 3, + max: 3, + type: 'string', + call: (v) => { + const s = String(v[0]) + const start = toNumber(v[1]) - 1 + const len = toNumber(v[2]) + return s.slice(start, start + len) + } +}) +defineFunction('trim', { min: 1, max: 1, type: 'string', call: (v) => String(v[0]).trim() }) +defineFunction('contains', { + min: 2, + max: 2, + type: 'logical', + call: (v) => { + if (Array.isArray(v[0])) return v[0].indexOf(v[1]) !== -1 + return String(v[0]).indexOf(String(v[1])) !== -1 + } +}) + +const OP_CLASSES = { + add: Addition, + subtract: Subtraction, + multiply: Multiplication, + divide: Division, + pow: Exponentiation, + negate: Negation, + mod: Modulo, + bitor: BitwiseOr, + bitand: BitwiseAnd, + bitshiftleft: BitwiseShiftLeft, + bitshiftright: BitwiseShiftRight, + lt: LessThan, + gt: GreaterThan, + le: LessThanOrEqual, + ge: GreaterThanOrEqual, + ne: NotEqual, + eq: Equal, + and: And, + or: Or +} + +function isOperationClass (cls) { + return cls && (cls.prototype instanceof Operation) +} + +function parse (tokens) { + if (!tokens || tokens.length === 0) return new NilNode() + + const output = [] + const operations = [] + const arities = [] + const skip = new Set() + + function consume (count) { + if (count == null) count = 2 + const op = operations.pop() + if (!op) throw new ParseError('invalid statement') + + const outputSize = output.length + const opMin = (typeof op.minParamCount === 'function') ? op.minParamCount() : null + const opMax = (typeof op.maxParamCount === 'function') ? op.maxParamCount() : null + + const actualArgs = (op.arity != null) ? op.arity : count + const minSize = (op.arity != null) ? op.arity : (opMin != null ? opMin : count) + const maxSize = (op.arity != null) ? op.arity : (opMax != null ? opMax : count) + + if (outputSize < minSize || actualArgs < minSize) { + throw new ParseError((op.name || 'operator') + ' has too few operands (given ' + outputSize + ', expected ' + minSize + ')') + } + if ((outputSize > maxSize && operations.length === 0) || actualArgs > maxSize) { + throw new ParseError((op.name || 'operator') + ' has too many operands (given ' + outputSize + ', expected ' + maxSize + ')') + } + + if (op === ArrayNode && output.length === 0) { + output.push(new ArrayNode()) + return + } + + if (outputSize < actualArgs) throw new ParseError('invalid statement') + const args = [] + for (let i = 0; i < actualArgs; i++) args.unshift(output.pop()) + + // eslint-disable-next-line new-cap + output.push(new op(...args)) + } + + function handleOperator (token, lookahead) { + let cls = OP_CLASSES[token.value] + if (!cls) throw new ParseError('Unknown operator ' + token.value) + if (typeof cls.resolveClass === 'function') cls = cls.resolveClass(lookahead) + const prec = cls.precedence + if (cls.rightAssociative) { + while (operations.length > 0) { + const top = operations[operations.length - 1] + if (isOperationClass(top) && prec < top.precedence) consume() + else break + } + } else { + while (operations.length > 0) { + const top = operations[operations.length - 1] + if (isOperationClass(top) && prec <= top.precedence) consume() + else break + } + } + operations.push(cls) + } + + function handleFunction (token) { + const fn = getFunctionClass(token.value) + if (!fn) throw new ParseError('Undefined function ' + token.value) + arities.push(0) + operations.push(fn) + } + + function handleArray (token) { + if (token.value === 'array_start') { + operations.push(ArrayNode) + arities.push(0) + } else { + while (operations.length > 0 && operations[operations.length - 1] !== ArrayNode) consume() + if (operations[operations.length - 1] !== ArrayNode) throw new ParseError('Unbalanced bracket') + consume(arities.pop() + 1) + } + } + + function handleGrouping (token, lookahead, index) { + if (token.value === 'open') { + if (lookahead && lookahead.value === 'close') { + skip.add(index + 1) + arities.pop() + consume(0) + } else { + operations.push(Grouping) + } + } else if (token.value === 'close') { + while (operations.length > 0 && operations[operations.length - 1] !== Grouping) consume() + const lparen = operations.pop() + if (lparen !== Grouping) throw new ParseError('Unbalanced parenthesis') + const top = operations[operations.length - 1] + if (top && (top.prototype instanceof FunctionNode)) consume(arities.pop() + 1) + } else if (token.value === 'comma') { + if (arities.length === 0) throw new ParseError('invalid statement') + arities[arities.length - 1] += 1 + while (operations.length > 0 && + operations[operations.length - 1] !== Grouping && + operations[operations.length - 1] !== ArrayNode) consume() + } else { + throw new ParseError('Unknown grouping token ' + token.value) + } + } + + for (let i = 0; i < tokens.length; i++) { + if (skip.has(i)) continue + const token = tokens[i] + const lookahead = tokens[i + 1] + + switch (token.category) { + case 'numeric': output.push(new Numeric(token)); break + case 'logical': output.push(new Logical(token)); break + case 'string': output.push(new StringNode(token)); break + case 'identifier': output.push(new Identifier(token)); break + case 'null': output.push(new NilNode()); break + case 'operator': + case 'comparator': + case 'combinator': + handleOperator(token, lookahead); break + case 'function': handleFunction(token); break + case 'array': handleArray(token); break + case 'grouping': handleGrouping(token, lookahead, i); break + default: + throw new ParseError('Not implemented for tokens of category ' + token.category) + } + } + + while (operations.length > 0) consume() + + if (output.length !== 1) throw new ParseError('Invalid statement') + return output[0] +} + +class Calculator { + constructor (options) { + options = options || {} + this.caseSensitive = options.caseSensitive === true + this.memory = Object.create(null) + this._astCache = Object.create(null) + } + + key (k) { return this.caseSensitive ? k : String(k).toLowerCase() } + + normalize (data) { + const out = Object.create(null) + if (!data) return out + for (const k of Object.keys(data)) out[this.key(k)] = data[k] + return out + } + + store (data) { Object.assign(this.memory, this.normalize(data)); return this } + bind (data) { return this.store(data) } + clear () { this.memory = Object.create(null); return this } + + ast (expression) { + const cached = this._astCache[expression] + if (cached) return cached + const node = parse(tokenize(expression, { caseSensitive: this.caseSensitive })) + this._astCache[expression] = node + return node + } + + tokenize (expression) { + return tokenize(expression, { caseSensitive: this.caseSensitive }) + } + + dependencies (expression) { + return this.ast(expression).dependencies(this.memory) + } + + evaluate (expression, data, onError) { + try { + return this.evaluateStrict(expression, data) + } catch (e) { + if (typeof data === 'function') return data(expression, e) + if (typeof onError === 'function') return onError(expression, e) + return undefined + } + } + + evaluateStrict (expression, data) { + const ctx = Object.assign(Object.create(null), this.memory, this.normalize(data)) + const node = this.ast(expression) + return node.value(ctx) + } + + addFunction (name, spec) { + defineFunction(name, { + min: spec.minArgs == null ? (spec.min == null ? 0 : spec.min) : spec.minArgs, + max: spec.maxArgs == null ? (spec.max == null ? Infinity : spec.max) : spec.maxArgs, + type: spec.type || 'numeric', + call: spec.call + }) + return this + } +} + +export { + Calculator, + tokenize, + parse, + CalculatorError, + ParseError, + TokenizerError, + UnboundVariableError, + ZeroDivisionError, + ArgumentError +} + +export function addFunction (name, spec) { defineFunction(name, spec) } diff --git a/app/javascript/submission_form/form.vue b/app/javascript/submission_form/form.vue index e2bdcf42..0f469459 100644 --- a/app/javascript/submission_form/form.vue +++ b/app/javascript/submission_form/form.vue @@ -402,6 +402,8 @@ :remember-signature="rememberSignature" :attachments-index="attachmentsIndex" :require-signing-reason="requireSigningReason" + :signature-text="signatureText" + :signature-src="signatureSrc" :button-text="submitButtonText" :dry-run="dryRun" :with-disclosure="withDisclosure" @@ -832,6 +834,16 @@ export default { required: false, default: '' }, + signatureText: { + type: String, + required: false, + default: '' + }, + signatureSrc: { + type: String, + required: false, + default: '' + }, previousSignatureValue: { type: String, required: false, diff --git a/app/javascript/submission_form/formula_areas.vue b/app/javascript/submission_form/formula_areas.vue index d5f80507..b0b1698c 100644 --- a/app/javascript/submission_form/formula_areas.vue +++ b/app/javascript/submission_form/formula_areas.vue @@ -68,34 +68,9 @@ export default { } }, async mounted () { - const { - create, - evaluateDependencies, - addDependencies, - subtractDependencies, - divideDependencies, - multiplyDependencies, - powDependencies, - roundDependencies, - absDependencies, - sinDependencies, - tanDependencies, - cosDependencies - } = await import('mathjs') + const { Calculator } = await import('./calculator') - this.math = create({ - evaluateDependencies, - addDependencies, - subtractDependencies, - divideDependencies, - multiplyDependencies, - powDependencies, - roundDependencies, - absDependencies, - sinDependencies, - tanDependencies, - cosDependencies - }) + this.math = new Calculator() this.isMathLoaded = true }, diff --git a/app/javascript/submission_form/payment_step.vue b/app/javascript/submission_form/payment_step.vue index adf4e19c..bcf32892 100644 --- a/app/javascript/submission_form/payment_step.vue +++ b/app/javascript/submission_form/payment_step.vue @@ -165,34 +165,9 @@ export default { } if (this.field.preferences?.formula) { - const { - create, - evaluateDependencies, - addDependencies, - subtractDependencies, - divideDependencies, - multiplyDependencies, - powDependencies, - roundDependencies, - absDependencies, - sinDependencies, - tanDependencies, - cosDependencies - } = await import('mathjs') + const { Calculator } = await import('./calculator') - this.math = create({ - evaluateDependencies, - addDependencies, - subtractDependencies, - divideDependencies, - multiplyDependencies, - powDependencies, - roundDependencies, - absDependencies, - sinDependencies, - tanDependencies, - cosDependencies - }) + this.math = new Calculator() this.isMathLoaded = true } diff --git a/app/javascript/submission_form/signature_step.vue b/app/javascript/submission_form/signature_step.vue index 2651f8d5..fb59ef3b 100644 --- a/app/javascript/submission_form/signature_step.vue +++ b/app/javascript/submission_form/signature_step.vue @@ -408,6 +408,16 @@ export default { required: false, default: '' }, + signatureText: { + type: String, + required: false, + default: '' + }, + signatureSrc: { + type: String, + required: false, + default: '' + }, modelValue: { type: String, required: false, @@ -422,7 +432,7 @@ export default { isOtherReason: false, isUsePreviousValue: true, isTouchAttachment: false, - isTextSignature: this.field.preferences?.format === 'typed' || this.field.preferences?.format === 'typed_or_upload', + isTextSignature: !this.signatureSrc && (!!this.signatureText || this.field.preferences?.format === 'typed' || this.field.preferences?.format === 'typed_or_upload'), uploadImageInputKey: Math.random().toString() } }, @@ -482,7 +492,9 @@ export default { if (entry.isIntersecting) { this.setCanvasSize() - if (this.isTextSignature) { + if (this.signatureSrc) { + this.$nextTick(() => this.drawSignatureSrc()) + } else if (this.isTextSignature) { this.$nextTick(() => { if (this.$refs.textInput) { this.initTypedSignature() @@ -686,7 +698,9 @@ export default { } }, async initTypedSignature () { - if (this.submitter.name) { + if (this.signatureText) { + this.$refs.textInput.value = this.signatureText + } else if (this.submitter.name) { this.$refs.textInput.value = this.submitter.name } @@ -696,6 +710,48 @@ export default { this.updateWrittenSignature({ target: this.$refs.textInput }) } }, + drawSignatureSrc () { + const canvas = this.$refs.canvas + + if (!canvas) return + + const img = new Image() + + img.crossOrigin = 'anonymous' + + img.onload = () => { + const context = canvas.getContext('2d') + + const aspectRatio = img.width / img.height + const canvasWidth = canvas.width / scale + const canvasHeight = canvas.height / scale + + let targetWidth = canvasWidth + let targetHeight = canvasHeight + + if (canvasWidth / canvasHeight > aspectRatio) { + targetWidth = canvasHeight * aspectRatio + } else { + targetHeight = canvasWidth / aspectRatio + } + + const x = (canvasWidth - targetWidth) / 2 + const y = (canvasHeight - targetHeight) / 2 + + context.clearRect(0, 0, canvasWidth, canvasHeight) + context.drawImage(img, x, y, targetWidth, targetHeight) + + this.isSignatureStarted = true + + this.$emit('start') + } + + img.onerror = () => { + console.error(`Failed to load signature image from ${this.signatureSrc}. The remote server must send an Access-Control-Allow-Origin header to allow CORS access.`) + } + + img.src = this.signatureSrc + }, drawImage (event) { this.remove() this.clear() diff --git a/app/javascript/submission_form/verification_step.vue b/app/javascript/submission_form/verification_step.vue index 29b3b590..0e0bb767 100644 --- a/app/javascript/submission_form/verification_step.vue +++ b/app/javascript/submission_form/verification_step.vue @@ -20,8 +20,11 @@ {{ t('complete_all_required_fields_to_proceed_with_identity_verification') }}