From 529f81b28ed77c62acaa63fad957e751798f2440 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 31 Jul 2026 22:36:17 +0200 Subject: Warn before the gate refuses, and check restore destinations nodes#new marks the kinds that place under /updates, derived from each kind's own path_prefix. The three parent pickers mark candidates the current user cannot publish into; menu_search returns needs_redaktion per result, computed per user rather than as a bare restricted flag, and the menu-item picker opts out. restore_from_trash! checks its destination: the restore is the move, applied rather than staged, so no later publish would have caught it. The node returns as a draft either way. --- app/controllers/admin_controller.rb | 4 +++- app/models/node.rb | 3 +++ app/views/layouts/admin.html.erb | 2 +- app/views/nodes/new.html.erb | 6 +++++- config/locales/de.yml | 2 ++ config/locales/en.yml | 2 ++ public/javascripts/admin_search.js | 8 ++++++++ public/stylesheets/admin.css | 11 +++++++++++ test/models/node_test.rb | 23 +++++++++++++++++++++++ 9 files changed, 58 insertions(+), 3 deletions(-) diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index 3b8b3582..c23cec04 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -67,7 +67,9 @@ class AdminController < ApplicationController format.js do render( :json => @results.map do |node| - {:node_id => node.id, :title => node.title, :unique_name => node.unique_name, :node_path => node_path(node)} + { :node_id => node.id, :title => node.title, + :unique_name => node.unique_name, :node_path => node_path(node), + :needs_redaktion => !current_user.may_change_live?(node) } end ) diff --git a/app/models/node.rb b/app/models/node.rb index 188b6c17..d6fe32ff 100644 --- a/app/models/node.rb +++ b/app/models/node.rb @@ -375,6 +375,9 @@ class Node < ApplicationRecord raise ActiveRecord::RecordInvalid.new(self) end + guard_live_change!(current_user, + :target_path => [new_parent.unique_name.presence, slug].compact.join("/")) + ActiveRecord::Base.transaction do path_before = unique_name move_to_child_of(new_parent) diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index 97117710..43f09b96 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -18,7 +18,7 @@ <%= javascript_tag nonce: true do %> var ADMIN_SEARCH_URL = "<%= admin_search_path %>"; var ADMIN_MENU_SEARCH_URL = "<%= admin_menu_search_path %>"; - var ADMIN_STRINGS = <%= raw({ :lock_lost_prefix => t(".lock_lost_prefix"), :lock_lost_link => t(".lock_lost_link"), :insert_image => t(".insert_image"), :insert_image_tooltip => t(".insert_image_tooltip"), :copied => t(".copied") }.to_json) %>; + var ADMIN_STRINGS = <%= raw({ :lock_lost_prefix => t(".lock_lost_prefix"), :lock_lost_link => t(".lock_lost_link"), :insert_image => t(".insert_image"), :insert_image_tooltip => t(".insert_image_tooltip"), :copied => t(".copied"), :needs_redaktion => t(".needs_redaktion") }.to_json) %>; var PARAMETERIZE_PREVIEW_URL = "<%= parameterize_preview_nodes_path %>"; var DASHBOARD_SEARCH_URL = "<%= admin_dashboard_search_path %>"; <% end %> diff --git a/app/views/nodes/new.html.erb b/app/views/nodes/new.html.erb index b56bbeed..883909a6 100644 --- a/app/views/nodes/new.html.erb +++ b/app/views/nodes/new.html.erb @@ -12,13 +12,17 @@
<%= t(".type") %>
<% CccConventions::NODE_KINDS.each do |kind, config| %> + <% prefix = resolve_kind_text(config[:path_prefix]) %>

<%= radio_button_tag :kind, kind, kind == @selected_kind, - data: { path_prefix: resolve_kind_text(config[:path_prefix]) } %> + data: { path_prefix: prefix } %> <%= resolve_kind_text(config[:label]) %> <% if config[:hint] %> <%= resolve_kind_text(config[:hint]) %> <% end %> + <% unless current_user.may_change_live_at?(prefix) %> + <%= t(".restricted_kind") %> + <% end %>

<% end %>
diff --git a/config/locales/de.yml b/config/locales/de.yml index 746aa803..74f6e8ae 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -382,6 +382,7 @@ de: attachment: "Anhang" attach_hint: "Dieses Asset wird der neuen Seite automatisch angehängt." as_headline: "als Aufmacher der Seite" + restricted_kind: "Veröffentlichen hier ist der Redaktion vorbehalten. Anlegen und schreiben kannst du aber." edit: save_draft: "Entwurf speichern" save_unlock_exit: "Speichern + Entsperren + Verlassen" @@ -743,6 +744,7 @@ de: insert_image: "Bild einfügen" insert_image_tooltip: "Eines der angehängten Bilder dieser Seite einfügen" copied: "Kopiert!" + needs_redaktion: "Redaktion nötig" nav_controls: dashboard: "Dashboard" search: "Suche" diff --git a/config/locales/en.yml b/config/locales/en.yml index 22429c9e..c23f2c33 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -325,6 +325,7 @@ en: attachment: "attachment" attach_hint: "This asset will automatically be attached to the new page." as_headline: "as the page's headline" + restricted_kind: "Publishing here is reserved for Redaktion. You can create and write freely, though." edit: save_draft: "Save Draft" save_unlock_exit: "Save + Unlock + Exit" @@ -686,6 +687,7 @@ en: insert_image: "Insert image" insert_image_tooltip: "Insert one of this page's attached images" copied: "Copied!" + needs_redaktion: "Redaktion required" nav_controls: dashboard: "Dashboard" search: "Search" diff --git a/public/javascripts/admin_search.js b/public/javascripts/admin_search.js index 792849f7..37b696e4 100644 --- a/public/javascripts/admin_search.js +++ b/public/javascripts/admin_search.js @@ -46,6 +46,7 @@ function initSearchPicker(options) { var isActive = options.isActive; var resultsHeaderHtml = options.resultsHeaderHtml; var renderResults = options.renderResults; + var showRestricted = options.showRestricted; var loadOnFocus = options.loadOnFocus; // optional, fires an initial search on focus with no term typed yet var requestId = 0; var timeout; @@ -75,6 +76,10 @@ function initSearchPicker(options) { var anchor = $("").attr("href", onSelect ? "#" : node.node_path); anchor.append(document.createTextNode(node.title || "")); anchor.append($("", { "class": "result_path" }).text(node.unique_name || "")); + if (showRestricted && node.needs_redaktion) { + anchor.append($("", { "class": "restricted_result" }) + .text(ADMIN_STRINGS.needs_redaktion)); + } var link = $("

").append(anchor); if (onSelect) { @@ -192,6 +197,7 @@ parent_search = { initSearchPicker({ inputSelector: "#parent_search_term", resultsSelector: "#parent_search_results", + showRestricted: true, onSelect: function(node) { $("#parent_search_term").val(node.title); $("#parent_id").val(node.node_id).attr("data-unique-name", node.unique_name); @@ -248,6 +254,7 @@ move_to_search = { initSearchPicker({ inputSelector: "#move_to_search_term", resultsSelector: "#move_to_search_results", + showRestricted: true, onSelect: function(node) { $("#move_to_search_term").val(node.title); $("#node_staged_parent_id").val(node.node_id); @@ -261,6 +268,7 @@ restore_search = { initSearchPicker({ inputSelector: "#restore_search_term", resultsSelector: "#restore_search_results", + showRestricted: true, onSelect: function(node) { $("#restore_search_term").val(node.title); $("#parent_id").val(node.node_id); diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 55fdf15d..7f286705 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -889,6 +889,7 @@ div.layout_row_content { font-weight: bold; color: var(--text-muted); cursor: not-allowed; + line-height: 1.25rem; } .info_group .disabled_action svg { @@ -973,6 +974,10 @@ form.button_to button[type="submit"] { padding-bottom: 4px; } +.restricted_kind_hint { + color: var(--accent); +} + .action_button + .field_hint { display: inline; margin-left: 0.5rem; @@ -1459,6 +1464,12 @@ input#menu_item_title { padding-bottom: 4px; } +.search_results .restricted_result { + display: block; + font-size: 0.85rem; + color: var(--accent); +} + .search_results p.search_more { margin: 0; padding: 6px 4px; diff --git a/test/models/node_test.rb b/test/models/node_test.rb index aa714427..a34094a0 100644 --- a/test/models/node_test.rb +++ b/test/models/node_test.rb @@ -1049,4 +1049,27 @@ class NodeTest < ActiveSupport::TestCase assert_not squatter.valid? assert squatter.errors[:slug].any? end + + test "restoring into a restricted subtree is refused" do + editor = User.create!(:login => "restore_editor", :email => "rs@example.com", + :password => "secret", :password_confirmation => "secret") + updates = Node.root.children.create!(:slug => "updates") + node = Node.root.children.create!(:slug => "restorable") + node.reload.trash! + + assert_raises(ActiveRecord::RecordInvalid) { node.restore_from_trash!(updates, editor) } + assert node.reload.in_trash? + end + + test "restoring into unrestricted space needs no role" do + editor = User.create!(:login => "restore_free", :email => "rf@example.com", + :password => "secret", :password_confirmation => "secret") + club = Node.root.children.create!(:slug => "club") + node = Node.root.children.create!(:slug => "restorable_free") + node.reload.trash! + + node.restore_from_trash!(club, editor) + assert_not node.reload.in_trash? + assert_equal club.id, node.parent_id + end end -- cgit v1.3