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 }