feat(templates): add visibility column (private default, creator-only)

pull/639/head
Bob Develop 2 weeks ago
parent 1b6aa56757
commit 2afbabed16

@ -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,

@ -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

@ -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'

@ -20,6 +20,20 @@
<% end %>
<div id="general" class="px-5 mb-4">
<%= 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| %>
<div class="form-control">
<%= 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' } %>
<label class="label">
<span class="label-text-alt">
<%= t('visibility_help') %>
</span>
</label>
</div>
<% end %>
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-2' }, data: { close_on_submit: false } do |f| %>
<toggle-on-submit data-element-id="bcc_saved_alert"></toggle-on-submit>
<%= f.fields_for :preferences, Struct.new(:bcc_completed).new(@template.preferences['bcc_completed']) do |ff| %>

@ -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)

@ -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

@ -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"

@ -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

@ -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();
});
});

@ -6,6 +6,7 @@ FactoryBot.define do
author factory: %i[user]
name { Faker::Book.title }
visibility { Template::VISIBILITY_PRIVATE }
transient do
submitter_count { 1 }

Loading…
Cancel
Save