From 46838a2b79221ea3474515559487277e06c5f475 Mon Sep 17 00:00:00 2001 From: Eros Stein Date: Fri, 8 May 2026 07:19:23 -0300 Subject: [PATCH] Adding embedding functionality --- app/controllers/account_configs_controller.rb | 19 +- app/controllers/api/api_base_controller.rb | 2 + app/controllers/api/attachments_controller.rb | 4 + .../api/submitter_email_clicks_controller.rb | 3 + .../api/submitter_form_views_controller.rb | 3 + app/controllers/concerns/embed_cors.rb | 76 +++++ app/controllers/cors_preflight_controller.rb | 11 + app/controllers/embed/forms_controller.rb | 267 ++++++++++++++++++ .../send_submission_email_controller.rb | 5 + ...bmit_form_completed_download_controller.rb | 5 + app/controllers/submit_form_controller.rb | 8 + .../submit_form_download_controller.rb | 5 + .../submit_form_metadata_controller.rb | 9 +- .../submit_form_values_controller.rb | 7 +- app/models/account_config.rb | 1 + app/views/accounts/show.html.erb | 21 ++ config/locales/i18n.yml | 3 + config/routes.rb | 8 + spec/requests/embed_forms_spec.rb | 50 ++++ 19 files changed, 503 insertions(+), 4 deletions(-) create mode 100644 app/controllers/concerns/embed_cors.rb create mode 100644 app/controllers/cors_preflight_controller.rb create mode 100644 app/controllers/embed/forms_controller.rb create mode 100644 spec/requests/embed_forms_spec.rb diff --git a/app/controllers/account_configs_controller.rb b/app/controllers/account_configs_controller.rb index 079866d7..19819af6 100644 --- a/app/controllers/account_configs_controller.rb +++ b/app/controllers/account_configs_controller.rb @@ -25,7 +25,8 @@ class AccountConfigsController < ApplicationController AccountConfig::COMBINE_PDF_RESULT_KEY, AccountConfig::REQUIRE_SIGNING_REASON_KEY, AccountConfig::DOCUMENT_FILENAME_FORMAT_KEY, - AccountConfig::ENABLE_MCP_KEY + AccountConfig::ENABLE_MCP_KEY, + AccountConfig::EMBED_ALLOWED_ORIGINS_KEY ].freeze InvalidKey = Class.new(StandardError) @@ -60,6 +61,22 @@ class AccountConfigsController < ApplicationController def account_config_params params.required(:account_config).permit(:key, :value, { value: {} }, { value: [] }).tap do |attrs| attrs[:value] = attrs[:value] == '1' if attrs[:value].in?(%w[1 0]) + attrs[:value] = normalize_origins(attrs[:value]) if attrs[:key] == AccountConfig::EMBED_ALLOWED_ORIGINS_KEY end end + + def normalize_origins(value) + value.to_s.split(/[\s,]+/).filter_map do |origin| + uri = Addressable::URI.parse(origin.strip) + + next unless uri.scheme.in?(%w[http https]) && uri.host.present? + + uri.path = nil + uri.query = nil + uri.fragment = nil + uri.to_s.delete_suffix('/') + rescue Addressable::URI::InvalidURIError + nil + end.uniq + end end diff --git a/app/controllers/api/api_base_controller.rb b/app/controllers/api/api_base_controller.rb index ff01fc8f..88acdbee 100644 --- a/app/controllers/api/api_base_controller.rb +++ b/app/controllers/api/api_base_controller.rb @@ -2,6 +2,7 @@ module Api class ApiBaseController < ActionController::API + include EmbedCors include ActiveStorage::SetCurrent include Pagy::Method @@ -13,6 +14,7 @@ module Api wrap_parameters false before_action :authenticate_user! + before_action :set_embed_cors_headers check_authorization rescue_from Params::BaseValidator::InvalidParameterError do |e| diff --git a/app/controllers/api/attachments_controller.rb b/app/controllers/api/attachments_controller.rb index c81aa0f9..e9bf5d5f 100644 --- a/app/controllers/api/attachments_controller.rb +++ b/app/controllers/api/attachments_controller.rb @@ -2,6 +2,7 @@ module Api class AttachmentsController < ActionController::API + include EmbedCors include ActionController::Cookies include ActiveStorage::SetCurrent @@ -9,6 +10,9 @@ module Api def create submitter = Submitter.find_by!(slug: params[:submitter_slug]) + @embed_cors_account = submitter.account + + set_embed_cors_headers unless can_upload?(submitter) Rollbar.error("Can't upload: #{submitter.id}") if defined?(Rollbar) diff --git a/app/controllers/api/submitter_email_clicks_controller.rb b/app/controllers/api/submitter_email_clicks_controller.rb index cef26542..3b0449f9 100644 --- a/app/controllers/api/submitter_email_clicks_controller.rb +++ b/app/controllers/api/submitter_email_clicks_controller.rb @@ -7,6 +7,9 @@ module Api def create @submitter = Submitter.find_by!(slug: params[:submitter_slug]) + @embed_cors_account = @submitter.account + + set_embed_cors_headers if params[:t] == SubmissionEvents.build_tracking_param(@submitter, 'click_email') SubmissionEvents.create_with_tracking_data(@submitter, 'click_email', request) diff --git a/app/controllers/api/submitter_form_views_controller.rb b/app/controllers/api/submitter_form_views_controller.rb index d9ac441e..80d89899 100644 --- a/app/controllers/api/submitter_form_views_controller.rb +++ b/app/controllers/api/submitter_form_views_controller.rb @@ -7,6 +7,9 @@ module Api def create @submitter = Submitter.find_by!(slug: params[:submitter_slug]) + @embed_cors_account = @submitter.account + + set_embed_cors_headers @submitter.opened_at = Time.current @submitter.save diff --git a/app/controllers/concerns/embed_cors.rb b/app/controllers/concerns/embed_cors.rb new file mode 100644 index 00000000..98601df0 --- /dev/null +++ b/app/controllers/concerns/embed_cors.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module EmbedCors + extend ActiveSupport::Concern + + private + + def set_embed_cors_headers + allowed_origin = embed_cors_allowed_origin + + headers.delete('Access-Control-Allow-Origin') + + return if allowed_origin.blank? + + headers['Access-Control-Allow-Origin'] = allowed_origin + headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS' + headers['Access-Control-Allow-Headers'] = '*' + headers['Access-Control-Max-Age'] = '1728000' + headers['Vary'] = [headers['Vary'], 'Origin'].compact_blank.join(', ') unless allowed_origin == '*' + end + + def embed_cors_allowed_origin + origin = normalized_embed_origin(request.headers['Origin']) + + return '*' if origin.blank? + + account_origins = configured_embed_origins_for_account(embed_cors_account) + + return origin if account_origins.include?(origin) + return if account_origins.present? + + configured_origins = configured_embed_origins + + return origin if configured_origins.include?(origin) + return if configured_origins.present? + + '*' + end + + def configured_embed_origins_for_account(account) + normalize_embed_origins( + account&.account_configs&.find_by(key: AccountConfig::EMBED_ALLOWED_ORIGINS_KEY)&.value + ) + end + + def configured_embed_origins + normalize_embed_origins( + AccountConfig.where(key: AccountConfig::EMBED_ALLOWED_ORIGINS_KEY).map(&:value).flatten + ) + end + + def normalize_embed_origins(value) + Array.wrap(value).filter_map { |origin| normalized_embed_origin(origin) }.uniq + end + + def normalized_embed_origin(origin) + return if origin.blank? + + uri = Addressable::URI.parse(origin.to_s.strip) + + return unless uri.scheme.in?(%w[http https]) && uri.host.present? + + uri.path = nil + uri.query = nil + uri.fragment = nil + uri.to_s.delete_suffix('/') + rescue Addressable::URI::InvalidURIError + nil + end + + def embed_cors_account + return @embed_cors_account if defined?(@embed_cors_account) + + current_account if respond_to?(:current_account, true) + end +end diff --git a/app/controllers/cors_preflight_controller.rb b/app/controllers/cors_preflight_controller.rb new file mode 100644 index 00000000..148b65b7 --- /dev/null +++ b/app/controllers/cors_preflight_controller.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class CorsPreflightController < ActionController::API + include EmbedCors + + before_action :set_embed_cors_headers + + def show + head :ok + end +end diff --git a/app/controllers/embed/forms_controller.rb b/app/controllers/embed/forms_controller.rb new file mode 100644 index 00000000..53ded7e5 --- /dev/null +++ b/app/controllers/embed/forms_controller.rb @@ -0,0 +1,267 @@ +# frozen_string_literal: true + +module Embed + class FormsController < ActionController::API + include ActionController::Cookies + include ActiveStorage::SetCurrent + include EmbedCors + + before_action :set_embed_cors_headers, only: :preflight + + def create + slug = token_payload['slug'] || params[:slug].presence || params[:template_slug].presence + + raise ActiveRecord::RecordNotFound if slug.blank? + + submitter = Submitter.find_by(slug: slug) + + payload = + if submitter + @embed_cors_account = submitter.account + + form_json(submitter) + else + template = Template.find_by!(slug: slug) + @embed_cors_account = template.account + + form_json_for_template(template) + end + + set_embed_cors_headers + + render json: payload, status: payload[:error].present? ? :unprocessable_content : :ok + end + + def preflight + head :ok + end + + private + + def form_json_for_template(template) + raise ActiveRecord::RecordNotFound if template.archived_at? || template.account.archived_at? + raise ActiveRecord::RecordNotFound unless template.shared_link? + + attrs = submitter_params + + return { template: template_json(template), logo: logo_json(template.account) } if attrs.compact_blank.blank? + + submitter = find_or_initialize_submitter(template, attrs) + + if selected_template_submitter(template).blank? && filter_undefined_submitters(template).size > 1 && submitter.new_record? + return { error: I18n.t('this_template_has_multiple_parties_which_prevents_the_use_of_a_sharing_link') } + end + + if submitter.new_record? + assign_submission_attributes(submitter, template) + Submissions::AssignDefinedSubmitters.call(submitter.submission) + else + submitter.assign_attributes(ip: request.remote_ip, ua: request.user_agent) + end + + submitter.values = values_param.presence || submitter.values + submitter.metadata = metadata_param.presence || submitter.metadata + submitter.external_id = params[:external_id].presence || submitter.external_id + + if template.preferences['shared_link_2fa'] == true && + !Submitters::AuthorizedForForm.pass_link_2fa?(submitter, nil, request) + Submitters.send_shared_link_email_verification_code(submitter, request: request) + + return { template: template_json(template), unverified_email: submitter.email, logo: logo_json(template.account) } + end + + if submitter.errors.blank? && submitter.save + enqueue_new_submitter_jobs(submitter) if submitter.previous_changes.key?('id') + + form_json(submitter) + else + { error: submitter.errors.full_messages.to_sentence } + end + end + + def form_json(submitter) + raise ActiveRecord::RecordNotFound if submitter.account.archived_at? + + return { expired_submitter: submitter_json(submitter), submission: submission_json(submitter.submission, submitter), + template: template_json(submitter.template), logo: logo_json(submitter.account) } if submitter.submission.expired? + + return { completed_submitter: submitter_json(submitter), submission: submission_json(submitter.submission, submitter), + template: template_json(submitter.template), logo: logo_json(submitter.account) } if submitter.completed_at? + + return { expired_submitter: submitter_json(submitter), submission: submission_json(submitter.submission, submitter), + template: template_json(submitter.template), logo: logo_json(submitter.account) } if submitter.declined_at? + + unless Submitters::AuthorizedForForm.pass_email_2fa?(submitter, request) + return { submitter_email_2fa: submitter_json(submitter), + submission: submission_json(submitter.submission, submitter), + template: template_json(submitter.template), logo: logo_json(submitter.account) } + end + + unless Submitters::AuthorizedForForm.pass_link_2fa?(submitter, nil, request) + return { unverified_email: submitter.email, submission: submission_json(submitter.submission, submitter), + template: template_json(submitter.template), logo: logo_json(submitter.account) } + end + + submission = submitter.submission + + Submissions.preload_with_pages(submission) + Submitters::MaybeUpdateDefaultValues.call(submitter, nil) + + { + template: template_json(submitter.template), + submission: submission_json(submission, submitter), + submitter: submitter_json(submitter), + documents: documents_json(submission), + attachments: attachments_json(submission), + values: values_param.presence || {}, + logo: logo_json(submitter.account) + } + end + + def template_json(template) + template.as_json(only: %i[id name slug preferences schema submitters archived_at account_id]) + end + + def submission_json(submission, submitter = nil) + submission.as_json( + only: %i[id slug name source submitters_order expire_at archived_at created_at updated_at template_id account_id], + methods: %i[expired?], + ).merge( + 'template_schema' => Submissions.filtered_conditions_schema(submission, + include_submitter_uuid: submitter&.uuid), + 'template_fields' => submission.template_fields || submission.template.fields, + 'template_submitters' => submission.template_submitters || submission.template.submitters, + 'submitters' => submission.submitters.as_json( + only: %i[id uuid slug name email phone completed_at declined_at opened_at sent_at] + ) + ) + end + + def submitter_json(submitter) + submitter.as_json( + only: %i[id uuid slug name email phone values metadata preferences completed_at declined_at opened_at sent_at] + ) + end + + def documents_json(submission) + submission.schema_documents.as_json( + methods: %i[metadata signed_key], + include: { preview_images: { methods: %i[url metadata filename] } } + ) + end + + def attachments_json(submission) + ActiveStorage::Attachment.where(record: submission.submitters, name: :attachments) + .preload(:blob) + .as_json(only: %i[uuid created_at], methods: %i[url filename content_type]) + end + + def logo_json(account) + return unless account.logo.attached? + + { url: account.logo.url } + end + + def find_or_initialize_submitter(template, attrs) + required_fields = template.preferences.fetch('link_form_fields', ['email']) + required_params = required_fields.index_with { |key| attrs[key] } + find_params = required_params.except('name') + + submitter = Submitter.new if find_params.compact_blank.blank? + + relation = + Submitter + .where(submission: template.submissions.where(expire_at: Time.current..) + .or(template.submissions.where(expire_at: nil)).where(archived_at: nil)) + .order(id: :desc) + .where(declined_at: nil) + .where(external_id: nil) + .where(template.preferences['shared_link_2fa'] == true ? {} : { ip: [nil, request.remote_ip] }) + + if (template_submitter = selected_template_submitter(template)) + relation = relation.where(uuid: template_submitter['uuid']) + end + + submitter ||= relation + .find_or_initialize_by(find_params) + + submitter.name = required_params['name'] if submitter.new_record? + + required_params.each do |key, value| + submitter.errors.add(key.to_sym, :blank) if value.blank? + end + + submitter + end + + def assign_submission_attributes(submitter, template) + submitter.assign_attributes( + uuid: (selected_template_submitter(template) || filter_undefined_submitters(template).first || + template.submitters.first)['uuid'], + ip: request.remote_ip, + ua: request.user_agent, + values: {}, + preferences: { 'send_email' => params[:send_email] }, + metadata: {} + ) + + submitter.submission ||= Submission.new(template: template, + account_id: template.account_id, + template_submitters: template.submitters, + template_fields: template.fields, + template_schema: template.schema, + expire_at: Templates.build_default_expire_at(template), + submitters: [submitter], + source: :embed) + + Submissions::CreateFromSubmitters.maybe_set_dynamic_documents(submitter.submission) + + submitter.account_id = submitter.submission.account_id + end + + def enqueue_new_submitter_jobs(submitter) + WebhookUrls.enqueue_events(submitter.submission, 'submission.created') + SearchEntries.enqueue_reindex(submitter) + + return unless submitter.submission.expire_at? + + ProcessSubmissionExpiredJob.perform_at(submitter.submission.expire_at, 'submission_id' => submitter.submission_id) + end + + def filter_undefined_submitters(template) + Templates.filter_undefined_submitters(template.submitters) + end + + def selected_template_submitter(template) + submitter_name = params[:submitter].presence || params[:role].presence + + return if submitter_name.blank? + + template.submitters.find { |item| item['uuid'] == submitter_name || item['name'] == submitter_name } + end + + def submitter_params + { + 'email' => Submissions.normalize_email(params[:email].presence), + 'name' => params[:name].presence, + 'phone' => params[:phone].presence + }.compact + end + + def values_param + params[:values].respond_to?(:to_unsafe_h) ? params[:values].to_unsafe_h : params[:values] + end + + def metadata_param + params[:metadata].respond_to?(:to_unsafe_h) ? params[:metadata].to_unsafe_h : params[:metadata] + end + + def token_payload + return {} if params[:token].blank? + + JSON.parse(Base64.urlsafe_decode64(params[:token].split('.')[1])).with_indifferent_access + rescue JSON::ParserError, ArgumentError + {} + end + end +end diff --git a/app/controllers/send_submission_email_controller.rb b/app/controllers/send_submission_email_controller.rb index c3a95158..ef8449e4 100644 --- a/app/controllers/send_submission_email_controller.rb +++ b/app/controllers/send_submission_email_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class SendSubmissionEmailController < ApplicationController + include EmbedCors + layout 'form' skip_before_action :authenticate_user! @@ -27,6 +29,9 @@ class SendSubmissionEmailController < ApplicationController @submitter = Submitter.completed.find_by!(slug: params[:submitter_slug]) end + @embed_cors_account = @submitter.account + set_embed_cors_headers + RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes) SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter) diff --git a/app/controllers/submit_form_completed_download_controller.rb b/app/controllers/submit_form_completed_download_controller.rb index 19dfafb1..62287034 100644 --- a/app/controllers/submit_form_completed_download_controller.rb +++ b/app/controllers/submit_form_completed_download_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class SubmitFormCompletedDownloadController < ApplicationController + include EmbedCors + skip_before_action :authenticate_user! skip_authorization_check @@ -18,6 +20,9 @@ class SubmitFormCompletedDownloadController < ApplicationController end @submitter ||= Submitter.find_by!(slug: submitter_slug) + @embed_cors_account = @submitter.account + + set_embed_cors_headers Submissions::EnsureResultGenerated.call(@submitter) diff --git a/app/controllers/submit_form_controller.rb b/app/controllers/submit_form_controller.rb index a65c2237..5caa57d9 100644 --- a/app/controllers/submit_form_controller.rb +++ b/app/controllers/submit_form_controller.rb @@ -1,13 +1,17 @@ # frozen_string_literal: true class SubmitFormController < ApplicationController + include EmbedCors + layout 'form' around_action :with_browser_locale, only: %i[show completed success delegated] skip_before_action :authenticate_user! + skip_before_action :verify_authenticity_token, only: :update skip_authorization_check before_action :load_submitter, only: %i[show update completed] + before_action :set_embed_cors_headers, only: :update before_action :maybe_redirect_delegated, only: %i[show completed] before_action :maybe_render_locked_page, only: :show before_action :maybe_require_link_2fa, only: %i[show] @@ -135,4 +139,8 @@ class SubmitFormController < ApplicationController ActiveStorage::Attachment.where(record: submission.submitters, name: :attachments) .preload(:blob).index_by(&:uuid) end + + def embed_cors_account + @submitter&.account || super + end end diff --git a/app/controllers/submit_form_download_controller.rb b/app/controllers/submit_form_download_controller.rb index e1c22eba..ad544322 100644 --- a/app/controllers/submit_form_download_controller.rb +++ b/app/controllers/submit_form_download_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class SubmitFormDownloadController < ApplicationController + include EmbedCors + skip_before_action :authenticate_user! skip_authorization_check @@ -8,6 +10,9 @@ class SubmitFormDownloadController < ApplicationController def index @submitter = Submitter.find_by!(slug: params[:submit_form_slug]) + @embed_cors_account = @submitter.account + + set_embed_cors_headers return redirect_to submit_form_documents_path(@submitter.slug) if @submitter.completed_at? diff --git a/app/controllers/submit_form_metadata_controller.rb b/app/controllers/submit_form_metadata_controller.rb index 49bf8666..b34f51bc 100644 --- a/app/controllers/submit_form_metadata_controller.rb +++ b/app/controllers/submit_form_metadata_controller.rb @@ -1,11 +1,16 @@ # frozen_string_literal: true class SubmitFormMetadataController < ApplicationController + include EmbedCors + skip_before_action :authenticate_user! skip_authorization_check def index submitter = Submitter.find_by!(slug: params[:submit_form_slug]) + @embed_cors_account = submitter.account + + set_embed_cors_headers return head :not_found if submitter.declined_at? || submitter.completed_at? || @@ -17,7 +22,7 @@ class SubmitFormMetadataController < ApplicationController submission = submitter.submission values = submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } - schema = Submissions.filtered_conditions_schema(submission, values:, include_submitter_uuid: submitter.uuid) + schema = Submissions.filtered_conditions_schema(submission, values: values, include_submitter_uuid: submitter.uuid) documents = schema.filter_map do |item| submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] } @@ -32,6 +37,6 @@ class SubmitFormMetadataController < ApplicationController ] end - render json: { text_runs: } + render json: { text_runs: text_runs } end end diff --git a/app/controllers/submit_form_values_controller.rb b/app/controllers/submit_form_values_controller.rb index affd37ba..f09b7e64 100644 --- a/app/controllers/submit_form_values_controller.rb +++ b/app/controllers/submit_form_values_controller.rb @@ -1,11 +1,16 @@ # frozen_string_literal: true class SubmitFormValuesController < ApplicationController + include EmbedCors + skip_before_action :authenticate_user! skip_authorization_check def index submitter = Submitter.find_by!(slug: params[:submit_form_slug]) + @embed_cors_account = submitter.account + + set_embed_cors_headers return render json: {} if submitter.completed_at? || submitter.declined_at? || @@ -18,7 +23,7 @@ class SubmitFormValuesController < ApplicationController attachment = submitter.attachments.where(created_at: params[:after]..).find_by(uuid: value) if value.present? render json: { - value:, + value: value, attachment: attachment&.as_json(only: %i[uuid created_at], methods: %i[url filename content_type]) }, head: :ok end diff --git a/app/models/account_config.rb b/app/models/account_config.rb index 8d41bca4..f296d885 100644 --- a/app/models/account_config.rb +++ b/app/models/account_config.rb @@ -59,6 +59,7 @@ class AccountConfig < ApplicationRecord TEMPLATE_CUSTOM_FIELDS_KEY = 'template_custom_fields' POLICY_LINKS_KEY = 'policy_links' ENABLE_MCP_KEY = 'enable_mcp' + EMBED_ALLOWED_ORIGINS_KEY = 'embed_allowed_origins' EMAIL_VARIABLES = { SUBMITTER_INVITATION_EMAIL_KEY => %w[template.name submitter.link account.name].freeze, diff --git a/app/views/accounts/show.html.erb b/app/views/accounts/show.html.erb index ba9a9c49..37676001 100644 --- a/app/views/accounts/show.html.erb +++ b/app/views/accounts/show.html.erb @@ -228,6 +228,27 @@ <% end %> <% end %> + <% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::EMBED_ALLOWED_ORIGINS_KEY) %> + <% if can?(:manage, account_config) %> + <%= form_for account_config, url: account_configs_path, method: :post, html: { class: 'py-2.5' } do |f| %> + <%= f.hidden_field :key %> +
+
+ <%= t('allowed_embedding_origins') %> + + <%= svg_icon('info_circle', class: 'hidden md:inline-block w-4 h-4 shrink-0') %> + +
+ <%= text_area_tag 'account_config[value]', Array(account_config.value).join("\n"), class: 'base-input min-h-28', placeholder: "https://app.example.com\nhttp://localhost:5173", dir: 'ltr' %> +
+

+ <%= t('leave_blank_to_allow_embedding_from_any_origin') %> +

+ <%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'btn btn-sm btn-neutral text-white px-4' %> +
+
+ <% end %> + <% end %> <% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::COMBINE_PDF_RESULT_KEY) %> <% if can?(:manage, account_config) %> <%= form_for account_config, url: account_configs_path, method: :post do |f| %> diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml index 37e6f448..647a4fcc 100644 --- a/config/locales/i18n.yml +++ b/config/locales/i18n.yml @@ -46,6 +46,7 @@ en: &en click_here_to_send_a_reset_password_email_html: ' to send a reset password email.' edit_order: Edit Order expirable_file_download_links: Expirable file download links + allowed_embedding_origins: Allowed embedding origins sender_form_fields: Sender form fields default_parties: Default parties authenticate_embedded_form_preview_with_token: Authenticate embedded form preview with token @@ -209,6 +210,7 @@ en: &en allow_to_delegate_documents: Allow to delegate documents remember_and_pre_fill_signatures: Remember and pre-fill signatures require_authentication_for_file_download_links: Require authentication for file download links + leave_blank_to_allow_embedding_from_any_origin: Leave blank to allow embedding from any origin. combine_completed_documents_and_audit_log: Combine completed documents and Audit Log salesforce_integration: Salesforce Integration salesforce_has_been_connected: Salesforce has been connected. @@ -855,6 +857,7 @@ en: &en save_a_users_signature_and_automatically_pre_fill_it_in_future_signing_sessions: "Save a user's signature and automatically pre-fill it in future signing sessions." make_document_download_links_expire_after_40_minutes_to_prevent_long_term_access_and_enhance_security: Make document download links expire after 40 minutes to prevent long-term access and enhance security. require_authentication_with_user_login_or_api_key_to_access_the_document_download_links: Require authentication with user login or API key to access the document download links. + allowed_embedding_origins_description: Enter allowed frontend origins for embedded signing, one per line or separated by commas. Include protocol and port, for example https://app.example.com or http://localhost:5173. combine_signed_documents_and_the_audit_log_into_a_single_pdf_file_for_easier_recordkeeping_and_compliance: Combine signed documents and the Audit Log into a single PDF file for easier recordkeeping and compliance. require_a_jwt_authorization_to_preview_embedded_forms_ensuring_only_authorized_users_can_view_them: Require a JWT authorization to preview embedded forms, ensuring only authorized users can view them. make_all_newly_created_templates_private_to_their_creator_by_default: Make all newly created templates private to their creator by default. diff --git a/config/routes.rb b/config/routes.rb index 0dfef7ea..8ece31b1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -214,6 +214,14 @@ Rails.application.routes.draw do match '/mcp', to: 'mcp#call', via: %i[get post] + post '/embed/forms', to: 'embed/forms#create' + match '/embed/forms', to: 'embed/forms#preflight', via: :options + match '/s/:slug', to: 'cors_preflight#show', via: :options + match '/s/:slug/*path', to: 'cors_preflight#show', via: :options + match '/submitters/:submitter_id/download', to: 'cors_preflight#show', via: :options + match '/send_submission_email', to: 'cors_preflight#show', via: :options + match '/api/*path', to: 'cors_preflight#show', via: :options + get '/js/:filename', to: 'embed_scripts#show', as: :embed_script ActiveSupport.run_load_hooks(:routes, self) diff --git a/spec/requests/embed_forms_spec.rb b/spec/requests/embed_forms_spec.rb new file mode 100644 index 00000000..c3ad28c8 --- /dev/null +++ b/spec/requests/embed_forms_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +describe 'Embed forms' do + let(:account) { create(:account) } + let(:author) { create(:user, account: account) } + let(:template) { create(:template, account: account, author: author, shared_link: true) } + + describe 'POST /embed/forms' do + it 'returns a shared template payload before the signer starts' do + post '/embed/forms', params: { slug: template.slug }.to_json, headers: { 'CONTENT_TYPE' => 'application/json' } + + expect(response).to have_http_status(:ok) + expect(response.parsed_body.dig('template', 'id')).to eq(template.id) + expect(response.parsed_body['submitter']).to be_nil + expect(response.headers['Access-Control-Allow-Origin']).to eq('*') + end + + it 'creates an embedded submitter and returns signing data' do + post '/embed/forms', + params: { slug: template.slug, email: 'signer@example.com', name: 'Jane Signer' }.to_json, + headers: { 'CONTENT_TYPE' => 'application/json' } + + expect(response).to have_http_status(:ok) + expect(response.parsed_body.dig('submitter', 'email')).to eq('signer@example.com') + expect(response.parsed_body.dig('submission', 'source')).to eq('embed') + expect(response.parsed_body.dig('submission', 'template_fields')).to be_present + expect(response.parsed_body['documents']).to be_present + end + + it 'loads an existing submitter by slug' do + submitter = create(:submission, :with_submitters, template: template).submitters.first + + post '/embed/forms', params: { slug: submitter.slug }.to_json, headers: { 'CONTENT_TYPE' => 'application/json' } + + expect(response).to have_http_status(:ok) + expect(response.parsed_body.dig('submitter', 'slug')).to eq(submitter.slug) + end + end + + describe 'OPTIONS /embed/forms' do + it 'returns CORS preflight headers' do + process :options, '/embed/forms' + + expect(response).to have_http_status(:ok) + expect(response.headers['Access-Control-Allow-Origin']).to eq('*') + expect(response.headers['Access-Control-Allow-Methods']).to include('POST') + expect(response.headers['Access-Control-Allow-Headers']).to eq('*') + end + end +end