Merge branch 'release/17.3' into dev

This commit is contained in:
OpenProject Actions CI
2026-04-17 04:32:30 +00:00
21 changed files with 368 additions and 34 deletions
-1
View File
@@ -37,7 +37,6 @@ class News::BaseContract < ModelContract
News
end
attribute :project
attribute :title
attribute :summary
attribute :description
+1
View File
@@ -29,4 +29,5 @@
#++
class News::CreateContract < News::BaseContract
attribute :project
end
@@ -72,6 +72,8 @@ module WorkPackages
validate :user_allowed_to_edit
validate :user_allowed_to_move_from_source_project
validate :can_move_to_milestone
validate :user_allowed_to_change_parent
@@ -90,6 +92,21 @@ module WorkPackages
end
end
# When moving a work package to a different project, require
# :move_work_packages in the source project (the project the work
# package currently belongs to). The per-attribute permission check
# (reduce_by_writable_permissions) evaluates :move_work_packages
# against the target project; this validation covers the source side.
def user_allowed_to_move_from_source_project
return unless model.project_id_changed?
with_unchanged_project_id do
unless user.allowed_in_project?(:move_work_packages, model.project)
errors.add :project_id, :error_readonly
end
end
end
def user_allowed_to_access
unless ::WorkPackage.visible(@user).exists?(model.id)
errors.add :base, :error_not_found
+2 -1
View File
@@ -285,7 +285,8 @@ module ApplicationHelper
def body_data_attributes(local_assigns)
{
controller: "application auto-theme-switcher hover-card-trigger beforeunload external-links highlight-target-element",
controller: ["application auto-theme-switcher hover-card-trigger beforeunload external-links highlight-target-element",
stimulus_body_controller].compact.join(" "),
relative_url_root: root_path,
overflowing_identifier: ".__overflowing_body",
external_links_enabled_value: Setting.capture_external_links?,
+8
View File
@@ -39,5 +39,13 @@ module StimulusHelper
@stimulus_content_data || {}
end
def body_controller(name)
@stimulus_body_controller = name
end
def stimulus_body_controller
@stimulus_body_controller
end
# rubocop:enable Rails/HelperInstanceVariable
end
+1 -1
View File
@@ -59,7 +59,7 @@ In OpenProject, artificial intelligence (AI) refers to the integration of AI sys
OpenProject approaches AI with a clear focus on control, transparency, and data protection. AI is designed to support users in their work, not to replace human decision-making. Users remain in full control at all times and can decide whether and how AI features are used.
With features such as the [MCP Server](https://www.openproject.org/blog/openproject-17-2-0-release/), OpenProject enables secure connections between project data and external AI systems. This allows organizations to benefit from AI while maintaining full control over how their data is accessed and used.
With features such as the [MCP Server](../system-admin-guide/integrations/mcp-server/), OpenProject enables secure connections between project data and external AI systems. This allows organizations to benefit from AI while maintaining full control over how their data is accessed and used.
### Attribute help texts
+6 -4
View File
@@ -7,10 +7,12 @@ keywords: configure backlogs, backlogs settings, story type, task type, burn cha
---
# Backlogs configuration
Backlogs settings let you tailor OpenProjects Scrum features to match how your team plans and tracks work. By configuring story and task types, burn charts, and sprint wiki templates, you can ensure your backlogs and boards show the right work items and support consistent sprint planning and documentation.
Backlogs settings let you tailor OpenProjects Scrum features to match how your team plans and tracks work. Backlogs support teams in structuring, prioritizing, and refining work for upcoming sprints, helping ensure a clear and consistent approach to sprint planning and execution.
## Backlog administration settings are evolving
We are currently redesigning the Backlogs module. Administration settings for sprints and backlogs will be visible here in the near future. Project-level settings remain available.
> [!NOTE]
>
> We are currently redesigning the Backlogs module. Administration settings for sprints and backlogs will be visible here in the near future.
>
> Project-level settings remain available.
+2 -2
View File
@@ -13,12 +13,12 @@ keywords: backlogs, scrum, backlog, agile, sprint, sprint bucket
Working in agile project teams is becoming increasingly important, and with OpenProject, it is easier than ever.
OpenProject supports your work with the Agile and Scrum methodology by providing a variety of improved functionalities. You can now create and manage sprints, record and prioritize user stories in sprints and the backlog, use sprint boards or burndown-charts, print story cards, and much more. For more information, please refer to the OpenProject [agile and scrum features](https://www.openproject.org/collaboration-software-features/agile-project-management/) page.
OpenProject supports your work with the Agile and Scrum methodology by providing a variety of improved functionalities. You can now create and manage sprints, record and prioritize work packages in sprints and the backlog, use automated sprint boards or burndown-charts, and much more. For more information, please refer to the OpenProject [agile and scrum features](https://www.openproject.org/collaboration-software-features/agile-project-management/) page.
<div class="glossary">
A **Backlog** is defined as a plugin that allows you to use the backlogs feature in OpenProject. In order to use backlogs in a project, the Backlogs module has to be activated in the project settings.
A **Backlog** is defined as a module that allows you to use the backlogs feature in OpenProject. In order to use backlogs in a project, the Backlogs module has to be activated in the project settings.
</div>
@@ -23,20 +23,43 @@ Press the **Save** button to apply your changes.
## Sharing sprints
Sharing is a project-level setting that allows you to choose whether sprints should be shared across projects or not.
Sharing is a **project-level setting** that allows you to choose whether sprints should be shared across projects or not.
> [!NOTE]
> This is not a sprint-level setting as is currently the case with versions.
Sharing sprints allows teams working across multiple projects to plan and track work in a coordinated way. Instead of managing separate, disconnected sprints in each project, you can define a sprint once and reuse it across projects. This is especially useful for cross-team Scrum setups, scaled agile environments, or when multiple teams contribute to the same increment.
Depending on the selected option, a project can either provide sprints to others, use shared sprints, or remain independent:
**Don't share:** This is the default setting for projects. Sprints can be created in this project and are available and visible only within this project. None of the created sprints are shared with any other project or sub-projects.
**Share sprints:** Sprints can be created in this project and shared with either **all projects** or **subprojects**:
**All projects:** Selecting this option means the sprints created are available to all projects within the instance. It also means that other projects will not be able to use this option.
- **All projects:** Selecting this option means the sprints created are available to all projects within the instance. It also means that no other project can share sprints with all projects.
**Subprojects:** Sprints created in this project will be available to all subprojects of the current project.
- **Subprojects:** Sprints created in this project will be available to all subprojects of the current project.
**Receive shared sprints:** No sprints can be created within this project. Instead, only sprints shared by other projects can be used.
**Receive shared sprints:** No sprints can be created within this project. Instead, only sprints shared by another project can be used.
![Manage backlogs settings under project settings in OpenProject](openproject_user_guide_project_settings_backlogs_sharing.png)
### What is shared
When sprints are shared, the sprint itself is shared across projects. This includes:
- Sprint name
- Start and finish dates
- Sprint status (e.g. planning, active, completed)
This ensures that all participating projects work with the same sprint definition and timeline.
### What is not shared
- Work packages remain in their respective projects
- Backlogs and their structure remain project-specific
- Permissions and visibility are still managed per project
Even when using shared sprints, each project keeps its own work items and configuration.
Read more on [how to work with Backlogs in OpenProject](../../../backlogs-scrum/).
@@ -31,16 +31,10 @@
import { ApplicationController } from 'stimulus-use';
export default class KeepScrollPositionController extends ApplicationController {
static values = {
url: String,
};
static targets = ['triggerButton'];
declare triggerButtonTarget:HTMLLinkElement;
declare urlValue:string;
connect() {
super.connect();
@@ -80,6 +74,6 @@ export default class KeepScrollPositionController extends ApplicationController
}
private scrollPositionKey():string {
return `${this.urlValue}/scrollPosition`;
return `${window.location.pathname}/scrollPosition`;
}
}
@@ -345,6 +345,7 @@ module OpenProject
# Target path with optional peg revision
# http://svnbook.red-bean.com/en/1.7/svn.advanced.pegrevs.html
def target(path = "", peg: nil)
validate_path!(path)
path = super(path)
if peg
@@ -354,6 +355,27 @@ module OpenProject
end
end
def validate_path!(path)
decoded = path.to_s
# Unescape repeatedly to catch nested encoding such as %252e%252e.
loop do
unescaped = URI::DEFAULT_PARSER.unescape(decoded)
break if unescaped == decoded
decoded = unescaped
end
invalid_characters = [path.to_s, decoded].any? do |value|
value.include?("\0") || value.include?("\n") || value.include?("\r")
end
traverses_parent = decoded.split("/").include?("..")
return unless invalid_characters || traverses_parent
raise Exceptions::CommandFailed.new(client_command, "Invalid repository path")
end
##
# Builds the full git arguments from the parameters
# and calls the given block with in, out, err, thread
@@ -1,8 +1,8 @@
<%=
helpers.content_controller "poll-for-changes keep-scroll-position",
helpers.content_controller "poll-for-changes",
poll_for_changes_url_value: check_for_updates_project_meeting_path(@meeting.project, @meeting),
poll_for_changes_interval_value: check_for_updates_interval,
keep_scroll_position_url_value: meeting_path(@meeting)
poll_for_changes_interval_value: check_for_updates_interval
helpers.body_controller "keep-scroll-position"
component_wrapper do
render(
@@ -1,5 +1,4 @@
<% helpers.content_controller "keep-scroll-position",
keep_scroll_position_url_value: polymorphic_path([@project, :meetings]) %>
<% helpers.content_controller "keep-scroll-position" %>
<%= render(Primer::OpenProject::PageHeader.new) do |header|
header.with_title { page_title }
@@ -1,6 +1,5 @@
<%=
helpers.content_controller "keep-scroll-position",
keep_scroll_position_url_value: polymorphic_path([@project, @meeting])
helpers.content_controller "keep-scroll-position"
component_wrapper do
render(Primer::OpenProject::PageHeader.new) do |header|
@@ -0,0 +1,174 @@
# frozen_string_literal: true
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
require "spec_helper"
require_relative "../support/pages/meetings/show"
require_relative "../support/pages/meetings/index"
require_relative "../support/pages/recurring_meeting/show"
RSpec.describe "Meeting scroll position",
:js do
shared_let(:project) { create(:project, enabled_module_names: %w[meetings]) }
shared_let(:user) { create(:admin) }
current_user { user }
describe "is restored after clicking 'Reload' in the flash banner" do
let(:meeting) { create(:meeting, project:, author: user) }
let(:show_page) { Pages::Meetings::Show.new(meeting) }
before do
25.times { create(:meeting_agenda_item, meeting:, author: user) }
# Disable automatic polling so we can trigger it manually
allow_any_instance_of(Meetings::HeaderComponent) # rubocop:disable RSpec/AnyInstance
.to receive(:check_for_updates_interval)
.and_return(0)
end
it do
flash_component = ".op-primer-flash--item"
show_page.visit!
first_window = current_window
second_window = open_new_window
scroll_position = within_window(first_window) do
expect(page).to have_test_selector("meeting-page-header")
last_item = all("[data-test-selector='op-meeting-agenda-title']").last
page.execute_script("arguments[0].scrollIntoView({ block: 'center', behavior: 'instant' });", last_item)
page.evaluate_script("document.getElementById('content-body').scrollTop")
end
within_window(second_window) do
show_page.visit!
retry_block do
show_page.add_agenda_item do
fill_in "Title", with: "New item triggering update"
end
end
end
within_window(first_window) do
show_page.trigger_change_poll
expect(page).to have_css(flash_component)
expect(page).to have_text(I18n.t(:notice_meeting_updated))
click_on I18n.t("label_meeting_reload")
expect(page).to have_test_selector("meeting-page-header")
retry_block do
restored_position = page.evaluate_script("document.getElementById('content-body').scrollTop")
expect(restored_position).to be_within(25).of(scroll_position)
end
end
end
end
describe "is restored after clicking 'Show more' in the meetings index page footer" do
let(:index_page) { Pages::Meetings::Index.new(project:) }
# Freeze time at 8 AM so "today" meetings scheduled for 10 AM are always in the future
around do |example|
freeze_time_at = Time.zone.today.beginning_of_day + 8.hours
travel_to(freeze_time_at) { example.run }
end
before do
30.times { create(:meeting, :author_participates, project:, author: user, start_time: Time.zone.today + 10.hours) }
8.times { create(:meeting, :author_participates, project:, author: user, start_time: 2.weeks.from_now) }
end
it do
index_page.visit!
expect(page).to have_text(I18n.t(:label_recurring_meeting_show_more))
trigger_button = find("[data-keep-scroll-position-target='triggerButton']")
page.execute_script("arguments[0].scrollIntoView({ block: 'center', behavior: 'instant' });", trigger_button)
scroll_position = page.evaluate_script("document.getElementById('content-body').scrollTop")
trigger_button.click
expect(page).to have_no_text(I18n.t(:label_recurring_meeting_show_more))
retry_block do
restored_position = page.evaluate_script("document.getElementById('content-body').scrollTop")
expect(restored_position).to be_within(25).of(scroll_position)
end
end
end
describe "is restored after clicking 'Show more' in the recurring meeting show page footer" do
let(:recurring_meeting) do
create(:recurring_meeting,
project:,
author: user,
start_time: Date.tomorrow + 10.hours,
frequency: "daily",
end_after: "iterations",
iterations: 31)
end
let(:show_page) { Pages::RecurringMeeting::Show.new(recurring_meeting) }
before do
25.times do |i|
meeting = create(:meeting,
project:,
author: user,
recurring_meeting:,
start_time: (i + 4).weeks.from_now + 10.hours)
create(:scheduled_meeting,
meeting:,
recurring_meeting:,
start_time: meeting.start_time)
end
end
it do
show_page.visit!
expect(page).to have_text(I18n.t(:label_recurring_meeting_show_more))
trigger_button = find("[data-keep-scroll-position-target='triggerButton']")
page.execute_script("arguments[0].scrollIntoView({ block: 'center', behavior: 'instant' });", trigger_button)
scroll_position = page.evaluate_script("document.getElementById('content-body').scrollTop")
trigger_button.click
expect(page).to have_no_text(I18n.t(:label_recurring_meeting_show_more))
retry_block do
restored_position = page.evaluate_script("document.getElementById('content-body').scrollTop")
expect(restored_position).to be_within(25).of(scroll_position)
end
end
end
end
@@ -98,24 +98,60 @@ RSpec.describe WorkPackages::UpdateContract do
describe "project_id" do
let(:target_project) { persisted_other_project }
let(:source_permissions) { %i[view_work_packages edit_work_packages move_work_packages] }
let(:target_permissions) { [:move_work_packages] }
before do
mock_permissions_for(user) do |mock|
mock.allow_in_project *permissions, project: persisted_project
mock.allow_in_project *source_permissions, project: persisted_project
mock.allow_in_project *target_permissions, project: target_project
end
work_package.project = target_project
end
it_behaves_like "contract is valid"
context "with move_work_packages in both source and target" do
it_behaves_like "contract is valid"
end
context "if the user lacks the permissions" do
context "if the user lacks move_work_packages in the target project" do
let(:target_permissions) { [] }
it_behaves_like "contract is invalid", project_id: :error_readonly
end
context "if the user lacks move_work_packages in the source project" do
let(:source_permissions) { %i[view_work_packages edit_work_packages] }
it_behaves_like "contract is invalid", project_id: :error_readonly
end
context "when modifying attributes while moving (authorization bypass prevention)" do
before do
work_package.subject = "modified-subject"
end
context "with edit_work_packages in target project" do
let(:target_permissions) { %i[move_work_packages edit_work_packages] }
it_behaves_like "contract is valid"
end
context "without edit_work_packages in target project" do
let(:target_permissions) { [:move_work_packages] }
it_behaves_like "contract is invalid", subject: :error_readonly
end
context "without move_work_packages in source project" do
let(:source_permissions) { %i[view_work_packages change_work_package_status] }
let(:target_permissions) { %i[move_work_packages edit_work_packages] }
it "blocks the move even when the target project grants all permissions" do
expect(validated_contract.errors.symbols_for(:project_id)).to include(:error_readonly)
end
end
end
end
describe "remaining_hours" do
@@ -128,6 +128,32 @@ RSpec.describe OpenProject::SCM::Adapters::Subversion do
end
end
describe "path validation" do
shared_examples "rejects encoded parent traversal" do |method_name, *args|
it "rejects encoded parent traversal in ##{method_name}" do
allow(adapter).to receive(:capture_svn)
allow(adapter).to receive(:popen3)
expect { adapter.public_send(method_name, "%252e%252e/other_repo/credentials.txt", *args) }
.to raise_error(OpenProject::SCM::Exceptions::CommandFailed) { |error|
expect(error.message).to eq("Invalid repository path")
}
expect(adapter).not_to have_received(:capture_svn)
expect(adapter).not_to have_received(:popen3)
end
end
include_examples "rejects encoded parent traversal", :entries
include_examples "rejects encoded parent traversal", :cat
include_examples "rejects encoded parent traversal", :diff, 2, 1
include_examples "rejects encoded parent traversal", :annotate
it "allows dots that are not parent traversal" do
expect(adapter.send(:target, "subversion_test/file..txt")).to eq("#{url}/subversion_test/file..txt")
end
end
describe "empty repository" do
include_context "with tmpdir"
let(:root_url) { tmpdir }
@@ -178,7 +204,7 @@ RSpec.describe OpenProject::SCM::Adapters::Subversion do
out, process = Open3.capture2e("svn", "info", url)
expect(process.exitstatus).to eq(0)
expect(out).to include("Repository UUID")
expect(out).to match(/UUID.*[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)
end
it "is available" do
@@ -389,7 +415,9 @@ RSpec.describe OpenProject::SCM::Adapters::Subversion do
it "provides the selected diff for the given range" do
diff = adapter.diff("subversion_test/helloworld.c", 8, 6).map(&:chomp)
expect(diff).to eq(<<~DIFF.split("\n"))
normalized_diff = diff.map { |line| line.gsub(/\((?i:revision) (\d+)\)/, "(revision \\1)") }
expect(normalized_diff).to eq(<<~DIFF.split("\n"))
Index: helloworld.c
===================================================================
--- helloworld.c (revision 6)
@@ -66,6 +66,33 @@ RSpec.describe API::V3::News::NewsAPI,
it_behaves_like "updates the news"
end
describe "user with only view_news on source project and manage_news on different project" do
let(:attacker_project) { create(:project, enabled_module_names: %w[news]) }
let(:user) do
create(:user,
member_with_permissions: {
project => %i[view_news],
attacker_project => %i[view_news manage_news]
})
end
let(:parameters) do
{
_links: {
project: {
href: api_v3_paths.project(attacker_project.id)
}
}
}
end
it "does not move the news to another project" do
expect(last_response.status).to eq(422)
expect(news.reload.project).to eq(project)
end
it_behaves_like "read-only violation", "project", News
end
describe "unauthorized user" do
let(:user) { build(:user) }
@@ -416,6 +416,7 @@ RSpec.describe "API v3 Work package resource",
let(:target_project) do
create(:project, public: false)
end
let(:permissions) { super() + [:move_work_packages] }
let(:project_link) { api_v3_paths.project target_project.id }
let(:project_parameter) { { _links: { project: { href: project_link } } } }
let(:params) { valid_params.merge(project_parameter) }
@@ -10,11 +10,15 @@ module FormFields
# Capybara's native .click on a checkbox can update the DOM property directly
# without dispatching a browser click event, so Stimulus event handlers won't fire.
# Using execute_script with element.click() fires a real browser event.
# We first wait for the element via Capybara's find (which retries until it appears)
# to avoid null reference errors when the DOM is still being updated by Turbo.
def check
page.find(selector)
page.execute_script("document.querySelector(\"#{selector}\").click()")
end
def uncheck
page.find(selector)
page.execute_script("document.querySelector(\"#{selector}\").click()")
end
+1 -2
View File
@@ -104,9 +104,8 @@ module Pages
def open_inplace_edit_field_for_custom_field(custom_field)
scroll_to_element(page.find("[data-test-selector='project-custom-field-#{custom_field.id}']"))
field = Components::Common::InplaceEditField.new(project, custom_field.attribute_name.to_sym)
field.open_field
wait_for_network_idle
wait_for_turbo_stream { field.open_field }
field
end