diff --git a/app/controllers/api/templates_controller.rb b/app/controllers/api/templates_controller.rb
index 5e8da098..25c0c537 100644
--- a/app/controllers/api/templates_controller.rb
+++ b/app/controllers/api/templates_controller.rb
@@ -100,6 +100,7 @@ module Api
permitted_params = [
:name,
:external_id,
+ :shared_link,
{
submitters: [%i[name uuid is_requester invite_by_uuid optional_invite_by_uuid linked_to_uuid email]],
fields: [[:uuid, :submitter_uuid, :name, :type,
diff --git a/app/controllers/start_form_controller.rb b/app/controllers/start_form_controller.rb
index 28a77f86..c7b74a3f 100644
--- a/app/controllers/start_form_controller.rb
+++ b/app/controllers/start_form_controller.rb
@@ -11,14 +11,22 @@ class StartFormController < ApplicationController
before_action :load_template
def show
- raise ActionController::RoutingError, I18n.t('not_found') if @template.preferences['require_phone_2fa'] == true
+ raise ActionController::RoutingError, I18n.t('not_found') if @template.preferences['require_phone_2fa']
- @submitter = @template.submissions.new(account_id: @template.account_id)
- .submitters.new(account_id: @template.account_id,
- uuid: (filter_undefined_submitters(@template).first ||
- @template.submitters.first)['uuid'])
+ if @template.shared_link?
+ @submitter = @template.submissions.new(account_id: @template.account_id)
+ .submitters.new(account_id: @template.account_id,
+ uuid: (filter_undefined_submitters(@template).first ||
+ @template.submitters.first)['uuid'])
- @form_configs = Submitters::FormConfigs.call(@submitter) unless Docuseal.multitenant?
+ @form_configs = Submitters::FormConfigs.call(@submitter) unless Docuseal.multitenant?
+
+ render :show
+ elsif current_user && current_ability.can?(:read, @template)
+ render :private
+ else
+ raise ActionController::RoutingError, I18n.t('not_found')
+ end
end
def update
diff --git a/app/controllers/templates_share_link_controller.rb b/app/controllers/templates_share_link_controller.rb
new file mode 100644
index 00000000..5b84f6ca
--- /dev/null
+++ b/app/controllers/templates_share_link_controller.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class TemplatesShareLinkController < ApplicationController
+ load_and_authorize_resource :template
+
+ def show; end
+
+ def create
+ authorize!(:update, @template)
+
+ @template.update!(template_params)
+
+ head :ok
+ end
+
+ private
+
+ def template_params
+ params.require(:template).permit(:shared_link)
+ end
+end
diff --git a/app/javascript/application.js b/app/javascript/application.js
index c2e12079..f951b8fe 100644
--- a/app/javascript/application.js
+++ b/app/javascript/application.js
@@ -24,6 +24,7 @@ import SubmitForm from './elements/submit_form'
import PromptPassword from './elements/prompt_password'
import EmailsTextarea from './elements/emails_textarea'
import ToggleOnSubmit from './elements/toggle_on_submit'
+import CheckOnClick from './elements/check_on_click'
import PasswordInput from './elements/password_input'
import SearchInput from './elements/search_input'
import ToggleAttribute from './elements/toggle_attribute'
@@ -103,6 +104,7 @@ safeRegisterElement('set-date-button', SetDateButton)
safeRegisterElement('indeterminate-checkbox', IndeterminateCheckbox)
safeRegisterElement('app-tour', AppTour)
safeRegisterElement('dashboard-dropzone', DashboardDropzone)
+safeRegisterElement('check-on-click', CheckOnClick)
safeRegisterElement('template-builder', class extends HTMLElement {
connectedCallback () {
diff --git a/app/javascript/elements/check_on_click.js b/app/javascript/elements/check_on_click.js
new file mode 100644
index 00000000..8b3b9ab8
--- /dev/null
+++ b/app/javascript/elements/check_on_click.js
@@ -0,0 +1,14 @@
+export default class extends HTMLElement {
+ connectedCallback () {
+ this.addEventListener('click', () => {
+ if (!this.element.checked) {
+ this.element.checked = true
+ this.element.dispatchEvent(new Event('change', { bubbles: true }))
+ }
+ })
+ }
+
+ get element () {
+ return document.getElementById(this.dataset.elementId)
+ }
+}
diff --git a/app/javascript/elements/clipboard_copy.js b/app/javascript/elements/clipboard_copy.js
index 777727d1..4118b9e2 100644
--- a/app/javascript/elements/clipboard_copy.js
+++ b/app/javascript/elements/clipboard_copy.js
@@ -3,8 +3,6 @@ export default class extends HTMLElement {
this.clearChecked()
this.addEventListener('click', (e) => {
- e.stopPropagation()
-
const text = this.dataset.text || this.innerText.trim()
if (navigator.clipboard) {
diff --git a/app/models/template.rb b/app/models/template.rb
index 28441a9a..faa9e696 100644
--- a/app/models/template.rb
+++ b/app/models/template.rb
@@ -10,6 +10,7 @@
# name :string not null
# preferences :text not null
# schema :text not null
+# shared_link :boolean default(FALSE), not null
# slug :string not null
# source :text not null
# submitters :text not null
diff --git a/app/views/start_form/private.html.erb b/app/views/start_form/private.html.erb
new file mode 100644
index 00000000..4e16eba4
--- /dev/null
+++ b/app/views/start_form/private.html.erb
@@ -0,0 +1,28 @@
+<% content_for(:html_title, "#{@template.name} | DocuSeal") %>
+<% content_for(:html_description, t('share_link_is_currently_disabled')) %>
+
+
+
+
+ <%= render 'banner' %>
+
+ <%= t('share_link_is_currently_disabled') %>
+
+
+
+
+
+ <%= svg_icon('writing_sign', class: 'w-10 h-10') %>
+
+
+
<%= @template.name %>
+ <% if @template.archived_at? %>
+
<%= t('form_has_been_deleted_by_html', name: @template.account.name) %>
+ <% end %>
+
+
+
+
+
+
+<%= render 'shared/attribution', link_path: '/start', account: @template.account %>
diff --git a/app/views/templates/_title.html.erb b/app/views/templates/_title.html.erb
index c33a8298..28d2e037 100644
--- a/app/views/templates/_title.html.erb
+++ b/app/views/templates/_title.html.erb
@@ -49,7 +49,12 @@
<% end %>
<% end %>
- <%= render 'shared/clipboard_copy', text: start_form_url(slug: @template.slug), id: 'share_link_clipboard', class: 'absolute md:relative bottom-0 right-0 btn btn-xs md:btn-sm whitespace-nowrap btn-neutral text-white mt-1 px-2', icon_class: 'w-4 h-4 md:w-6 md:h-6 text-white', copy_title: t('link'), copied_title: t('copied'), copy_title_md: t('link'), copied_title_md: t('copied') %>
+ <%= link_to template_share_link_path(template), class: 'absolute md:relative bottom-0 right-0 btn btn-xs md:btn-sm whitespace-nowrap btn-neutral text-white mt-1 px-2', data: { turbo_frame: :modal } do %>
+
+ <%= svg_icon('link', class: 'w-4 h-4 md:w-6 md:h-6 text-white') %>
+ <%= t('link') %>
+
+ <% end %>
<% end %>
diff --git a/app/views/templates_preferences/show.html.erb b/app/views/templates_preferences/show.html.erb
index b59fac43..47b342c7 100644
--- a/app/views/templates_preferences/show.html.erb
+++ b/app/views/templates_preferences/show.html.erb
@@ -380,8 +380,18 @@
<%= t('embedding_url') %>
-
- <%= render 'shared/clipboard_copy', icon: 'copy', text: start_form_url(slug: @template.slug), class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
+ <%= form_for @template, url: template_share_link_path(@template), method: :post, html: { id: 'shared_link_form', autocomplete: 'off', class: 'w-full mt-1' }, data: { close_on_submit: false } do |f| %>
+
+
+
+ <%= render 'shared/clipboard_copy', icon: 'copy', text: start_form_url(slug: @template.slug), class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
+
+
+
+ <%= t('enable_shared_link') %>
+ <%= f.check_box :shared_link, { class: 'toggle', onchange: 'this.form.requestSubmit()' }, 'true', 'false' %>
+
+ <% end %>
<%= render 'templates_code_modal/placeholder' %>
diff --git a/app/views/templates_share_link/show.html.erb b/app/views/templates_share_link/show.html.erb
new file mode 100644
index 00000000..eb2d74e0
--- /dev/null
+++ b/app/views/templates_share_link/show.html.erb
@@ -0,0 +1,16 @@
+<%= render 'shared/turbo_modal_large', title: t('share_link') do %>
+
+ <%= form_for @template, url: template_share_link_path(@template), method: :post, html: { id: 'shared_link_form', autocomplete: 'off', class: 'mt-3' }, data: { close_on_submit: false } do |f| %>
+
+ <%= t('enable_shared_link') %>
+ <%= f.check_box :shared_link, { disabled: !can?(:update, @template), class: 'toggle', onchange: 'this.form.requestSubmit()' }, 'true', 'false' %>
+
+
+
+
+ <%= render 'shared/clipboard_copy', icon: 'copy', text: start_form_url(slug: @template.slug), class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
+
+
+ <% end %>
+
+<% end %>
diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml
index 5dd85c32..378a6f5e 100644
--- a/config/locales/i18n.yml
+++ b/config/locales/i18n.yml
@@ -746,6 +746,9 @@ en: &en
eu_data_residency: EU data residency
please_enter_your_email_address_associated_with_the_completed_submission: Please enter your email address associated with the completed submission.
esignature_disclosure: eSignature Disclosure
+ share_link: Share link
+ enable_shared_link: Enable shared link
+ share_link_is_currently_disabled: Share link is currently disabled
submission_sources:
api: API
bulk: Bulk Send
@@ -1575,6 +1578,9 @@ es: &es
eu_data_residency: Datos alojados UE
please_enter_your_email_address_associated_with_the_completed_submission: Por favor, introduce tu dirección de correo electrónico asociada con el envío completado.
esignature_disclosure: Uso de firma electrónica
+ share_link: Enlace para compartir
+ enable_shared_link: Habilitar enlace compartido
+ share_link_is_currently_disabled: El enlace compartido está deshabilitado actualmente
submission_sources:
api: API
bulk: Envío masivo
@@ -2402,6 +2408,9 @@ it: &it
eu_data_residency: "Dati nell'UE"
please_enter_your_email_address_associated_with_the_completed_submission: "Inserisci il tuo indirizzo email associato all'invio completato."
esignature_disclosure: Uso della firma elettronica
+ share_link: Link di condivisione
+ enable_shared_link: Abilita link condiviso
+ share_link_is_currently_disabled: Il link condiviso è attualmente disabilitato
submission_sources:
api: API
bulk: Invio massivo
@@ -3232,6 +3241,9 @@ fr: &fr
eu_data_residency: "Données dans l'UE"
please_enter_your_email_address_associated_with_the_completed_submission: "Veuillez saisir l'adresse e-mail associée à l'envoi complété."
esignature_disclosure: Divulgation de Signature Électronique
+ share_link: Lien de partage
+ enable_shared_link: Activer le lien de partage
+ share_link_is_currently_disabled: Le lien de partage est actuellement désactivé
submission_sources:
api: API
bulk: Envoi en masse
@@ -4061,6 +4073,9 @@ pt: &pt
eu_data_residency: Dados na UE
please_enter_your_email_address_associated_with_the_completed_submission: Por favor, insira seu e-mail associado ao envio concluído.
esignature_disclosure: Uso de assinatura eletrônica
+ share_link: Link de compartilhamento
+ enable_shared_link: Ativar link compartilhado
+ share_link_is_currently_disabled: O link compartilhado está desativado no momento
submission_sources:
api: API
bulk: Envio em massa
@@ -4891,6 +4906,9 @@ de: &de
eu_data_residency: EU-Datenspeicher
please_enter_your_email_address_associated_with_the_completed_submission: Bitte gib deine E-Mail-Adresse ein, die mit der abgeschlossenen Übermittlung verknüpft ist.
esignature_disclosure: Nutzung der E-Signatur
+ share_link: Freigabelink
+ enable_shared_link: 'Freigabelink aktivieren'
+ share_link_is_currently_disabled: 'Freigabelink ist derzeit deaktiviert'
submission_sources:
api: API
bulk: Massenversand
diff --git a/config/routes.rb b/config/routes.rb
index 331ca82d..3409a78e 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -106,6 +106,7 @@ Rails.application.routes.draw do
resource :form, only: %i[show], controller: 'templates_form_preview'
resource :code_modal, only: %i[show], controller: 'templates_code_modal'
resource :preferences, only: %i[show create], controller: 'templates_preferences'
+ resource :share_link, only: %i[show create], controller: 'templates_share_link'
resources :recipients, only: %i[create], controller: 'templates_recipients'
resources :submissions_export, only: %i[index new]
end
diff --git a/db/migrate/20250523121121_add_shared_link_to_templates.rb b/db/migrate/20250523121121_add_shared_link_to_templates.rb
new file mode 100644
index 00000000..6fe962eb
--- /dev/null
+++ b/db/migrate/20250523121121_add_shared_link_to_templates.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class AddSharedLinkToTemplates < ActiveRecord::Migration[8.0]
+ disable_ddl_transaction
+
+ class MigrationTemplate < ActiveRecord::Base
+ self.table_name = 'templates'
+ end
+
+ def up
+ add_column :templates, :shared_link, :boolean, if_not_exists: true
+
+ MigrationTemplate.where(shared_link: nil).in_batches.update_all(shared_link: true)
+
+ change_column_default :templates, :shared_link, from: nil, to: false
+ change_column_null :templates, :shared_link, false
+ end
+
+ def down
+ remove_column :templates, :shared_link
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 28f1a524..b024ff66 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -361,6 +361,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_30_080846) do
t.bigint "folder_id", null: false
t.string "external_id"
t.text "preferences", null: false
+ t.boolean "shared_link", default: false, null: false
t.index ["account_id"], name: "index_templates_on_account_id"
t.index ["author_id"], name: "index_templates_on_author_id"
t.index ["external_id"], name: "index_templates_on_external_id"
diff --git a/lib/templates/clone.rb b/lib/templates/clone.rb
index 0fe3b91b..e4b5fc60 100644
--- a/lib/templates/clone.rb
+++ b/lib/templates/clone.rb
@@ -9,6 +9,7 @@ module Templates
template = original_template.account.templates.new
template.external_id = external_id
+ template.shared_link = original_template.shared_link
template.author = author
template.name = name.presence || "#{original_template.name} (#{I18n.t('clone')})"
diff --git a/lib/templates/serialize_for_api.rb b/lib/templates/serialize_for_api.rb
index f5f03ebe..b60f9023 100644
--- a/lib/templates/serialize_for_api.rb
+++ b/lib/templates/serialize_for_api.rb
@@ -6,7 +6,7 @@ module Templates
only: %w[
id archived_at fields name preferences schema
slug source submitters created_at updated_at
- author_id external_id folder_id
+ author_id external_id folder_id shared_link
],
methods: %i[application_key folder_name],
include: { author: { only: %i[id email first_name last_name] } }
diff --git a/spec/fixtures/fieldtags.docx b/spec/fixtures/fieldtags.docx
new file mode 100644
index 00000000..50655a04
Binary files /dev/null and b/spec/fixtures/fieldtags.docx differ
diff --git a/spec/requests/templates_spec.rb b/spec/requests/templates_spec.rb
index fa2a362a..7763d8cd 100644
--- a/spec/requests/templates_spec.rb
+++ b/spec/requests/templates_spec.rb
@@ -83,13 +83,15 @@ describe 'Templates API' do
end
describe 'PUT /api/templates' do
- it 'update a template' do
- template = create(:template, account:,
- author:,
- folder:,
- external_id: SecureRandom.base58(10),
- preferences: template_preferences)
+ let(:template) do
+ create(:template, account:,
+ author:,
+ folder:,
+ external_id: SecureRandom.base58(10),
+ preferences: template_preferences)
+ end
+ it 'updates a template' do
put "/api/templates/#{template.id}", headers: { 'x-auth-token': author.access_token.token }, params: {
name: 'Updated Template Name',
external_id: '123456'
@@ -106,6 +108,24 @@ describe 'Templates API' do
updated_at: template.updated_at
}.to_json))
end
+
+ it "enables the template's shared link" do
+ expect do
+ put "/api/templates/#{template.id}", headers: { 'x-auth-token': author.access_token.token }, params: {
+ shared_link: true
+ }.to_json
+ end.to change { template.reload.shared_link }.from(false).to(true)
+ end
+
+ it "disables the template's shared link" do
+ template.update(shared_link: true)
+
+ expect do
+ put "/api/templates/#{template.id}", headers: { 'x-auth-token': author.access_token.token }, params: {
+ shared_link: false
+ }.to_json
+ end.to change { template.reload.shared_link }.from(true).to(false)
+ end
end
describe 'DELETE /api/templates/:id' do
@@ -206,6 +226,7 @@ describe 'Templates API' do
name: 'sample-document'
}
],
+ shared_link: template.shared_link,
author_id: author.id,
archived_at: nil,
created_at: template.created_at,
diff --git a/spec/system/signing_form_spec.rb b/spec/system/signing_form_spec.rb
index 8bfa3e5e..83c1b30b 100644
--- a/spec/system/signing_form_spec.rb
+++ b/spec/system/signing_form_spec.rb
@@ -5,7 +5,9 @@ RSpec.describe 'Signing Form' do
let(:author) { create(:user, account:) }
context 'when the template form link is opened' do
- let(:template) { create(:template, account:, author:, except_field_types: %w[phone payment stamp]) }
+ let(:template) do
+ create(:template, shared_link: true, account:, author:, except_field_types: %w[phone payment stamp])
+ end
before do
visit start_form_path(slug: template.slug)
@@ -811,7 +813,9 @@ RSpec.describe 'Signing Form' do
end
context 'when the template requires multiple submitters' do
- let(:template) { create(:template, submitter_count: 2, account:, author:, only_field_types: %w[text]) }
+ let(:template) do
+ create(:template, shared_link: true, submitter_count: 2, account:, author:, only_field_types: %w[text])
+ end
context 'when default signer details are not defined' do
it 'shows an explanation error message if a logged-in user associated with the template account opens the link' do
diff --git a/spec/system/template_share_link_spec.rb b/spec/system/template_share_link_spec.rb
new file mode 100644
index 00000000..8930812b
--- /dev/null
+++ b/spec/system/template_share_link_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+RSpec.describe 'Template Share Link' do
+ let!(:account) { create(:account) }
+ let!(:author) { create(:user, account:) }
+ let!(:template) { create(:template, account:, author:) }
+
+ before do
+ sign_in(author)
+ end
+
+ context 'when the template is not shareable' do
+ before do
+ visit template_path(template)
+ end
+
+ it 'makes the template shareable' do
+ click_on 'Link'
+
+ expect do
+ within '#modal' do
+ check 'template_shared_link'
+ end
+ end.to change { template.reload.shared_link }.from(false).to(true)
+ end
+
+ it 'makes the template shareable when copying the shareable link' do
+ click_on 'Link'
+
+ expect do
+ within '#modal' do
+ find('clipboard-copy').click
+ end
+ end.to change { template.reload.shared_link }.from(false).to(true)
+ end
+
+ it 'copies the shareable link without changing its status' do
+ template.update(shared_link: true)
+
+ click_on 'Link'
+
+ expect do
+ within '#modal' do
+ find('clipboard-copy').click
+ end
+ end.not_to(change { template.reload.shared_link })
+ end
+ end
+
+ context 'when the template is already shareable' do
+ before do
+ template.update(shared_link: true)
+ visit template_path(template)
+ end
+
+ it 'makes the template unshareable' do
+ click_on 'Link'
+
+ expect do
+ within '#modal' do
+ uncheck 'template_shared_link'
+ end
+ end.to change { template.reload.shared_link }.from(true).to(false)
+ end
+ end
+end