From 3b4e1423635fd9f33ae760312e438cc1e484662e Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 31 Jul 2026 20:41:05 +0200 Subject: Show the gate before it refuses, and check the destination nodes#show renders publish and trash as disabled_action spans with a hint naming what an editor can still do, matching how locked_by_other already reads. Editing and reverting stay live: drafting is free everywhere. --- app/models/node.rb | 23 +++++++++++++++------- app/models/user.rb | 6 ++++++ app/views/nodes/show.html.erb | 26 +++++++++++++++++++------ config/locales/de.yml | 1 + config/locales/en.yml | 1 + public/stylesheets/admin.css | 9 ++++++++- test/models/node_test.rb | 45 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 97 insertions(+), 14 deletions(-) diff --git a/app/models/node.rb b/app/models/node.rb index ac3a6160..188b6c17 100644 --- a/app/models/node.rb +++ b/app/models/node.rb @@ -236,7 +236,7 @@ class Node < ApplicationRecord # Return nil if nothing to publish and no staged changes return nil unless self.draft || staged_slug || staged_parent_id - guard_live_change!(current_user) + guard_live_change!(current_user, :target_path => prospective_unique_name) if in_trash? || trash_node? errors.add(:base, :publish_in_trash) @@ -574,10 +574,8 @@ class Node < ApplicationRecord false end - def restricted? - return true if root? - - name = unique_name.to_s + def self.restricted_path? name + name = name.to_s return false if name.empty? CccConventions::RESTRICTED_SUBTREES.any? do |prefix| @@ -585,6 +583,17 @@ class Node < ApplicationRecord end end + def restricted? + root? || self.class.restricted_path?(unique_name) + end + + def prospective_unique_name + target_parent = staged_parent_id ? Node.find_by(:id => staged_parent_id) : parent + return nil unless target_parent + + [target_parent.unique_name.presence, staged_slug.presence || slug].compact.join("/") + end + # Returns immutable node id for all new nodes so that the atom feed entry ids # stay the same eventhough the slug or positions changes. # Can be removed after a year or so ;) @@ -697,9 +706,9 @@ class Node < ApplicationRecord private - def guard_live_change! user + def guard_live_change! user, target_path: nil return if user.nil? - return if user.may_change_live?(self) + return if user.may_change_live?(self) && user.may_change_live_at?(target_path) errors.add(:base, :not_permitted) raise ActiveRecord::RecordInvalid.new(self) diff --git a/app/models/user.rb b/app/models/user.rb index e8c3b9bb..bf0f40ee 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -110,6 +110,12 @@ class User < ApplicationRecord redaktion? end + def may_change_live_at?(path) + return true if path.nil? + return true unless Node.restricted_path?(path) + redaktion? + end + def deactivate!(actor:) return false if alumni? transaction do diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 8b12e2eb..a6167c9e 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -1,4 +1,5 @@ <% locked_by_other = @node.locked? && @node.lock_owner != current_user %> +<% may_change_live = current_user.may_change_live?(@node) %>

<%= title_for_node(@node) %> (<%= I18n.default_locale.to_s.upcase %>)

@@ -48,8 +49,12 @@ <% unless locked_by_other %> <% if @node.draft && !@node.autosave && !@node.in_trash? && !@node.trash_node? %>
- <%= button_to t(".publish"), publish_node_path(@node), method: :put, - form: { data: { confirm: t(".confirm_publish") }, class: 'button_to state_changing' } %> + <% if may_change_live %> + <%= button_to t(".publish"), publish_node_path(@node), method: :put, + form: { data: { confirm: t(".confirm_publish") }, class: 'button_to state_changing' } %> + <% else %> + <%= t(".publish") %> + <% end %>
<% end %> <% if @node.autosave || (@node.draft && @node.head) %> @@ -64,10 +69,17 @@ <% end %> <% unless @node.trash_node? || @node.in_trash? || @node.root? %>
- <%= button_to trash_node_path(@node), method: :put, - form: { data: { confirm: t(".confirm_trash") }, class: 'button_to destructive' } do %> - <%= icon("trash", library: "tabler", "aria-hidden": true) %> - <%= t(".move_to_trash") %> + <% if may_change_live %> + <%= button_to trash_node_path(@node), method: :put, + form: { data: { confirm: t(".confirm_trash") }, class: 'button_to destructive' } do %> + <%= icon("trash", library: "tabler", "aria-hidden": true) %> + <%= t(".move_to_trash") %> + <% end %> + <% else %> + + <%= icon("trash", library: "tabler", "aria-hidden": true) %> + <%= t(".move_to_trash") %> + <% end %>
<% end %> @@ -76,6 +88,8 @@ <% if locked_by_other %> <%= t(".locked_hint") %> + <% elsif !may_change_live %> + <%= t(".restricted_hint") %> <% end %>
diff --git a/config/locales/de.yml b/config/locales/de.yml index a7ad0f26..746aa803 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -493,6 +493,7 @@ de: add_child: "Kind vom Typ %{kind} anlegen" abstract_locale: "Abstract (%{lang})" body_locale: "Text (%{lang})" + restricted_hint: "Geschützter Bereich — Veröffentlichen und Papierkorb sind der Redaktion vorbehalten. Entwürfe kannst du frei bearbeiten." drafts: title: "Nodes mit Entwürfen, Autosaves oder Sperren" mine: diff --git a/config/locales/en.yml b/config/locales/en.yml index 7e830325..22429c9e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -436,6 +436,7 @@ en: add_child: "Add child type %{kind}" abstract_locale: "Abstract (%{lang})" body_locale: "Body (%{lang})" + restricted_hint: "Protected section — publishing and trashing are reserved for Redaktion. You can edit drafts freely." drafts: title: "Nodes with drafts, autosaves or locks" mine: diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 9991fad0..55fdf15d 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -880,7 +880,9 @@ div.layout_row_content { } .info_group .disabled_action { - display: inline-block; + display: inline-flex; + align-items: center; + gap: 0.35rem; border: 1px solid var(--border); border-radius: 2px; padding: 4px 12px; @@ -889,6 +891,11 @@ div.layout_row_content { cursor: not-allowed; } +.info_group .disabled_action svg { + width: 1.25rem; + height: 1.25rem; +} + .info_group_items { display: flex; flex-wrap: wrap; diff --git a/test/models/node_test.rb b/test/models/node_test.rb index f57f83bf..aa714427 100644 --- a/test/models/node_test.rb +++ b/test/models/node_test.rb @@ -1004,4 +1004,49 @@ class NodeTest < ActiveSupport::TestCase node.publish_draft! assert_not_nil node.reload.head end + + test "publishing a staged move into a restricted subtree is refused" do + editor = User.create!(:login => "move_editor", :email => "me@example.com", + :password => "secret", :password_confirmation => "secret") + updates = Node.root.children.create!(:slug => "updates") + year = updates.children.create!(:slug => "2026") + node = Node.root.children.create!(:slug => "outside-post") + node.reload.draft.update!(:title => "Entwurf") + node.update!(:staged_parent_id => year.id) + + assert_raises(ActiveRecord::RecordInvalid) { node.publish_draft!(editor) } + assert_nil node.reload.head + assert_equal Node.root.id, node.parent_id + end + + test "a staged move within unrestricted space needs no role" do + editor = User.create!(:login => "move_free", :email => "mf@example.com", + :password => "secret", :password_confirmation => "secret") + club = Node.root.children.create!(:slug => "club") + node = Node.root.children.create!(:slug => "movable-post") + node.reload.draft.update!(:title => "Entwurf") + node.update!(:staged_parent_id => club.id) + + node.publish_draft!(editor) + assert_equal club.id, node.reload.parent_id + end + + test "publishing a staged rename onto a restricted path is refused" do + editor = User.create!(:login => "rename_editor", :email => "re@example.com", + :password => "secret", :password_confirmation => "secret") + node = Node.root.children.create!(:slug => "harmless") + node.reload.draft.update!(:title => "Entwurf") + node.update!(:staged_slug => "updates") + + assert_raises(ActiveRecord::RecordInvalid) { node.publish_draft!(editor) } + assert_nil node.reload.head + end + + test "a restricted subtree root cannot be claimed once it exists" do + Node.root.children.create!(:slug => "updates") + + squatter = Node.root.children.build(:slug => "updates") + assert_not squatter.valid? + assert squatter.errors[:slug].any? + end end -- cgit v1.3