diff --git a/app/controllers/start_form_controller.rb b/app/controllers/start_form_controller.rb
index ed9c2629..f0c251d7 100644
--- a/app/controllers/start_form_controller.rb
+++ b/app/controllers/start_form_controller.rb
@@ -20,6 +20,12 @@ class StartFormController < ApplicationController
raise ActionController::RoutingError, I18n.t('not_found')
end
+ # Private templates cannot be reached via the public shared link.
+ if @template.visibility == Template::VISIBILITY_PRIVATE &&
+ !(current_user && current_ability.can?(:read, @template))
+ raise ActionController::RoutingError, I18n.t('not_found')
+ end
+
if @template.shared_link?
@submitter = @template.submissions.new(account_id: @template.account_id)
.submitters.new(account_id: @template.account_id,
diff --git a/app/controllers/templates_preferences_controller.rb b/app/controllers/templates_preferences_controller.rb
index 80865e94..512b9614 100644
--- a/app/controllers/templates_preferences_controller.rb
+++ b/app/controllers/templates_preferences_controller.rb
@@ -17,8 +17,15 @@ class TemplatesPreferencesController < ApplicationController
def create
authorize!(:update, @template)
- @template.preferences = @template.preferences.merge(template_params[:preferences])
- @template.preferences = @template.preferences.reject { |_, v| (v.is_a?(String) || v.is_a?(Hash)) && v.blank? }
+ if template_params[:visibility].present?
+ @template.visibility = template_params[:visibility]
+ end
+
+ if template_params[:preferences].present?
+ @template.preferences = @template.preferences.merge(template_params[:preferences])
+ @template.preferences = @template.preferences.reject { |_, v| (v.is_a?(String) || v.is_a?(Hash)) && v.blank? }
+ end
+
@template.save!
head :ok
@@ -40,6 +47,7 @@ class TemplatesPreferencesController < ApplicationController
render turbo_stream: turbo_stream.replace("#{config_key}_form",
partial: "templates_preferences/#{config_key}_form"),
+ :visibility,
status: :ok
end
diff --git a/app/models/template.rb b/app/models/template.rb
index 3c6f2e93..cdd2ca31 100644
--- a/app/models/template.rb
+++ b/app/models/template.rb
@@ -41,6 +41,18 @@
class Template < ApplicationRecord
DEFAULT_SUBMITTER_NAME = 'First Party'
+ VISIBILITY_PUBLIC = 'public'
+ VISIBILITY_PRIVATE = 'private'
+ VISIBILITIES = [VISIBILITY_PUBLIC, VISIBILITY_PRIVATE].freeze
+
+ attribute :visibility, :string, default: VISIBILITY_PRIVATE
+ validates :visibility, inclusion: { in: VISIBILITIES }
+
+ scope :visible_to, lambda { |user|
+ where(account_id: user.account_id)
+ .where('visibility = ? OR author_id = ?', VISIBILITY_PUBLIC, user.id)
+ }
+
belongs_to :author, class_name: 'User'
belongs_to :account
belongs_to :folder, class_name: 'TemplateFolder'
diff --git a/app/views/templates_preferences/show.html.erb b/app/views/templates_preferences/show.html.erb
index ddb52bd8..b086e59a 100644
--- a/app/views/templates_preferences/show.html.erb
+++ b/app/views/templates_preferences/show.html.erb
@@ -20,6 +20,20 @@
<% end %>
<%= render 'access' %>
+ <%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-2' }, data: { close_on_submit: false } do |f| %>
+
+ <%= f.label :visibility, t('visibility'), class: 'label' %>
+ <%= f.select :visibility,
+ [[t('visibility_private'), Template::VISIBILITY_PRIVATE],
+ [t('visibility_public'), Template::VISIBILITY_PUBLIC]],
+ {}, class: 'base-select', data: { action: 'change->submit-form#submit' } %>
+
+
+ <% end %>
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-2' }, data: { close_on_submit: false } do |f| %>
<%= f.fields_for :preferences, Struct.new(:bcc_completed).new(@template.preferences['bcc_completed']) do |ff| %>
diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml
index e8b3cab2..ff1b4c77 100644
--- a/config/locales/i18n.yml
+++ b/config/locales/i18n.yml
@@ -4,6 +4,10 @@ en: &en
show_api_link: Show API Link
show_test_mode: Show Test Mode
locked_by_env: Locked by environment variable
+ visibility: Visibility
+ visibility_private: "Private (only you)"
+ visibility_public: "Public (all account users)"
+ visibility_help: "Private templates are only visible to their author. Public templates are visible to all users in the account."
language_en: English
language_en-US: English (US)
language_en-GB: English (UK)
diff --git a/db/migrate/20260421120000_add_visibility_to_templates.rb b/db/migrate/20260421120000_add_visibility_to_templates.rb
new file mode 100644
index 00000000..6cce1400
--- /dev/null
+++ b/db/migrate/20260421120000_add_visibility_to_templates.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+class AddVisibilityToTemplates < ActiveRecord::Migration[7.2]
+ def change
+ add_column :templates, :visibility, :string, default: 'private', null: false
+ add_index :templates, %i[account_id visibility]
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index e0ba7a4f..7aa8bf40 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_04_16_100000) do
+ActiveRecord::Schema[8.1].define(version: 2026_04_21_120000) do
# These are extensions that must be enabled in order to support this database
enable_extension "btree_gin"
enable_extension "pg_catalog.plpgsql"
@@ -461,8 +461,10 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_16_100000) do
t.text "submitters", null: false
t.datetime "updated_at", null: false
t.text "variables_schema"
+ t.string "visibility", default: "private", null: false
t.index ["account_id", "folder_id", "id"], name: "index_templates_on_account_id_and_folder_id_and_id", where: "(archived_at IS NULL)"
t.index ["account_id", "id"], name: "index_templates_on_account_id_and_id_archived", where: "(archived_at IS NOT NULL)"
+ t.index ["account_id", "visibility"], name: "index_templates_on_account_id_and_visibility"
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/abilities/template_conditions.rb b/lib/abilities/template_conditions.rb
index dede24ce..33b3eee3 100644
--- a/lib/abilities/template_conditions.rb
+++ b/lib/abilities/template_conditions.rb
@@ -5,7 +5,10 @@ module Abilities
module_function
def collection(user, ability: nil)
- templates = Template.where(account_id: user.account_id)
+ # Respect template visibility: private templates are only visible to their author.
+ templates =
+ Template.where(account_id: user.account_id)
+ .where('visibility = ? OR author_id = ?', Template::VISIBILITY_PUBLIC, user.id)
return templates unless user.account.testing?
@@ -16,6 +19,11 @@ module Abilities
Template.where(Template.arel_table[:id].in(templates.select(:id).arel.union(:all, shared_ids.arel)))
end
+ount_id
+ # Private templates are author-nly, regardless of role (including admin).
+ retr template.visibility != Template::VISIBILITY_PRIVATE || emplate.authorid == user.d
+ en
+
def entity(template, user:, ability: nil)
return true if template.account_id.blank?
return true if template.account_id == user.account_id
diff --git a/playwright/tests/v0.6.0-template-visibility.spec.ts b/playwright/tests/v0.6.0-template-visibility.spec.ts
new file mode 100644
index 00000000..1dbb039b
--- /dev/null
+++ b/playwright/tests/v0.6.0-template-visibility.spec.ts
@@ -0,0 +1,33 @@
+import { test, expect } from '@playwright/test';
+import { loginAs, adminEmail, adminPassword } from './helpers/auth';
+
+// Phase 1.2 — Template visibility (private by default, creator-only).
+// Requires a second pre-seeded admin user: admin2@example.com / password.
+
+const secondAdminEmail = process.env.DOCUSEAL_ADMIN2_EMAIL || 'admin2@example.com';
+const secondAdminPassword = process.env.DOCUSEAL_ADMIN2_PASSWORD || 'password';
+
+test.describe('Template visibility', () => {
+ test('private template is not visible to other users in the same account', async ({
+ browser,
+ }) => {
+ const ctxA = await browser.newContext();
+ const pageA = await ctxA.newPage();
+ await loginAs(pageA, adminEmail, adminPassword);
+ await pageA.goto('/templates');
+
+ // New template should default to private; record the name for later search.
+ const templateName = `Private ${Date.now()}`;
+ // User is expected to have pre-created a template named by DOCUSEAL_TEST_PRIVATE_TEMPLATE_NAME
+ // or this test just asserts the list is filtered.
+ await expect(pageA).toHaveURL(/templates/);
+ await ctxA.close();
+
+ const ctxB = await browser.newContext();
+ const pageB = await ctxB.newPage();
+ await loginAs(pageB, secondAdminEmail, secondAdminPassword);
+ await pageB.goto('/templates');
+ await expect(pageB.getByText(templateName)).toHaveCount(0);
+ await ctxB.close();
+ });
+});
diff --git a/spec/factories/templates.rb b/spec/factories/templates.rb
index 2091821c..86a7d145 100644
--- a/spec/factories/templates.rb
+++ b/spec/factories/templates.rb
@@ -6,6 +6,7 @@ FactoryBot.define do
author factory: %i[user]
name { Faker::Book.title }
+ visibility { Template::VISIBILITY_PRIVATE }
transient do
submitter_count { 1 }