From 8e8d5c77ad1f68be19ab20dab0c5c23478841770 Mon Sep 17 00:00:00 2001 From: Eric Schubert Date: Wed, 16 Oct 2024 10:57:48 +0200 Subject: [PATCH 001/115] [#57817] add deletion action to item action menu - https://community.openproject.org/work_packages/57817 - added dialog component to safe guard item deletion - added controller actions and routes to open dialog and delete item --- .../delete_item_dialog_component.html.erb | 56 +++++++++++++++++++ .../hierarchy/delete_item_dialog_component.rb | 47 ++++++++++++++++ .../hierarchy/item_component.html.erb | 10 +++- .../custom_fields/hierarchy/item_component.rb | 16 +++++- .../hierarchy/items_component.html.erb | 5 +- .../hierarchy/items_component.rb | 2 + .../hierarchy/items_controller.rb | 23 +++++++- config/locales/en.yml | 2 +- config/routes.rb | 4 +- 9 files changed, 157 insertions(+), 8 deletions(-) create mode 100644 app/components/admin/custom_fields/hierarchy/delete_item_dialog_component.html.erb create mode 100644 app/components/admin/custom_fields/hierarchy/delete_item_dialog_component.rb diff --git a/app/components/admin/custom_fields/hierarchy/delete_item_dialog_component.html.erb b/app/components/admin/custom_fields/hierarchy/delete_item_dialog_component.html.erb new file mode 100644 index 000000000000..3ddbcb1ccb9b --- /dev/null +++ b/app/components/admin/custom_fields/hierarchy/delete_item_dialog_component.html.erb @@ -0,0 +1,56 @@ +<%#-- 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. + +++#%> + +<%= + render(Primer::Alpha::Dialog.new(id: DIALOG_ID, title: "Delete item")) do |dialog| + dialog.with_header(variant: :large) + dialog.with_body do + "Are you sure you want to delete this item from the current hierarchy level?" + end + + dialog.with_footer do + concat(render(Primer::ButtonComponent.new(data: { "close-dialog-id": DIALOG_ID })) do + I18n.t(:button_cancel) + end) + + concat(primer_form_with( + model: @custom_field, + url: custom_field_item_path(custom_field_id: @custom_field.id, id: @hierarchy_item.id), + method: :delete, + data: { turbo: true } + ) do + render(Primer::ButtonComponent.new(scheme: :danger, + type: :submit, + data: { "close-dialog-id": DIALOG_ID })) do + I18n.t(:button_delete) + end + end) + end + end +%> diff --git a/app/components/admin/custom_fields/hierarchy/delete_item_dialog_component.rb b/app/components/admin/custom_fields/hierarchy/delete_item_dialog_component.rb new file mode 100644 index 000000000000..e7b9307bcb4a --- /dev/null +++ b/app/components/admin/custom_fields/hierarchy/delete_item_dialog_component.rb @@ -0,0 +1,47 @@ +# 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. +#++ + +module Admin + module CustomFields + module Hierarchy + class DeleteItemDialogComponent < ApplicationComponent + include OpTurbo::Streamable + + DIALOG_ID = "op-hierarchy-item--deletion-confirmation" + + def initialize(custom_field:, hierarchy_item:) + super + @custom_field = custom_field + @hierarchy_item = hierarchy_item + end + end + end + end +end diff --git a/app/components/admin/custom_fields/hierarchy/item_component.html.erb b/app/components/admin/custom_fields/hierarchy/item_component.html.erb index 369d9234804f..5ff3bf6cf6a1 100644 --- a/app/components/admin/custom_fields/hierarchy/item_component.html.erb +++ b/app/components/admin/custom_fields/hierarchy/item_component.html.erb @@ -46,10 +46,14 @@ See COPYRIGHT and LICENSE files for more details. # Actions item_container.with_column do - render(Primer::Beta::IconButton.new(scheme: :default, - icon: "kebab-horizontal", - "aria-label": I18n.t("custom_fields.admin.items.more_actions"))) + render(Primer::Alpha::ActionMenu.new(data: { test_selector: "op-hierarchy-item--action-menu" })) do |menu| + menu.with_show_button(icon: "kebab-horizontal", + scheme: :invisible, + "aria-label": I18n.t("custom_fields.admin.items.actions")) + delete_item(menu) + end end end end + %> diff --git a/app/components/admin/custom_fields/hierarchy/item_component.rb b/app/components/admin/custom_fields/hierarchy/item_component.rb index ddf8b1e16fcc..c9f06cb11dd2 100644 --- a/app/components/admin/custom_fields/hierarchy/item_component.rb +++ b/app/components/admin/custom_fields/hierarchy/item_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH @@ -33,14 +35,26 @@ class ItemComponent < ApplicationComponent include OpTurbo::Streamable include OpPrimer::ComponentHelpers - def initialize(hierarchy_item:) + def initialize(custom_field:, hierarchy_item:) super + @custom_field = custom_field @hierarchy_item = hierarchy_item end def short_text "(#{@hierarchy_item.short})" end + + def delete_item(menu) + menu.with_item(label: I18n.t(:button_delete), + scheme: :danger, + tag: :a, + href: deletion_dialog_custom_field_item_path(custom_field_id: @custom_field.id, + id: @hierarchy_item.id), + content_arguments: { data: { controller: "async-dialog" } }) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end end end end diff --git a/app/components/admin/custom_fields/hierarchy/items_component.html.erb b/app/components/admin/custom_fields/hierarchy/items_component.html.erb index 46ad4c347698..f729fcbe7f81 100644 --- a/app/components/admin/custom_fields/hierarchy/items_component.html.erb +++ b/app/components/admin/custom_fields/hierarchy/items_component.html.erb @@ -44,7 +44,10 @@ See COPYRIGHT and LICENSE files for more details. item_box.with_header { @custom_field.name } items.each do |item| - item_box.with_row { render Admin::CustomFields::Hierarchy::ItemComponent.new(hierarchy_item: item) } + item_box.with_row do + render Admin::CustomFields::Hierarchy::ItemComponent.new(custom_field: @custom_field, + hierarchy_item: item) + end end if show_new_item_form? diff --git a/app/components/admin/custom_fields/hierarchy/items_component.rb b/app/components/admin/custom_fields/hierarchy/items_component.rb index d98747609159..a0c91411f7d4 100644 --- a/app/components/admin/custom_fields/hierarchy/items_component.rb +++ b/app/components/admin/custom_fields/hierarchy/items_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) the OpenProject GmbH diff --git a/app/controllers/admin/custom_fields/hierarchy/items_controller.rb b/app/controllers/admin/custom_fields/hierarchy/items_controller.rb index 416e71f6e9f3..4314da2f8a93 100644 --- a/app/controllers/admin/custom_fields/hierarchy/items_controller.rb +++ b/app/controllers/admin/custom_fields/hierarchy/items_controller.rb @@ -33,13 +33,15 @@ module CustomFields module Hierarchy class ItemsController < ApplicationController include OpTurbo::ComponentStream + include OpTurbo::DialogStreamHelper layout "admin" model_object CustomField before_action :require_admin - before_action :find_model_object + before_action :find_model_object, except: %i[destroy deletion_dialog] + before_action :find_custom_field_and_item, only: %i[destroy deletion_dialog] menu_item :custom_fields @@ -63,6 +65,18 @@ def create respond_with_turbo_streams end + def destroy + # TODO: user persistence service + @hierarchy_item.destroy + update_via_turbo_stream(component: ItemsComponent.new(custom_field: @custom_field)) + respond_with_turbo_streams + end + + def deletion_dialog + respond_with_dialog DeleteItemDialogComponent.new(custom_field: @custom_field, + hierarchy_item: @hierarchy_item) + end + private def item_input @@ -86,6 +100,13 @@ def find_model_object(object_id = :custom_field_id) super @custom_field = @object end + + def find_custom_field_and_item + @custom_field = CustomField.find(params[:custom_field_id]) + @hierarchy_item = CustomField::Hierarchy::Item.find(params[:id]) + rescue ActiveRecord::RecordNotFound + render_404 + end end end end diff --git a/config/locales/en.yml b/config/locales/en.yml index 6b07ea4f6a1e..2305500a6fa4 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -231,7 +231,7 @@ en: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" diff --git a/config/routes.rb b/config/routes.rb index 5c26f37f499d..9f15c3a2abe9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -180,7 +180,9 @@ only: :destroy resources :items, controller: "/admin/custom_fields/hierarchy/items", - only: %i[index new create] + only: %i[index new create destroy] do + get :deletion_dialog, on: :member + end end end end From 81d4842aca7858430ea6d415403a47f314464824 Mon Sep 17 00:00:00 2001 From: Ivan Kuchin Date: Fri, 20 Sep 2024 16:08:18 +0200 Subject: [PATCH 002/115] [#57550] Custom field with format version are ordered as strings https://community.openproject.org/work_packages/57550 From dbb4d285cf1277644f8a02824b100df382c0c334 Mon Sep 17 00:00:00 2001 From: Ivan Kuchin Date: Mon, 23 Sep 2024 13:04:45 +0200 Subject: [PATCH 003/115] change expectations of Version.order_by_semver_name --- .../scopes/order_by_semver_name_spec.rb | 43 ++++++++----------- 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/spec/models/versions/scopes/order_by_semver_name_spec.rb b/spec/models/versions/scopes/order_by_semver_name_spec.rb index 2e330d0c86a4..f572a0df7f23 100644 --- a/spec/models/versions/scopes/order_by_semver_name_spec.rb +++ b/spec/models/versions/scopes/order_by_semver_name_spec.rb @@ -2,32 +2,27 @@ RSpec.describe Versions::Scopes::OrderBySemverName do let(:project) { create(:project) } - let!(:version1) do - create(:version, name: "aaaaa 1.", project:) - end - let!(:version2) do - create(:version, name: "aaaaa", project:) - end - let!(:version3) do - create(:version, name: "1.10. aaa", project:) - end - let!(:version4) do - create(:version, name: "1.1. zzz", project:, start_date: Date.today, effective_date: Date.today + 1.day) - end - let!(:version5) do - create(:version, name: "1.2. mmm", project:, start_date: Date.today) - end - let!(:version6) do - create(:version, name: "1. xxxx", project:, start_date: Date.today + 5.days) - end - let!(:version7) do - create(:version, name: "1.1. aaa", project:) - end + let(:names) do + [ + "1. xxxx", + "1.1. aaa", + "1.1. zzz", + "1.2. mmm", + "1.10. aaa", + "9", + "10.2", + "10.10.2", + "10.10.10", + "aaaaa", + "aaaaa 1." + ] + end + let!(:versions) { names.map { |name| create(:version, name:, project:) } } - subject { Version.order_by_semver_name } + subject { Version.order_by_semver_name.order(id: :desc).to_a } it "returns the versions in semver order" do - expect(subject.to_a) - .to eql [version6, version7, version4, version5, version3, version2, version1] + expect(subject) + .to eql versions end end From 7f8798557a4374578f07c12612437ff70d5b49fd Mon Sep 17 00:00:00 2001 From: Ivan Kuchin Date: Mon, 23 Sep 2024 13:05:10 +0200 Subject: [PATCH 004/115] correct order of versions in version custom field ordering --- .../project_query_custom_field_order_spec.rb | 16 ++++++++-------- .../query/results_cf_sorting_integration_spec.rb | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/spec/models/queries/projects/project_query_custom_field_order_spec.rb b/spec/models/queries/projects/project_query_custom_field_order_spec.rb index fd13984ed995..5958575f31b1 100644 --- a/spec/models/queries/projects/project_query_custom_field_order_spec.rb +++ b/spec/models/queries/projects/project_query_custom_field_order_spec.rb @@ -316,10 +316,10 @@ def project_without_cf_value let(:projects) do [ - project_with_cf_value(id_by_name.fetch("10.10.10")), - project_with_cf_value(id_by_name.fetch("10.10.2")), - project_with_cf_value(id_by_name.fetch("10.2")), project_with_cf_value(id_by_name.fetch("9")), + project_with_cf_value(id_by_name.fetch("10.2")), + project_with_cf_value(id_by_name.fetch("10.10.2")), + project_with_cf_value(id_by_name.fetch("10.10.10")), project # TODO: should be at index 0 ] end @@ -332,12 +332,12 @@ def project_without_cf_value let(:projects) do [ - project_with_cf_value(*id_by_name.fetch_values("10.10.10")), # 10.10.10 - project_with_cf_value(*id_by_name.fetch_values("9", "10.10.10")), # 10.10.10, 9 - project_with_cf_value(*id_by_name.fetch_values("10.10.10", "9")), # 10.10.10, 9 + project_with_cf_value(*id_by_name.fetch_values("10.10.2", "9")), # 9, 10.10.2 + project_with_cf_value(*id_by_name.fetch_values("10.10.10", "9")), # 9, 10.10.10 + project_with_cf_value(*id_by_name.fetch_values("9", "10.10.10")), # 9, 10.10.10 + project_with_cf_value(*id_by_name.fetch_values("10.2", "10.10.2")), # 10.2, 10.10.2 project_with_cf_value(*id_by_name.fetch_values("10.10.2")), # 10.10.2 - project_with_cf_value(*id_by_name.fetch_values("10.2", "10.10.2")), # 10.10.2, 10.2 - project_with_cf_value(*id_by_name.fetch_values("10.10.2", "9")), # 10.10.2, 9 + project_with_cf_value(*id_by_name.fetch_values("10.10.10")), # 10.10.10 project # TODO: should be at index 0 ] end diff --git a/spec/models/query/results_cf_sorting_integration_spec.rb b/spec/models/query/results_cf_sorting_integration_spec.rb index 50cf643c9a41..74e2f9865b39 100644 --- a/spec/models/query/results_cf_sorting_integration_spec.rb +++ b/spec/models/query/results_cf_sorting_integration_spec.rb @@ -308,10 +308,10 @@ def wp_without_cf_value let(:work_packages) do [ - wp_with_cf_value(id_by_name.fetch("10.10.10")), - wp_with_cf_value(id_by_name.fetch("10.10.2")), - wp_with_cf_value(id_by_name.fetch("10.2")), wp_with_cf_value(id_by_name.fetch("9")), + wp_with_cf_value(id_by_name.fetch("10.2")), + wp_with_cf_value(id_by_name.fetch("10.10.2")), + wp_with_cf_value(id_by_name.fetch("10.10.10")), wp_without_cf_value # TODO: should be at index 0 ] end @@ -324,12 +324,12 @@ def wp_without_cf_value let(:work_packages) do [ - wp_with_cf_value(id_by_name.fetch_values("10.10.10")), # 10.10.10 - wp_with_cf_value(id_by_name.fetch_values("9", "10.10.10")), # 10.10.10, 9 - wp_with_cf_value(id_by_name.fetch_values("10.10.10", "9")), # 10.10.10, 9 + wp_with_cf_value(id_by_name.fetch_values("10.10.2", "9")), # 9, 10.10.2 + wp_with_cf_value(id_by_name.fetch_values("10.10.10", "9")), # 9, 10.10.10 + wp_with_cf_value(id_by_name.fetch_values("9", "10.10.10")), # 9, 10.10.10 + wp_with_cf_value(id_by_name.fetch_values("10.2", "10.10.2")), # 10.2, 10.10.2 wp_with_cf_value(id_by_name.fetch_values("10.10.2")), # 10.10.2 - wp_with_cf_value(id_by_name.fetch_values("10.2", "10.10.2")), # 10.10.2, 10.2 - wp_with_cf_value(id_by_name.fetch_values("10.10.2", "9")), # 10.10.2, 9 + wp_with_cf_value(id_by_name.fetch_values("10.10.10")), # 10.10.10 wp_without_cf_value # TODO: should be at index 0 ] end From 6edcfea1292061aa42fdcdf3ed3bca44f5788f5e Mon Sep 17 00:00:00 2001 From: Ivan Kuchin Date: Mon, 23 Sep 2024 13:06:47 +0200 Subject: [PATCH 005/115] migration to add numeric collation for column name of versions table --- .../20240920152544_set_versions_name_collation.rb | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 db/migrate/20240920152544_set_versions_name_collation.rb diff --git a/db/migrate/20240920152544_set_versions_name_collation.rb b/db/migrate/20240920152544_set_versions_name_collation.rb new file mode 100644 index 000000000000..3a2da158eb9c --- /dev/null +++ b/db/migrate/20240920152544_set_versions_name_collation.rb @@ -0,0 +1,13 @@ +class SetVersionsNameCollation < ActiveRecord::Migration[7.1] + def up + execute <<-SQL.squish + CREATE COLLATION IF NOT EXISTS versions_name (provider = icu, locale = 'en-u-kn-true'); + SQL + + change_column :versions, :name, :string, collation: "versions_name" + end + + def down + change_column :versions, :name, :string + end +end From 48d78777ceda5abee63b1cc087c4bb76366f4ef3 Mon Sep 17 00:00:00 2001 From: Ivan Kuchin Date: Mon, 23 Sep 2024 13:07:29 +0200 Subject: [PATCH 006/115] simplify Version.order_by_semver_name Also use order instead of reorder --- .../versions/scopes/order_by_semver_name.rb | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/app/models/versions/scopes/order_by_semver_name.rb b/app/models/versions/scopes/order_by_semver_name.rb index 35545c9e8db1..b4affc08d6fa 100644 --- a/app/models/versions/scopes/order_by_semver_name.rb +++ b/app/models/versions/scopes/order_by_semver_name.rb @@ -32,21 +32,7 @@ module OrderBySemverName class_methods do def order_by_semver_name - reorder semver_sql, :name - end - - # Returns an sql for ordering which: - # * Returns a substring from the beginning of the name up until the first alphabetical character e.g. "1.2.3 " - # from "1.2.3 ABC" - # * Replaces all non numerical character groups in that substring by a blank, e.g "1.2.3 " to "1 2 3 " - # * Splits the result into an array of individual number parts, e.g. "{1, 2, 3, ''}" from "1 2 3 " - # * removes all empty array items, e.g. "{1, 2, 3}" from "{1, 2, 3, ''}" - def semver_sql(table_name = Version.table_name) - sql = <<~SQL - array_remove(regexp_split_to_array(regexp_replace(substring(#{table_name}.name from '^[^a-zA-Z]+'), '\\D+', ' ', 'g'), ' '), '')::int[] - SQL - - Arel.sql(sql) + order :name end end end From d6d133e4b33ac1328901c943e2c0e165b947aa78 Mon Sep 17 00:00:00 2001 From: Ivan Kuchin Date: Mon, 23 Sep 2024 14:03:39 +0200 Subject: [PATCH 007/115] fix and simplify sortable of version property of work package --- app/models/queries/work_packages/selects/property_select.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/queries/work_packages/selects/property_select.rb b/app/models/queries/work_packages/selects/property_select.rb index 48635b1c296e..7963dfb81348 100644 --- a/app/models/queries/work_packages/selects/property_select.rb +++ b/app/models/queries/work_packages/selects/property_select.rb @@ -95,7 +95,7 @@ def caption }, version: { association: "version", - sortable: [->(table_name = Version.table_name) { Version.semver_sql(table_name) }, "name"], + sortable: "name", default_order: "ASC", null_handling: "NULLS LAST", groupable: "#{WorkPackage.table_name}.version_id" From 4ddc1e603e7063d359efcc1eafcec80cf5faca7a Mon Sep 17 00:00:00 2001 From: Ivan Kuchin Date: Mon, 23 Sep 2024 14:17:59 +0200 Subject: [PATCH 008/115] fix sort criteria spec --- spec/models/query/sort_criteria_spec.rb | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/spec/models/query/sort_criteria_spec.rb b/spec/models/query/sort_criteria_spec.rb index c1444e2ca065..04bf3de16177 100644 --- a/spec/models/query/sort_criteria_spec.rb +++ b/spec/models/query/sort_criteria_spec.rb @@ -113,12 +113,8 @@ let(:sort_criteria) { [%w[version desc], %w[start_date asc]] } it "adds the order handling (and the default order by id)" do - sort_sql = <<~SQL - array_remove(regexp_split_to_array(regexp_replace(substring(versions.name from '^[^a-zA-Z]+'), '\\D+', ' ', 'g'), ' '), '')::int[] - SQL - expect(subject) - .to eq [["#{sort_sql} DESC NULLS LAST", "name DESC NULLS LAST"], + .to eq [["name DESC NULLS LAST"], ["work_packages.start_date NULLS LAST"], ["work_packages.id DESC"]] end From 8f23e532d27b3ef7de06e50e246a171df7ce62d2 Mon Sep 17 00:00:00 2001 From: Ivan Kuchin Date: Mon, 23 Sep 2024 14:18:52 +0200 Subject: [PATCH 009/115] improve a bit results version integration spec --- .../query/results_version_integration_spec.rb | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/spec/models/query/results_version_integration_spec.rb b/spec/models/query/results_version_integration_spec.rb index efb8c8e90b48..e06e3f5f4b3a 100644 --- a/spec/models/query/results_version_integration_spec.rb +++ b/spec/models/query/results_version_integration_spec.rb @@ -42,7 +42,7 @@ let(:old_version) do create(:version, - name: "1. Old version", + name: "4. Old version", project:, start_date: "2019-02-02", effective_date: "2019-02-03") @@ -50,7 +50,7 @@ let(:new_version) do create(:version, - name: "1.2 New version", + name: "10.2 New version", project:, start_date: "2020-02-02", effective_date: "2020-02-03") @@ -58,7 +58,7 @@ let(:no_date_version) do create(:version, - name: "1.1 No date version", + name: "10.1 No date version", project:, start_date: nil, effective_date: nil) @@ -69,13 +69,13 @@ subject: "No version wp", project:) end - let!(:newest_version_wp) do + let!(:new_version_wp) do create(:work_package, subject: "Newest version wp", version: new_version, project:) end - let!(:oldest_version_wp) do + let!(:old_version_wp) do create(:work_package, subject: "Oldest version wp", version: old_version, @@ -101,7 +101,8 @@ q.sort_criteria = sort_criteria end end - let(:work_packages_asc) { [oldest_version_wp, no_date_version_wp, newest_version_wp, no_version_wp] } + let(:work_packages_asc) { [old_version_wp, no_date_version_wp, new_version_wp, no_version_wp] } + let(:work_packages_desc) { [new_version_wp, no_date_version_wp, old_version_wp, no_version_wp] } before do login_as(user) @@ -136,11 +137,8 @@ let(:sort_criteria) { [["version", "desc"]] } it "returns the correctly sorted result" do - # null values are still sorted last - work_packages_order = [newest_version_wp, no_date_version_wp, oldest_version_wp, no_version_wp] - expect(query_results.work_packages.pluck(:id)) - .to match work_packages_order.map(&:id) + .to match work_packages_desc.map(&:id) end end end From 01485db2709dbdcec1cbe5d9d70cb4afa60256da Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Mon, 30 Sep 2024 10:51:38 +0200 Subject: [PATCH 010/115] [#52147] i18n, rubocop styling WIP - remove column WIP - remove column refactor WIP - move column left or right WIP - small refactor WIP - filter by WIP - style adjustments as discussed with the frontend --- .../projects/table_component.html.erb | 4 +- app/components/projects/table_component.rb | 5 + app/helpers/projects_helper.rb | 10 + app/helpers/sort_helper.rb | 171 ++++++++++++++++++ config/locales/en.yml | 9 +- .../src/global_styles/content/_table.sass | 3 + .../dynamic/filter/filters-form.controller.ts | 9 + .../dynamic/table-action-menu.controller.ts | 63 +++++++ 8 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 frontend/src/stimulus/controllers/dynamic/table-action-menu.controller.ts diff --git a/app/components/projects/table_component.html.erb b/app/components/projects/table_component.html.erb index 1b16267dd13a..26fc30b15c27 100644 --- a/app/components/projects/table_component.html.erb +++ b/app/components/projects/table_component.html.erb @@ -27,7 +27,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <%= component_wrapper(tag: 'turbo-frame') do %> -
+
@@ -55,7 +55,7 @@ See COPYRIGHT and LICENSE files for more details.
<% elsif sortable_column?(column) %> - <%= build_sort_header column.attribute, order_options(column, turbo: true) %> + <%= build_new_sort_header column.attribute, order_options(column, turbo: true) %> <% elsif column.attribute == :favored %>
diff --git a/app/components/projects/table_component.rb b/app/components/projects/table_component.rb index 751645cd2fb9..518bf403094d 100644 --- a/app/components/projects/table_component.rb +++ b/app/components/projects/table_component.rb @@ -67,6 +67,11 @@ def build_sort_header(column, options) helpers.projects_sort_header_tag(column, **options, param: :json) end + def build_new_sort_header(column, options) + helpers.projects_new_sort_header_tag(column, **options, param: :json) + end + + # We don't return the project row # but the [project, level] array from the helper def rows diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index c9d8f2d0de39..053e7d10729e 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -44,6 +44,16 @@ def projects_sort_header_tag(column, **) @sort_criteria.criteria = former_criteria end + def projects_new_sort_header_tag(column, **) + former_criteria = @sort_criteria.criteria.dup + + @sort_criteria.criteria.reject! { |a, _| a == "lft" } + + sort_header_with_action_menu(column, **, allowed_params: projects_query_param_names_for_sort) + ensure + @sort_criteria.criteria = former_criteria + end + def short_project_description(project, length = 255) if project.description.blank? return "" diff --git a/app/helpers/sort_helper.rb b/app/helpers/sort_helper.rb index a5ec937ccafc..f94b57f2399a 100644 --- a/app/helpers/sort_helper.rb +++ b/app/helpers/sort_helper.rb @@ -328,6 +328,177 @@ def sort_header_tag(column, allowed_params: nil, **options) end end + def sort_header_with_action_menu(column, allowed_params: nil, **options) + caption = get_caption(column, options) + + default_order = options.delete(:default_order) || "asc" + lang = options.delete(:lang) || nil + param = options.delete(:param) || :sort + data = options.delete(:data) || {} + + options[:title] = sort_header_title(column, caption, options) + + within_sort_header_tag_hierarchy(options, sort_class(column)) do + # FIXME: always render the action menu + # if %w(name project_status public created_at).include?(column.to_s) + action_menu(column, caption, default_order, allowed_params:, param:, lang:, title: options[:title], data:) + # else + # sort_link(column, caption, default_order, allowed_params:, param:, lang:, title: options[:title], data:) + # end + end + end + + def sort_key(key) + key == :json ? :sortBy : :sort + end + + def sort_by_options(column, order, default_order, allowed_params: nil, **html_options) + order ||= order_string(column, inverted: true) || default_order + + sort_by = html_options.delete(:param) + + sort_param = @sort_criteria.add(column.to_s, order).to_param(sort_by) + sort_key = sort_key(sort_by) + + sort_options = { sort_key => sort_param } + allowed_params ||= %w[filters per_page expand columns] + + safe_query_params(allowed_params).merge(sort_options) + end + + # FIXME: copied from ConfigureViewModalComponent + def selected_columns_for_action_menu + @selected_columns ||= @query + .selects + .map(&:attribute) + end + + def build_columns_link(columns, allowed_params: nil, **html_options) + sort_by = html_options.delete(:param) + sort_key = sort_key(sort_by) + + allowed_params ||= %w[filters per_page expand columns] + projects_path(safe_query_params(allowed_params).merge(columns: columns.join(" "), sort_key => params[sort_key])) + end + + def shift_element(arr, str, direction=:left) + arr = arr.dup + index = arr.index(str) + return arr unless index + + case direction + when :left + if index > 0 + arr[index], arr[index - 1] = arr[index - 1], arr[index] + end + when :right + if index < arr.size - 1 + arr[index], arr[index + 1] = arr[index + 1], arr[index] + end + end + + arr + end + + def filter_conversion(column) + col = column.to_s + + # FIXME: description and project_status_description have NO action menu right now. Should be nil here, too. + { + "name" => "id", + "project_status" => "project_status_code", + "identifier" => nil, + "required_disk_space" => nil, + }.fetch(col, col) + end + + def action_menu(column, caption, default_order, allowed_params: nil, **html_options) + caption ||= column.to_s.humanize + + desc_sort_link = projects_path(sort_by_options(column, "desc", default_order, allowed_params:, **html_options)) + asc_sort_link = projects_path(sort_by_options(column, "asc", default_order, allowed_params:, **html_options)) + + selected_columns = selected_columns_for_action_menu + + left_shift = shift_element(selected_columns, column) + shift_left_link = build_columns_link(left_shift, allowed_params:, **html_options) + + right_shift = shift_element(selected_columns, column, :right) + shift_right_link = build_columns_link(right_shift, allowed_params:, **html_options) + + all_columns_except_this = selected_columns.reject { _1 == column } + rm_column_link = build_columns_link(all_columns_except_this, allowed_params:, **html_options) + + filter = filter_conversion(column) + + html_options.delete(:param) + + content_args = html_options.merge(rel: :nofollow) + + render Primer::Alpha::ActionMenu.new(menu_id: "menu-#{column.to_s}") do |menu| + menu.with_show_button(scheme: :link, color: :default, text_transform: :uppercase, + underline: false, display: :inline_flex) do |button| + button.with_trailing_action_icon(icon: :"triangle-down") + "#{h(caption)}" + end + menu.with_item(label: t(:label_sort_descending), + content_arguments: content_args.merge(title: t(:label_sort_descending)), + href: desc_sort_link) do |item| + item.with_leading_visual_icon(icon: "sort-desc") + end + # FIXME: the title must be unique per link! Else the href will be duplicated. + # TODO: move the title creation to another method. + menu.with_item(label: t(:label_sort_ascending), + content_arguments: content_args.merge(title: t(:label_sort_ascending)), + href: asc_sort_link) do |item| + item.with_leading_visual_icon(icon: "sort-asc") + end + + # Some columns do not offer a filter. Only show the option when filtering is possible. + if filter + menu.with_divider + menu.with_item(label: t(:label_filter_by), + content_arguments: content_args.merge( + data: { + action: "table-action-menu#filterBy", + filter_name: filter + }, + title: t(:label_filter_by) + )) do |item| + item.with_leading_visual_icon(icon: "filter") + end + end + + menu.with_divider + menu.with_item(label: t(:label_move_column_left), + content_arguments: content_args.merge(title: t(:label_move_column_left)), + href: shift_left_link) do |item| + item.with_leading_visual_icon(icon: "op-columns-left") + end + menu.with_item(label: t(:label_move_column_right), + content_arguments: content_args.merge(title: t(:label_move_column_right)), + href: shift_right_link) do |item| + item.with_leading_visual_icon(icon: "op-columns-right") + end + # TODO: title? + menu.with_item(label: t(:label_add_column), + href: configure_view_modal_project_queries_path(projects_query_params), + content_arguments: content_args.merge( + data: { controller: "async-dialog" }, + title: t(:label_add_column) + )) do |item| + item.with_leading_visual_icon(icon: "columns") + end + menu.with_divider + menu.with_item(label: t(:label_remove_column), + scheme: :danger, + content_arguments: content_args.merge(title: t(:label_remove_column)), + href: rm_column_link) do |item| + item.with_leading_visual_icon(icon: "trash") + end + end + end + def sort_class(column) order = order_string(column) diff --git a/config/locales/en.yml b/config/locales/en.yml index 6b07ea4f6a1e..10b04d6e67fe 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2097,6 +2097,7 @@ en: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "Revoke" + label_add_column: "Add column" label_applied_status: "Applied status" label_archive_project: "Archive project" label_ascending: "Ascending" @@ -2266,8 +2267,9 @@ en: label_feeds_access_key_created_on: "RSS access key created %{value} ago" label_feeds_access_key_type: "RSS" label_file_plural: "Files" - label_filter_add: "Add filter" label_filter: "Filters" + label_filter_add: "Add filter" + label_filter_by: "Filter by" label_filter_plural: "Filters" label_filters_toggle: "Show/hide filters" label_float: "Float" @@ -2390,6 +2392,8 @@ en: label_months_from: "months from" label_more: "More" label_more_than_ago: "more than days ago" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Move work package" label_my_account: "Account settings" label_my_activity: "My activity" @@ -2512,6 +2516,7 @@ en: label_relation_new: "New relation" label_release_notes: "Release notes" label_remaining_work: "Remaining work" + label_remove_column: "Remove column" label_remove_columns: "Remove selected columns" label_renamed: "renamed" label_reply_plural: "Replies" @@ -2553,8 +2558,10 @@ en: label_show_completed_versions: "Show completed versions" label_columns: "Columns" label_sort: "Sort" + label_sort_ascending: "Sort ascending" label_sort_by: "Sort by %{value}" label_sorted_by: "sorted by %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Move up" label_sort_highest: "Move to top" label_sort_lower: "Move down" diff --git a/frontend/src/global_styles/content/_table.sass b/frontend/src/global_styles/content/_table.sass index 4b866d275129..f5566d7657db 100644 --- a/frontend/src/global_styles/content/_table.sass +++ b/frontend/src/global_styles/content/_table.sass @@ -104,6 +104,9 @@ table.generic-table // https://www.456bereastreet.com/archive/201305/firefox_and_the_magical_text-overflowellipsis_z-index/ z-index: 1 + action-menu + text-transform: none + &.-right text-align: right diff --git a/frontend/src/stimulus/controllers/dynamic/filter/filters-form.controller.ts b/frontend/src/stimulus/controllers/dynamic/filter/filters-form.controller.ts index 880ffe72a279..1a35b40d5943 100644 --- a/frontend/src/stimulus/controllers/dynamic/filter/filters-form.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/filter/filters-form.controller.ts @@ -142,6 +142,11 @@ export default class FiltersFormController extends Controller { this.displayFiltersValue = !this.displayFiltersValue; } + showDisplayFilters() { + this.displayFiltersValue = true; + this.displayFiltersValueChanged(); + } + displayFiltersValueChanged() { this.toggleButtonActive(); this.toggleFilterFormVisible(); @@ -187,6 +192,10 @@ export default class FiltersFormController extends Controller { addFilter(event:Event) { const filterName = (event.target as HTMLSelectElement).value; + this.addFilterByName(filterName); + } + + addFilterByName(filterName:string) { const selectedFilter = this.findTargetByName(filterName, this.filterTargets); if (selectedFilter) { selectedFilter.classList.remove('hidden'); diff --git a/frontend/src/stimulus/controllers/dynamic/table-action-menu.controller.ts b/frontend/src/stimulus/controllers/dynamic/table-action-menu.controller.ts new file mode 100644 index 000000000000..00feed62859e --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/table-action-menu.controller.ts @@ -0,0 +1,63 @@ +/* + * -- 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. + * ++ + */ + +import { Controller } from '@hotwired/stimulus'; +import FiltersFormController from './filter/filters-form.controller'; + +export default class TableActionMenuController extends Controller { + declare filterController:FiltersFormController|null; + + connect():void { + this.findFilterController(); + } + + private findFilterController() { + if (this.filterController) return; + + const subHeader = document.getElementById('projects-index-sub-header')?.querySelector('sub-header'); + if (subHeader) { + this.filterController = this.application.getControllerForElementAndIdentifier(subHeader, 'filter--filters-form') as FiltersFormController; + } + } + + filterBy(event: Event) { + // Sometimes the filterController was not loaded during `connect`, so we try again here: + if (!this.filterController) { this.findFilterController() } + + const button = event.currentTarget as HTMLElement; + + const filterName = button.getAttribute('data-filter-name'); + + if (this.filterController && filterName) { + this.filterController.showDisplayFilters(); + this.filterController.addFilterByName(filterName); + } + } +} From 35517163ac45b0afce0ce431366f096b1585765c Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Wed, 9 Oct 2024 16:32:19 +0200 Subject: [PATCH 011/115] [#52147] tidy up --- app/helpers/sort_helper.rb | 102 +++++++++++++++++-------------------- 1 file changed, 48 insertions(+), 54 deletions(-) diff --git a/app/helpers/sort_helper.rb b/app/helpers/sort_helper.rb index f94b57f2399a..bfe95eafbaa8 100644 --- a/app/helpers/sort_helper.rb +++ b/app/helpers/sort_helper.rb @@ -339,12 +339,7 @@ def sort_header_with_action_menu(column, allowed_params: nil, **options) options[:title] = sort_header_title(column, caption, options) within_sort_header_tag_hierarchy(options, sort_class(column)) do - # FIXME: always render the action menu - # if %w(name project_status public created_at).include?(column.to_s) - action_menu(column, caption, default_order, allowed_params:, param:, lang:, title: options[:title], data:) - # else - # sort_link(column, caption, default_order, allowed_params:, param:, lang:, title: options[:title], data:) - # end + action_menu(column, caption, default_order, allowed_params:, param:, lang:, title: options[:title], data:) end end @@ -381,34 +376,36 @@ def build_columns_link(columns, allowed_params: nil, **html_options) projects_path(safe_query_params(allowed_params).merge(columns: columns.join(" "), sort_key => params[sort_key])) end - def shift_element(arr, str, direction=:left) + def shift_element(arr, str, direction = :left) arr = arr.dup index = arr.index(str) return arr unless index - case direction - when :left - if index > 0 - arr[index], arr[index - 1] = arr[index - 1], arr[index] - end - when :right - if index < arr.size - 1 - arr[index], arr[index + 1] = arr[index + 1], arr[index] - end - end + step = direction == :left ? -1 : 1 + + new_index = index + step + return arr if new_index.negative? || new_index >= arr.size + + arr[index], arr[new_index] = arr[new_index], arr[index] arr end + def menu_options(label:, content_args:, **extra_args) + { + label:, + content_arguments: content_args.merge(title: label) + }.merge(extra_args) + end + def filter_conversion(column) col = column.to_s - # FIXME: description and project_status_description have NO action menu right now. Should be nil here, too. { "name" => "id", "project_status" => "project_status_code", "identifier" => nil, - "required_disk_space" => nil, + "required_disk_space" => nil }.fetch(col, col) end @@ -426,6 +423,8 @@ def action_menu(column, caption, default_order, allowed_params: nil, **html_opti right_shift = shift_element(selected_columns, column, :right) shift_right_link = build_columns_link(right_shift, allowed_params:, **html_options) + config_view_modal_link = configure_view_modal_project_queries_path(projects_query_params) + all_columns_except_this = selected_columns.reject { _1 == column } rm_column_link = build_columns_link(all_columns_except_this, allowed_params:, **html_options) @@ -435,65 +434,60 @@ def action_menu(column, caption, default_order, allowed_params: nil, **html_opti content_args = html_options.merge(rel: :nofollow) - render Primer::Alpha::ActionMenu.new(menu_id: "menu-#{column.to_s}") do |menu| + render Primer::Alpha::ActionMenu.new(menu_id: "menu-#{column}") do |menu| menu.with_show_button(scheme: :link, color: :default, text_transform: :uppercase, underline: false, display: :inline_flex) do |button| button.with_trailing_action_icon(icon: :"triangle-down") - "#{h(caption)}" + h(caption).to_s end - menu.with_item(label: t(:label_sort_descending), - content_arguments: content_args.merge(title: t(:label_sort_descending)), - href: desc_sort_link) do |item| + menu.with_item(**menu_options(label: t(:label_sort_descending), + content_args:, + href: desc_sort_link)) do |item| item.with_leading_visual_icon(icon: "sort-desc") end - # FIXME: the title must be unique per link! Else the href will be duplicated. - # TODO: move the title creation to another method. - menu.with_item(label: t(:label_sort_ascending), - content_arguments: content_args.merge(title: t(:label_sort_ascending)), - href: asc_sort_link) do |item| + menu.with_item(**menu_options(label: t(:label_sort_ascending), + content_args:, + href: asc_sort_link)) do |item| item.with_leading_visual_icon(icon: "sort-asc") end # Some columns do not offer a filter. Only show the option when filtering is possible. if filter menu.with_divider - menu.with_item(label: t(:label_filter_by), - content_arguments: content_args.merge( - data: { - action: "table-action-menu#filterBy", - filter_name: filter - }, - title: t(:label_filter_by) - )) do |item| + menu.with_item(**menu_options(label: t(:label_filter_by), + content_args: content_args.merge( + data: { + action: "table-action-menu#filterBy", + filter_name: filter + } + ))) do |item| item.with_leading_visual_icon(icon: "filter") end end menu.with_divider - menu.with_item(label: t(:label_move_column_left), - content_arguments: content_args.merge(title: t(:label_move_column_left)), - href: shift_left_link) do |item| + menu.with_item(**menu_options(label: t(:label_move_column_left), + content_args:, + href: shift_left_link)) do |item| item.with_leading_visual_icon(icon: "op-columns-left") end - menu.with_item(label: t(:label_move_column_right), - content_arguments: content_args.merge(title: t(:label_move_column_right)), - href: shift_right_link) do |item| + menu.with_item(**menu_options(label: t(:label_move_column_right), + content_args:, + href: shift_right_link)) do |item| item.with_leading_visual_icon(icon: "op-columns-right") end - # TODO: title? - menu.with_item(label: t(:label_add_column), - href: configure_view_modal_project_queries_path(projects_query_params), - content_arguments: content_args.merge( - data: { controller: "async-dialog" }, - title: t(:label_add_column) - )) do |item| + menu.with_item(**menu_options(label: t(:label_add_column), + content_args: content_args.merge( + data: { controller: "async-dialog" } + ), + href: config_view_modal_link)) do |item| item.with_leading_visual_icon(icon: "columns") end menu.with_divider - menu.with_item(label: t(:label_remove_column), - scheme: :danger, - content_arguments: content_args.merge(title: t(:label_remove_column)), - href: rm_column_link) do |item| + menu.with_item(**menu_options(label: t(:label_remove_column), + content_args:, + scheme: :danger, + href: rm_column_link)) do |item| item.with_leading_visual_icon(icon: "trash") end end From 5bc8ffe3fd2b94eb40b86765ee1287fb95c71508 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Wed, 9 Oct 2024 17:31:03 +0200 Subject: [PATCH 012/115] [#52147] use helpers for action menu construction Better have many smaller methods than one giant one. --- app/helpers/sort_helper.rb | 144 +++++++++++++++++++++---------------- 1 file changed, 81 insertions(+), 63 deletions(-) diff --git a/app/helpers/sort_helper.rb b/app/helpers/sort_helper.rb index bfe95eafbaa8..e583f0a10efb 100644 --- a/app/helpers/sort_helper.rb +++ b/app/helpers/sort_helper.rb @@ -412,84 +412,102 @@ def filter_conversion(column) def action_menu(column, caption, default_order, allowed_params: nil, **html_options) caption ||= column.to_s.humanize + selected_columns = selected_columns_for_action_menu + filter = filter_conversion(column) + + # `param` is not needed in the `content_arguments`, but should remain in the `html_options`. + # It is important for keeping the current state in the GET parameters of each link used in + # the action menu. + content_args = html_options.merge(rel: :nofollow, param: nil) + + render Primer::Alpha::ActionMenu.new(menu_id: "menu-#{column}") do |menu| + action_button(menu, caption) + sort_actions(menu, column, default_order, content_args:, allowed_params:, **html_options) + + # Some columns do not offer a filter. Only show the option when filtering is possible. + filter_action(menu, filter, content_args:) if filter + + move_column_actions(menu, column, selected_columns, content_args:, allowed_params:, **html_options) + add_and_remove_column_actions(menu, column, selected_columns, content_args:, allowed_params:, **html_options) + end + end + + def action_button(menu, caption) + menu.with_show_button(scheme: :link, color: :default, text_transform: :uppercase, + underline: false, display: :inline_flex) do |button| + button.with_trailing_action_icon(icon: :"triangle-down") + h(caption).to_s + end + end + + def sort_actions(menu, column, default_order, content_args:, allowed_params: nil, **html_options) desc_sort_link = projects_path(sort_by_options(column, "desc", default_order, allowed_params:, **html_options)) asc_sort_link = projects_path(sort_by_options(column, "asc", default_order, allowed_params:, **html_options)) - selected_columns = selected_columns_for_action_menu + menu.with_item(**menu_options(label: t(:label_sort_descending), + content_args:, + href: desc_sort_link)) do |item| + item.with_leading_visual_icon(icon: "sort-desc") + end + menu.with_item(**menu_options(label: t(:label_sort_ascending), + content_args:, + href: asc_sort_link)) do |item| + item.with_leading_visual_icon(icon: "sort-asc") + end + end + def filter_action(menu, filter, content_args:) + menu.with_divider + menu.with_item(**menu_options(label: t(:label_filter_by), + content_args: content_args.merge( + data: { + action: "table-action-menu#filterBy", + filter_name: filter + } + ))) do |item| + item.with_leading_visual_icon(icon: "filter") + end + end + + def move_column_actions(menu, column, selected_columns, content_args:, allowed_params: nil, **html_options) left_shift = shift_element(selected_columns, column) shift_left_link = build_columns_link(left_shift, allowed_params:, **html_options) right_shift = shift_element(selected_columns, column, :right) shift_right_link = build_columns_link(right_shift, allowed_params:, **html_options) + menu.with_divider + menu.with_item(**menu_options(label: t(:label_move_column_left), + content_args:, + href: shift_left_link)) do |item| + item.with_leading_visual_icon(icon: "op-columns-left") + end + menu.with_item(**menu_options(label: t(:label_move_column_right), + content_args:, + href: shift_right_link)) do |item| + item.with_leading_visual_icon(icon: "op-columns-right") + end + end + + def add_and_remove_column_actions(menu, column, selected_columns, content_args:, allowed_params: nil, **html_options) config_view_modal_link = configure_view_modal_project_queries_path(projects_query_params) all_columns_except_this = selected_columns.reject { _1 == column } rm_column_link = build_columns_link(all_columns_except_this, allowed_params:, **html_options) - filter = filter_conversion(column) - - html_options.delete(:param) - - content_args = html_options.merge(rel: :nofollow) - - render Primer::Alpha::ActionMenu.new(menu_id: "menu-#{column}") do |menu| - menu.with_show_button(scheme: :link, color: :default, text_transform: :uppercase, - underline: false, display: :inline_flex) do |button| - button.with_trailing_action_icon(icon: :"triangle-down") - h(caption).to_s - end - menu.with_item(**menu_options(label: t(:label_sort_descending), - content_args:, - href: desc_sort_link)) do |item| - item.with_leading_visual_icon(icon: "sort-desc") - end - menu.with_item(**menu_options(label: t(:label_sort_ascending), - content_args:, - href: asc_sort_link)) do |item| - item.with_leading_visual_icon(icon: "sort-asc") - end - - # Some columns do not offer a filter. Only show the option when filtering is possible. - if filter - menu.with_divider - menu.with_item(**menu_options(label: t(:label_filter_by), - content_args: content_args.merge( - data: { - action: "table-action-menu#filterBy", - filter_name: filter - } - ))) do |item| - item.with_leading_visual_icon(icon: "filter") - end - end - - menu.with_divider - menu.with_item(**menu_options(label: t(:label_move_column_left), - content_args:, - href: shift_left_link)) do |item| - item.with_leading_visual_icon(icon: "op-columns-left") - end - menu.with_item(**menu_options(label: t(:label_move_column_right), - content_args:, - href: shift_right_link)) do |item| - item.with_leading_visual_icon(icon: "op-columns-right") - end - menu.with_item(**menu_options(label: t(:label_add_column), - content_args: content_args.merge( - data: { controller: "async-dialog" } - ), - href: config_view_modal_link)) do |item| - item.with_leading_visual_icon(icon: "columns") - end - menu.with_divider - menu.with_item(**menu_options(label: t(:label_remove_column), - content_args:, - scheme: :danger, - href: rm_column_link)) do |item| - item.with_leading_visual_icon(icon: "trash") - end + menu.with_item(**menu_options(label: t(:label_add_column), + content_args: content_args.merge( + data: { controller: "async-dialog" } + ), + href: config_view_modal_link)) do |item| + item.with_leading_visual_icon(icon: "columns") + end + menu.with_divider + menu.with_item(**menu_options(label: t(:label_remove_column), + content_args:, + scheme: :danger, + href: rm_column_link)) do |item| + item.with_leading_visual_icon(icon: "trash") end end From 39322117e20f66d276e7eae7f2153ad419577bb8 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Thu, 10 Oct 2024 10:32:03 +0200 Subject: [PATCH 013/115] [#52147] reorganize quick action table header methods --- .../projects/table_component.html.erb | 2 +- app/components/projects/table_component.rb | 5 ---- app/helpers/projects_helper.rb | 10 ------- app/helpers/sort_helper.rb | 26 ++++++++++--------- 4 files changed, 15 insertions(+), 28 deletions(-) diff --git a/app/components/projects/table_component.html.erb b/app/components/projects/table_component.html.erb index 26fc30b15c27..5e16a55c21de 100644 --- a/app/components/projects/table_component.html.erb +++ b/app/components/projects/table_component.html.erb @@ -55,7 +55,7 @@ See COPYRIGHT and LICENSE files for more details.
<% elsif sortable_column?(column) %> - <%= build_new_sort_header column.attribute, order_options(column, turbo: true) %> + <%= build_sort_header column.attribute, order_options(column, turbo: true) %> <% elsif column.attribute == :favored %>
diff --git a/app/components/projects/table_component.rb b/app/components/projects/table_component.rb index 518bf403094d..751645cd2fb9 100644 --- a/app/components/projects/table_component.rb +++ b/app/components/projects/table_component.rb @@ -67,11 +67,6 @@ def build_sort_header(column, options) helpers.projects_sort_header_tag(column, **options, param: :json) end - def build_new_sort_header(column, options) - helpers.projects_new_sort_header_tag(column, **options, param: :json) - end - - # We don't return the project row # but the [project, level] array from the helper def rows diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 053e7d10729e..09ce1b37516b 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -39,16 +39,6 @@ def projects_sort_header_tag(column, **) @sort_criteria.criteria.reject! { |a, _| a == "lft" } - sort_header_tag(column, **, allowed_params: projects_query_param_names_for_sort) - ensure - @sort_criteria.criteria = former_criteria - end - - def projects_new_sort_header_tag(column, **) - former_criteria = @sort_criteria.criteria.dup - - @sort_criteria.criteria.reject! { |a, _| a == "lft" } - sort_header_with_action_menu(column, **, allowed_params: projects_query_param_names_for_sort) ensure @sort_criteria.criteria = former_criteria diff --git a/app/helpers/sort_helper.rb b/app/helpers/sort_helper.rb index e583f0a10efb..6ee1b2f2cab1 100644 --- a/app/helpers/sort_helper.rb +++ b/app/helpers/sort_helper.rb @@ -314,21 +314,20 @@ def sort_link(column, caption, default_order, allowed_params: nil, **html_option # # def sort_header_tag(column, allowed_params: nil, **options) - caption = get_caption(column, options) - - default_order = options.delete(:default_order) || "asc" - lang = options.delete(:lang) || nil - param = options.delete(:param) || :sort - data = options.delete(:data) || {} - - options[:title] = sort_header_title(column, caption, options) - - within_sort_header_tag_hierarchy(options, sort_class(column)) do - sort_link(column, caption, default_order, allowed_params:, param:, lang:, title: options[:title], data:) + with_sort_header_options(column, allowed_params:, **options) do |col, cap, default_ord, **opts| + sort_link(col, cap, default_ord, **opts) end end def sort_header_with_action_menu(column, allowed_params: nil, **options) + with_sort_header_options(column, allowed_params:, **options) do |col, cap, default_ord, **opts| + action_menu(col, cap, default_ord, **opts) + end + end + + # Extracts the given `options` and provides them to a block. + # See #sort_header_tag and #sort_header_with_action_menu for usage examples. + def with_sort_header_options(column, allowed_params: nil, **options) caption = get_caption(column, options) default_order = options.delete(:default_order) || "asc" @@ -339,7 +338,7 @@ def sort_header_with_action_menu(column, allowed_params: nil, **options) options[:title] = sort_header_title(column, caption, options) within_sort_header_tag_hierarchy(options, sort_class(column)) do - action_menu(column, caption, default_order, allowed_params:, param:, lang:, title: options[:title], data:) + yield(column, caption, default_order, allowed_params:, param:, lang:, title: options[:title], data:) end end @@ -398,6 +397,9 @@ def menu_options(label:, content_args:, **extra_args) }.merge(extra_args) end + # Accepts a column and returns the corresponding filter name. + # For some columns, there is no such filter. The method returns nil for these cases. + # TODO: move project specific logic to a project specific file. def filter_conversion(column) col = column.to_s From ed75e7c2d37aac01c47915d99c26315ea977a177 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Thu, 10 Oct 2024 14:01:23 +0200 Subject: [PATCH 014/115] [#52147] further simplifications --- app/helpers/projects_helper.rb | 9 +++- app/helpers/sort_helper.rb | 81 +++++++++++++++++----------------- 2 files changed, 49 insertions(+), 41 deletions(-) diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 09ce1b37516b..7f9804d2816a 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -30,6 +30,12 @@ module ProjectsHelper include WorkPackagesFilterHelper PROJECTS_QUERY_PARAM_NAMES = %i[query_id filters columns sortBy per_page page].freeze + PROJECTS_FILTER_FOR_COLUMN_MAPPING = { + "identifier" => nil, + "name" => "id", + "project_status" => "project_status_code", + "required_disk_space" => nil + }.freeze # Just like sort_header tag but removes sorting by # lft from the sort criteria as lft is mutually exclusive with @@ -39,7 +45,8 @@ def projects_sort_header_tag(column, **) @sort_criteria.criteria.reject! { |a, _| a == "lft" } - sort_header_with_action_menu(column, **, allowed_params: projects_query_param_names_for_sort) + sort_header_with_action_menu(column, PROJECTS_FILTER_FOR_COLUMN_MAPPING, **, + allowed_params: projects_query_param_names_for_sort) ensure @sort_criteria.criteria = former_criteria end diff --git a/app/helpers/sort_helper.rb b/app/helpers/sort_helper.rb index 6ee1b2f2cab1..7f22fd5ee68c 100644 --- a/app/helpers/sort_helper.rb +++ b/app/helpers/sort_helper.rb @@ -266,6 +266,19 @@ def default_sort_order? @sort_default.criteria == @sort_criteria.criteria end + def build_sort_options(column, order, default_order, allowed_params: nil, **html_options) + order ||= order_string(column, inverted: true) || default_order + sort_by = html_options.delete(:param) + + sort_param = @sort_criteria.add(column.to_s, order).to_param(sort_by) + + sort_options = { sort_key(sort_by) => sort_param } + allowed_params ||= %w[filters per_page expand columns] + + # Don't lose other params. + safe_query_params(allowed_params).merge(sort_options) + end + # Returns a link which sorts by the named column. # # - column is the name of an attribute in the sorted record collection. @@ -273,20 +286,10 @@ def default_sort_order? # - 2 CSS classes reflect the state of the link: sort and asc or desc # def sort_link(column, caption, default_order, allowed_params: nil, **html_options) - order = order_string(column, inverted: true) || default_order caption ||= column.to_s.humanize - sort_by = html_options.delete(:param) - - sort_param = @sort_criteria.add(column.to_s, order).to_param(sort_by) - sort_key = sort_by == :json ? :sortBy : :sort - - sort_options = { sort_key => sort_param } - - allowed_params ||= %w[filters per_page expand columns] - - # Don't lose other params. - link_to_content_update(h(caption), safe_query_params(allowed_params).merge(sort_options), html_options.merge(rel: :nofollow)) + sort_options = build_sort_options(column, nil, default_order, allowed_params:, **html_options) + link_to_content_update(h(caption), sort_options, html_options.merge(rel: :nofollow)) end # Returns a table header tag with a sort link for the named column @@ -314,14 +317,19 @@ def sort_link(column, caption, default_order, allowed_params: nil, **html_option # # def sort_header_tag(column, allowed_params: nil, **options) - with_sort_header_options(column, allowed_params:, **options) do |col, cap, default_ord, **opts| - sort_link(col, cap, default_ord, **opts) + with_sort_header_options(column, allowed_params:, **options) do |col, cap, default_order, **opts| + sort_link(col, cap, default_order, **opts) end end - def sort_header_with_action_menu(column, allowed_params: nil, **options) - with_sort_header_options(column, allowed_params:, **options) do |col, cap, default_ord, **opts| - action_menu(col, cap, default_ord, **opts) + # Returns a clickable column header. When clicked, an action menu with multiple possible actions will + # pop up. These actions include sorting, reordering the columns, filtering, etc. + # + # This is a more specific version of #sort_header_tag. + # For "filter by" to work properly, you must pass a Hash for `filter_column_mapping`. + def sort_header_with_action_menu(column, filter_column_mapping = {}, allowed_params: nil, **options) + with_sort_header_options(column, allowed_params:, **options) do |col, cap, default_order, **opts| + action_menu(col, cap, default_order, filter_column_mapping, **opts) end end @@ -347,17 +355,7 @@ def sort_key(key) end def sort_by_options(column, order, default_order, allowed_params: nil, **html_options) - order ||= order_string(column, inverted: true) || default_order - - sort_by = html_options.delete(:param) - - sort_param = @sort_criteria.add(column.to_s, order).to_param(sort_by) - sort_key = sort_key(sort_by) - - sort_options = { sort_key => sort_param } - allowed_params ||= %w[filters per_page expand columns] - - safe_query_params(allowed_params).merge(sort_options) + build_sort_options(column, order, default_order, allowed_params:, **html_options) end # FIXME: copied from ConfigureViewModalComponent @@ -397,25 +395,28 @@ def menu_options(label:, content_args:, **extra_args) }.merge(extra_args) end - # Accepts a column and returns the corresponding filter name. - # For some columns, there is no such filter. The method returns nil for these cases. - # TODO: move project specific logic to a project specific file. - def filter_conversion(column) + # Tries to find the correct filter name for a column. + # + # Most columns play it safe and have their filter named just like them. This is the default. + # Some filters have a different name than the column. For these cases, the correct filter name for the column + # is read from the `filter_mapping`. + # As a special case, some columns do not have any filter at all. For these, the `filter_mapping` defines `nil` + # as filter name. + # + # @param column [Column] the column model that you would like to look up the filter name for + # @param filter_mapping [Hash{String => String, nil} column name to filter name (to nil if no filter) + # @return [String, nil] the correct filter name for the column. Returns nil if the column has no filter. + def find_filter_for_column(column, filter_mapping) col = column.to_s - { - "name" => "id", - "project_status" => "project_status_code", - "identifier" => nil, - "required_disk_space" => nil - }.fetch(col, col) + filter_mapping.fetch(col, col) end - def action_menu(column, caption, default_order, allowed_params: nil, **html_options) + def action_menu(column, caption, default_order, filter_column_mapping = {}, allowed_params: nil, **html_options) caption ||= column.to_s.humanize selected_columns = selected_columns_for_action_menu - filter = filter_conversion(column) + filter = find_filter_for_column(column, filter_column_mapping) # `param` is not needed in the `content_arguments`, but should remain in the `html_options`. # It is important for keeping the current state in the GET parameters of each link used in From 6c06cf54a51265429db8c370541d1e9bdaf510c7 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Fri, 11 Oct 2024 11:17:49 +0200 Subject: [PATCH 015/115] [#52147] use symbols for icons, doc comment --- app/helpers/sort_helper.rb | 69 +++++++++++++++++++------------------- 1 file changed, 34 insertions(+), 35 deletions(-) diff --git a/app/helpers/sort_helper.rb b/app/helpers/sort_helper.rb index 7f22fd5ee68c..e1d21db01b53 100644 --- a/app/helpers/sort_helper.rb +++ b/app/helpers/sort_helper.rb @@ -266,7 +266,7 @@ def default_sort_order? @sort_default.criteria == @sort_criteria.criteria end - def build_sort_options(column, order, default_order, allowed_params: nil, **html_options) + def sort_by_options(column, order, default_order, allowed_params: nil, **html_options) order ||= order_string(column, inverted: true) || default_order sort_by = html_options.delete(:param) @@ -288,7 +288,7 @@ def build_sort_options(column, order, default_order, allowed_params: nil, **html def sort_link(column, caption, default_order, allowed_params: nil, **html_options) caption ||= column.to_s.humanize - sort_options = build_sort_options(column, nil, default_order, allowed_params:, **html_options) + sort_options = sort_by_options(column, nil, default_order, allowed_params:, **html_options) link_to_content_update(h(caption), sort_options, html_options.merge(rel: :nofollow)) end @@ -354,10 +354,6 @@ def sort_key(key) key == :json ? :sortBy : :sort end - def sort_by_options(column, order, default_order, allowed_params: nil, **html_options) - build_sort_options(column, order, default_order, allowed_params:, **html_options) - end - # FIXME: copied from ConfigureViewModalComponent def selected_columns_for_action_menu @selected_columns ||= @query @@ -373,28 +369,6 @@ def build_columns_link(columns, allowed_params: nil, **html_options) projects_path(safe_query_params(allowed_params).merge(columns: columns.join(" "), sort_key => params[sort_key])) end - def shift_element(arr, str, direction = :left) - arr = arr.dup - index = arr.index(str) - return arr unless index - - step = direction == :left ? -1 : 1 - - new_index = index + step - return arr if new_index.negative? || new_index >= arr.size - - arr[index], arr[new_index] = arr[new_index], arr[index] - - arr - end - - def menu_options(label:, content_args:, **extra_args) - { - label:, - content_arguments: content_args.merge(title: label) - }.merge(extra_args) - end - # Tries to find the correct filter name for a column. # # Most columns play it safe and have their filter named just like them. This is the default. @@ -450,12 +424,12 @@ def sort_actions(menu, column, default_order, content_args:, allowed_params: nil menu.with_item(**menu_options(label: t(:label_sort_descending), content_args:, href: desc_sort_link)) do |item| - item.with_leading_visual_icon(icon: "sort-desc") + item.with_leading_visual_icon(icon: :"sort-desc") end menu.with_item(**menu_options(label: t(:label_sort_ascending), content_args:, href: asc_sort_link)) do |item| - item.with_leading_visual_icon(icon: "sort-asc") + item.with_leading_visual_icon(icon: :"sort-asc") end end @@ -468,7 +442,7 @@ def filter_action(menu, filter, content_args:) filter_name: filter } ))) do |item| - item.with_leading_visual_icon(icon: "filter") + item.with_leading_visual_icon(icon: :filter) end end @@ -483,12 +457,12 @@ def move_column_actions(menu, column, selected_columns, content_args:, allowed_p menu.with_item(**menu_options(label: t(:label_move_column_left), content_args:, href: shift_left_link)) do |item| - item.with_leading_visual_icon(icon: "op-columns-left") + item.with_leading_visual_icon(icon: :"op-columns-left") end menu.with_item(**menu_options(label: t(:label_move_column_right), content_args:, href: shift_right_link)) do |item| - item.with_leading_visual_icon(icon: "op-columns-right") + item.with_leading_visual_icon(icon: :"op-columns-right") end end @@ -503,17 +477,42 @@ def add_and_remove_column_actions(menu, column, selected_columns, content_args:, data: { controller: "async-dialog" } ), href: config_view_modal_link)) do |item| - item.with_leading_visual_icon(icon: "columns") + item.with_leading_visual_icon(icon: :columns) end menu.with_divider menu.with_item(**menu_options(label: t(:label_remove_column), content_args:, scheme: :danger, href: rm_column_link)) do |item| - item.with_leading_visual_icon(icon: "trash") + item.with_leading_visual_icon(icon: :trash) end end + # Searches for `item` in the given `array` and shifts the item + # one index to the left or right (depending on `direction`). + # Returns a copy of `array` with the shifted item order. + def shift_element(array, item, direction = :left) + array = array.dup + index = array.index(item) + return array unless index + + step = direction == :left ? -1 : 1 + + new_index = index + step + return array if new_index.negative? || new_index >= array.size + + array[index], array[new_index] = array[new_index], array[index] + + array + end + + def menu_options(label:, content_args:, **extra_args) + { + label:, + content_arguments: content_args.merge(title: label) + }.merge(extra_args) + end + def sort_class(column) order = order_string(column) From d3efcf3212ac953eca310dd78e4d75a295ff7844 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Mon, 14 Oct 2024 08:22:18 +0200 Subject: [PATCH 016/115] [#52147] remove unnecessary helper method --- app/components/projects/table_component.rb | 2 +- app/helpers/projects_helper.rb | 4 ++-- app/helpers/sort_helper.rb | 22 +++++++++------------- 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/app/components/projects/table_component.rb b/app/components/projects/table_component.rb index 751645cd2fb9..e82659385814 100644 --- a/app/components/projects/table_component.rb +++ b/app/components/projects/table_component.rb @@ -64,7 +64,7 @@ def container_class ## # The project sort by is handled differently def build_sort_header(column, options) - helpers.projects_sort_header_tag(column, **options, param: :json) + helpers.projects_sort_header_tag(column, query.selects.map(&:attribute), **options, param: :json) end # We don't return the project row diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 7f9804d2816a..e508d83b4b91 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -40,12 +40,12 @@ module ProjectsHelper # Just like sort_header tag but removes sorting by # lft from the sort criteria as lft is mutually exclusive with # the other criteria. - def projects_sort_header_tag(column, **) + def projects_sort_header_tag(column, all_column_attributes, **) former_criteria = @sort_criteria.criteria.dup @sort_criteria.criteria.reject! { |a, _| a == "lft" } - sort_header_with_action_menu(column, PROJECTS_FILTER_FOR_COLUMN_MAPPING, **, + sort_header_with_action_menu(column, all_column_attributes, PROJECTS_FILTER_FOR_COLUMN_MAPPING, **, allowed_params: projects_query_param_names_for_sort) ensure @sort_criteria.criteria = former_criteria diff --git a/app/helpers/sort_helper.rb b/app/helpers/sort_helper.rb index e1d21db01b53..dd673ca038dc 100644 --- a/app/helpers/sort_helper.rb +++ b/app/helpers/sort_helper.rb @@ -327,9 +327,9 @@ def sort_header_tag(column, allowed_params: nil, **options) # # This is a more specific version of #sort_header_tag. # For "filter by" to work properly, you must pass a Hash for `filter_column_mapping`. - def sort_header_with_action_menu(column, filter_column_mapping = {}, allowed_params: nil, **options) + def sort_header_with_action_menu(column, all_columns, filter_column_mapping = {}, allowed_params: nil, **options) with_sort_header_options(column, allowed_params:, **options) do |col, cap, default_order, **opts| - action_menu(col, cap, default_order, filter_column_mapping, **opts) + action_menu(col, all_columns, cap, default_order, filter_column_mapping, **opts) end end @@ -354,13 +354,6 @@ def sort_key(key) key == :json ? :sortBy : :sort end - # FIXME: copied from ConfigureViewModalComponent - def selected_columns_for_action_menu - @selected_columns ||= @query - .selects - .map(&:attribute) - end - def build_columns_link(columns, allowed_params: nil, **html_options) sort_by = html_options.delete(:param) sort_key = sort_key(sort_by) @@ -386,10 +379,13 @@ def find_filter_for_column(column, filter_mapping) filter_mapping.fetch(col, col) end - def action_menu(column, caption, default_order, filter_column_mapping = {}, allowed_params: nil, **html_options) + # Renders an ActionMenu for a specific column. The ActionMenu offers options such as sorting, moving a column to + # the left or right, filtering by the column (not available for all columns) or removing it. + # Some of the method arguments are only needed for specific actions. + def action_menu(column, table_columns, caption, default_order, filter_column_mapping = {}, + allowed_params: nil, **html_options) caption ||= column.to_s.humanize - selected_columns = selected_columns_for_action_menu filter = find_filter_for_column(column, filter_column_mapping) # `param` is not needed in the `content_arguments`, but should remain in the `html_options`. @@ -404,8 +400,8 @@ def action_menu(column, caption, default_order, filter_column_mapping = {}, allo # Some columns do not offer a filter. Only show the option when filtering is possible. filter_action(menu, filter, content_args:) if filter - move_column_actions(menu, column, selected_columns, content_args:, allowed_params:, **html_options) - add_and_remove_column_actions(menu, column, selected_columns, content_args:, allowed_params:, **html_options) + move_column_actions(menu, column, table_columns, content_args:, allowed_params:, **html_options) + add_and_remove_column_actions(menu, column, table_columns, content_args:, allowed_params:, **html_options) end end From ffeee35a2b1993edda52549315ed9b2717285354 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Mon, 14 Oct 2024 08:35:43 +0200 Subject: [PATCH 017/115] [#52147] fix cop errors --- app/helpers/sort_helper.rb | 5 ++++- .../controllers/dynamic/table-action-menu.controller.ts | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/helpers/sort_helper.rb b/app/helpers/sort_helper.rb index dd673ca038dc..c011d5d59c36 100644 --- a/app/helpers/sort_helper.rb +++ b/app/helpers/sort_helper.rb @@ -77,7 +77,9 @@ # - Introduces instance variables: @sort_default, @sort_criteria # - Introduces param :sort # - +# rubocop:disable Lint/RedundantCopDisableDirective, Rails/HelperInstanceVariable +# TODO: We should not use instance variables in our rails helpers. Since this is a bigger piece of work, for now +# we just disable the respective cop. Due to a bug, we must also disable the redundancy cop. module SortHelper class SortCriteria attr_reader :criteria @@ -554,3 +556,4 @@ def get_caption(column, options) caption end end +# rubocop:enable Rails/HelperInstanceVariable, Lint/RedundantCopDisableDirective diff --git a/frontend/src/stimulus/controllers/dynamic/table-action-menu.controller.ts b/frontend/src/stimulus/controllers/dynamic/table-action-menu.controller.ts index 00feed62859e..b05c79dec0c0 100644 --- a/frontend/src/stimulus/controllers/dynamic/table-action-menu.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/table-action-menu.controller.ts @@ -47,9 +47,9 @@ export default class TableActionMenuController extends Controller { } } - filterBy(event: Event) { + filterBy(event:Event) { // Sometimes the filterController was not loaded during `connect`, so we try again here: - if (!this.filterController) { this.findFilterController() } + if (!this.filterController) { this.findFilterController(); } const button = event.currentTarget as HTMLElement; From 1cf4c2b52425f637eb7081aff0524d78a1ec160a Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Mon, 14 Oct 2024 09:27:23 +0200 Subject: [PATCH 018/115] [#52147] repair sort helper specs --- app/helpers/sort_helper.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/helpers/sort_helper.rb b/app/helpers/sort_helper.rb index c011d5d59c36..bc7fe0078aba 100644 --- a/app/helpers/sort_helper.rb +++ b/app/helpers/sort_helper.rb @@ -291,6 +291,7 @@ def sort_link(column, caption, default_order, allowed_params: nil, **html_option caption ||= column.to_s.humanize sort_options = sort_by_options(column, nil, default_order, allowed_params:, **html_options) + html_options.delete(:param) # remove the `param` as we do not want it on our link-tag link_to_content_update(h(caption), sort_options, html_options.merge(rel: :nofollow)) end From 4d7f2f33dd403e7d03a82e3720e20e9ab68fa17e Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Mon, 14 Oct 2024 16:26:40 +0200 Subject: [PATCH 019/115] [#52147] helper specs, only move column if possible We only offer to move a column to the left when it's not the leftmost column already. Same for the right hand side. --- app/helpers/sort_helper.rb | 73 ++++++++++++----- spec/helpers/sort_helper_spec.rb | 134 +++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+), 19 deletions(-) diff --git a/app/helpers/sort_helper.rb b/app/helpers/sort_helper.rb index bc7fe0078aba..5ceb668827ab 100644 --- a/app/helpers/sort_helper.rb +++ b/app/helpers/sort_helper.rb @@ -401,7 +401,7 @@ def action_menu(column, table_columns, caption, default_order, filter_column_map sort_actions(menu, column, default_order, content_args:, allowed_params:, **html_options) # Some columns do not offer a filter. Only show the option when filtering is possible. - filter_action(menu, filter, content_args:) if filter + filter_action(menu, column, filter, content_args:) if filter move_column_actions(menu, column, table_columns, content_args:, allowed_params:, **html_options) add_and_remove_column_actions(menu, column, table_columns, content_args:, allowed_params:, **html_options) @@ -421,22 +421,31 @@ def sort_actions(menu, column, default_order, content_args:, allowed_params: nil asc_sort_link = projects_path(sort_by_options(column, "asc", default_order, allowed_params:, **html_options)) menu.with_item(**menu_options(label: t(:label_sort_descending), - content_args:, + content_args: content_args.merge( + data: { + "test-selector" => "#{column}-sort-desc" + } + ), href: desc_sort_link)) do |item| item.with_leading_visual_icon(icon: :"sort-desc") end menu.with_item(**menu_options(label: t(:label_sort_ascending), - content_args:, + content_args: content_args.merge( + data: { + "test-selector" => "#{column}-sort-asc" + } + ), href: asc_sort_link)) do |item| item.with_leading_visual_icon(icon: :"sort-asc") end end - def filter_action(menu, filter, content_args:) + def filter_action(menu, column, filter, content_args:) menu.with_divider menu.with_item(**menu_options(label: t(:label_filter_by), content_args: content_args.merge( data: { + "test-selector" => "#{column}-filter-by", action: "table-action-menu#filterBy", filter_name: filter } @@ -446,22 +455,41 @@ def filter_action(menu, filter, content_args:) end def move_column_actions(menu, column, selected_columns, content_args:, allowed_params: nil, **html_options) - left_shift = shift_element(selected_columns, column) - shift_left_link = build_columns_link(left_shift, allowed_params:, **html_options) - - right_shift = shift_element(selected_columns, column, :right) - shift_right_link = build_columns_link(right_shift, allowed_params:, **html_options) + column_pos = selected_columns.index(column) + return unless column_pos menu.with_divider - menu.with_item(**menu_options(label: t(:label_move_column_left), - content_args:, - href: shift_left_link)) do |item| - item.with_leading_visual_icon(icon: :"op-columns-left") + + # Only offer the option of moving the column left if it is not already the leftmost column. + if column_pos > 0 + left_shift = shift_element(selected_columns, column) + shift_left_link = build_columns_link(left_shift, allowed_params:, **html_options) + + menu.with_item(**menu_options(label: t(:label_move_column_left), + content_args: content_args.merge( + data: { + "test-selector" => "#{column}-move-col-left" + } + ), + href: shift_left_link)) do |item| + item.with_leading_visual_icon(icon: :"op-columns-left") + end end - menu.with_item(**menu_options(label: t(:label_move_column_right), - content_args:, - href: shift_right_link)) do |item| - item.with_leading_visual_icon(icon: :"op-columns-right") + + # Only offer the option of moving the column right if it is not already the rightmost column. + if column_pos < selected_columns.length - 1 + right_shift = shift_element(selected_columns, column, :right) + shift_right_link = build_columns_link(right_shift, allowed_params:, **html_options) + + menu.with_item(**menu_options(label: t(:label_move_column_right), + content_args: content_args.merge( + data: { + "test-selector" => "#{column}-move-col-right" + } + ), + href: shift_right_link)) do |item| + item.with_leading_visual_icon(icon: :"op-columns-right") + end end end @@ -473,14 +501,21 @@ def add_and_remove_column_actions(menu, column, selected_columns, content_args:, menu.with_item(**menu_options(label: t(:label_add_column), content_args: content_args.merge( - data: { controller: "async-dialog" } + data: { + controller: "async-dialog", + "test-selector" => "#{column}-add-column" + } ), href: config_view_modal_link)) do |item| item.with_leading_visual_icon(icon: :columns) end menu.with_divider menu.with_item(**menu_options(label: t(:label_remove_column), - content_args:, + content_args: content_args.merge( + data: { + "test-selector" => "#{column}-remove-column" + } + ), scheme: :danger, href: rm_column_link)) do |item| item.with_leading_visual_icon(icon: :trash) diff --git a/spec/helpers/sort_helper_spec.rb b/spec/helpers/sort_helper_spec.rb index 7d692a604028..5dc96b8c22a3 100644 --- a/spec/helpers/sort_helper_spec.rb +++ b/spec/helpers/sort_helper_spec.rb @@ -270,4 +270,138 @@ def session; @session ||= {}; end end end end + + describe "#sort_header_with_action_menu" do + subject(:output) do + helper.sort_header_with_action_menu("id", + %w[name id description], {}, **options) + end + + let(:options) { { param: :json } } + let(:sort_criteria) { SortHelper::SortCriteria.new } + + let(:action_menu) do + # The resulting HTML is too big to assert in detail. We will only check some key parts to ensure it is + # an action menu with the expected content. + Nokogiri::HTML(output).at_css("th .generic-table--sort-header action-menu") + end + + before do + # helper relies on this instance var + @sort_criteria = sort_criteria + + # fake having called '/projects' + allow(helper) + .to receive(:url_options) + .and_return(url_options.merge(controller: "projects", action: "index")) + end + + it "renders an action-menu button as column header" do + expect(action_menu.at_css("button#menu-id-button .Button-content .Button-label").text).to eq("Id") + end + + it "shows sorting actions in the action-menu" do + sort_desc = action_menu.at_css("action-list .ActionListItem a[data-test-selector='id-sort-desc']") + expect(sort_desc.at_css(".ActionListItem-label").text.strip).to eq("Sort descending") + expect(sort_desc["href"]).to eq("/projects?sortBy=%5B%5B%22id%22%2C%22desc%22%5D%5D") + + sort_asc = action_menu.at_css("action-list .ActionListItem a[data-test-selector='id-sort-asc']") + expect(sort_asc.at_css(".ActionListItem-label").text.strip).to eq("Sort ascending") + expect(sort_asc["href"]).to eq("/projects?sortBy=%5B%5B%22id%22%2C%22asc%22%5D%5D") + end + + it "shows an action to move columns left and right" do + move_left = action_menu.at_css("action-list .ActionListItem a[data-test-selector='id-move-col-left']") + expect(move_left.at_css(".ActionListItem-label").text.strip).to eq("Move column left") + # The id column moved one place to the left and is now the first column instead of the second. + expect(move_left["href"]).to eq("/projects?columns=id+name+description") + + move_right = action_menu.at_css("action-list .ActionListItem a[data-test-selector='id-move-col-right']") + expect(move_right.at_css(".ActionListItem-label").text.strip).to eq("Move column right") + # The id column moved one place to the right and is now the last one. + expect(move_right["href"]).to eq("/projects?columns=name+description+id") + end + + context "with the current column being the leftmost one" do + subject(:output) do + helper.sort_header_with_action_menu("id", + %w[id name description], {}, **options) + end + + it "does not offer a 'move left' option" do + move_left = action_menu.at_css("action-list .ActionListItem a[data-test-selector='id-move-col-left']") + expect(move_left).to be_nil + + # But it offers a 'move right' option + move_right = action_menu.at_css("action-list .ActionListItem a[data-test-selector='id-move-col-right']") + expect(move_right).not_to be_nil + end + end + + context "with the current column being the rightmost one" do + subject(:output) do + helper.sort_header_with_action_menu("id", + %w[name description id], {}, **options) + end + + it "does not offer a 'move right' option" do + move_right = action_menu.at_css("action-list .ActionListItem a[data-test-selector='id-move-col-right']") + expect(move_right).to be_nil + + # But it offers a 'move left' option + move_left = action_menu.at_css("action-list .ActionListItem a[data-test-selector='id-move-col-left']") + expect(move_left).not_to be_nil + end + end + + it "shows an action to add columns" do + add_col = action_menu.at_css("action-list .ActionListItem a[data-test-selector='id-add-column']") + expect(add_col.at_css(".ActionListItem-label").text.strip).to eq("Add column") + # Check that the 'ConfigureViewModal' is opened on link click. This is where adding columns happens. + expect(add_col["href"]).to eq("/project_queries/configure_view_modal") + end + + it "shows an action to remove a column" do + remove_col = action_menu.at_css("action-list .ActionListItem a[data-test-selector='id-remove-column']") + expect(remove_col.at_css(".ActionListItem-label").text.strip).to eq("Remove column") + # The current column is removed from the columns-query: + expect(remove_col["href"]).to eq("/projects?columns=name+description") + end + + it "shows a 'filter by' action" do + filter_by = action_menu.at_css("action-list .ActionListItem button[data-test-selector='id-filter-by']") + expect(filter_by.at_css(".ActionListItem-label").text.strip).to eq("Filter by") + # Check that the correct Stimulus controller with the correct data is referenced: + expect(filter_by["data-action"]).to eq("table-action-menu#filterBy") + expect(filter_by["data-filter-name"]).to eq("id") + end + + context "with a filter mapping for the column" do + subject(:output) do + helper.sort_header_with_action_menu("id", + %w[name id description], { "id" => "id_code" }, **options) + end + + it "shows a 'filter by' action with the mapped filter" do + filter_by = action_menu.at_css("action-list .ActionListItem button[data-test-selector='id-filter-by']") + expect(filter_by.at_css(".ActionListItem-label").text.strip).to eq("Filter by") + expect(filter_by["data-action"]).to eq("table-action-menu#filterBy") + # With a column mapping, the filter name is changed accordingly: + expect(filter_by["data-filter-name"]).to eq("id_code") + end + end + + context "with the filter mapping specifying there is no filter for the column" do + subject(:output) do + # With the filter name mapped to nil, we expect no filter action to be present. + helper.sort_header_with_action_menu("id", + %w[name id description], { "id" => nil }, **options) + end + + it "does not show a 'filter by' action" do + filter_by = action_menu.at_css("action-list .ActionListItem button[data-test-selector='id-filter-by']") + expect(filter_by).to be_nil + end + end + end end From b3f85a02e274d20486870cead2adf8bd44d3fc96 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Mon, 14 Oct 2024 16:53:31 +0200 Subject: [PATCH 020/115] [#52147] reduce complexity of column moving --- app/helpers/sort_helper.rb | 49 ++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/app/helpers/sort_helper.rb b/app/helpers/sort_helper.rb index 5ceb668827ab..72b0611f8772 100644 --- a/app/helpers/sort_helper.rb +++ b/app/helpers/sort_helper.rb @@ -460,36 +460,33 @@ def move_column_actions(menu, column, selected_columns, content_args:, allowed_p menu.with_divider - # Only offer the option of moving the column left if it is not already the leftmost column. + # Add left shift action if possible (i.e. current column is not the leftmost one) if column_pos > 0 - left_shift = shift_element(selected_columns, column) - shift_left_link = build_columns_link(left_shift, allowed_params:, **html_options) - - menu.with_item(**menu_options(label: t(:label_move_column_left), - content_args: content_args.merge( - data: { - "test-selector" => "#{column}-move-col-left" - } - ), - href: shift_left_link)) do |item| - item.with_leading_visual_icon(icon: :"op-columns-left") - end + add_shift_action(menu, column, selected_columns, content_args, allowed_params, html_options, direction: :left) end - # Only offer the option of moving the column right if it is not already the rightmost column. + # Add right shift action if possible (i.e. current column is not the rightmost one) if column_pos < selected_columns.length - 1 - right_shift = shift_element(selected_columns, column, :right) - shift_right_link = build_columns_link(right_shift, allowed_params:, **html_options) - - menu.with_item(**menu_options(label: t(:label_move_column_right), - content_args: content_args.merge( - data: { - "test-selector" => "#{column}-move-col-right" - } - ), - href: shift_right_link)) do |item| - item.with_leading_visual_icon(icon: :"op-columns-right") - end + add_shift_action(menu, column, selected_columns, content_args, allowed_params, html_options, direction: :right) + end + end + + def add_shift_action(menu, column, selected_columns, content_args, allowed_params, html_options, direction:) + icon = direction == :left ? :"op-columns-left" : :"op-columns-right" + label_key = direction == :left ? :label_move_column_left : :label_move_column_right + test_selector = direction == :left ? "#{column}-move-col-left" : "#{column}-move-col-right" + + shifted_columns = shift_element(selected_columns, column, direction == :right ? :right : :left) + shift_link = build_columns_link(shifted_columns, allowed_params:, **html_options) + + menu.with_item(**menu_options(label: t(label_key), + content_args: content_args.merge( + data: { + "test-selector" => test_selector + } + ), + href: shift_link)) do |item| + item.with_leading_visual_icon(icon:) end end From 6557fb0fd447df9e348f18dca77029696ac3e760 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Tue, 15 Oct 2024 16:34:50 +0200 Subject: [PATCH 021/115] [#52147] make feature specs green --- .../features/projects/persisted_lists_spec.rb | 6 +++-- spec/features/projects/projects_index_spec.rb | 24 ++++++++++++------- spec/support/pages/projects/index.rb | 12 +++++++--- 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/spec/features/projects/persisted_lists_spec.rb b/spec/features/projects/persisted_lists_spec.rb index dcf5574875c7..233304fe7f9c 100644 --- a/spec/features/projects/persisted_lists_spec.rb +++ b/spec/features/projects/persisted_lists_spec.rb @@ -457,7 +457,8 @@ projects_page.expect_no_sidebar_filter(another_users_projects_list.name) # Sorts ASC by name - projects_page.sort_by_via_table_header("Name") + projects_page.click_table_header_to_open_action_menu("Name") + projects_page.sort_via_action_menu("Name", direction: :asc) # Results should be filtered and ordered ASC by name and the user is still on the first page. # Column is kept. @@ -483,7 +484,8 @@ # Sorts DESC by name # Soon, a save icon should be displayed then. - projects_page.sort_by_via_table_header("Name") + projects_page.click_table_header_to_open_action_menu("Name") + projects_page.sort_via_action_menu("Name", direction: :desc) # The title is kept projects_page.expect_title(my_projects_list.name) diff --git a/spec/features/projects/projects_index_spec.rb b/spec/features/projects/projects_index_spec.rb index 886d00deb450..b0bbfa035cb7 100644 --- a/spec/features/projects/projects_index_spec.rb +++ b/spec/features/projects/projects_index_spec.rb @@ -427,7 +427,8 @@ def load_and_open_filters(user) projects_page.expect_no_columns("Status") # Sorts ASC by name - projects_page.sort_by_via_table_header("Name") + projects_page.click_table_header_to_open_action_menu("Name") + projects_page.sort_via_action_menu("Name", direction: :asc) wait_for_reload projects_page.expect_sort_order_via_table_header("Name", direction: :asc) @@ -461,7 +462,8 @@ def load_and_open_filters(user) projects_page.expect_total_pages(2) # Filters kept active, so there is no third page. # Sorts DESC by name - projects_page.sort_by_via_table_header("Name") + projects_page.click_table_header_to_open_action_menu("Name") + projects_page.sort_via_action_menu("Name", direction: :desc) wait_for_reload projects_page.expect_sort_order_via_table_header("Name", direction: :desc) @@ -646,12 +648,14 @@ def load_and_open_filters(user) login_as(admin) projects_page.visit! - click_link_or_button('Sort by "Status"') + projects_page.click_table_header_to_open_action_menu("project_status") + projects_page.sort_via_action_menu("project_status", direction: :asc) projects_page.expect_project_at_place(green_project, 1) expect(page).to have_text("(1 - 5/5)") - click_link_or_button('Ascending sorted by "Status"') + projects_page.click_table_header_to_open_action_menu("project_status") + projects_page.sort_via_action_menu("project_status", direction: :desc) projects_page.expect_project_at_place(green_project, 5) expect(page).to have_text("(1 - 5/5)") @@ -1254,7 +1258,8 @@ def load_and_open_filters(user) child_project_z, public_project) - click_link_or_button("Name") + projects_page.click_table_header_to_open_action_menu("Name") + projects_page.sort_via_action_menu("Name", direction: :asc) wait_for_reload # Projects ordered by name asc @@ -1266,7 +1271,8 @@ def load_and_open_filters(user) public_project, child_project_z) - click_link_or_button("Name") + projects_page.click_table_header_to_open_action_menu("Name") + projects_page.sort_via_action_menu("Name", direction: :desc) wait_for_reload # Projects ordered by name desc @@ -1278,7 +1284,8 @@ def load_and_open_filters(user) development_project, child_project_a) - click_link_or_button(integer_custom_field.name) + projects_page.click_table_header_to_open_action_menu(integer_custom_field.column_name) + projects_page.sort_via_action_menu(integer_custom_field.column_name, direction: :asc) wait_for_reload # Projects ordered by cf asc first then project name desc @@ -1304,7 +1311,8 @@ def load_and_open_filters(user) end it "sorts projects by latest_activity_at" do - click_link_or_button('Sort by "Latest activity at"') + projects_page.click_table_header_to_open_action_menu("latest_activity_at") + projects_page.sort_via_action_menu("latest_activity_at", direction: :asc) wait_for_reload projects_page.expect_project_at_place(project, 1) diff --git a/spec/support/pages/projects/index.rb b/spec/support/pages/projects/index.rb index d83f6f04ac43..465a71cb2490 100644 --- a/spec/support/pages/projects/index.rb +++ b/spec/support/pages/projects/index.rb @@ -472,14 +472,20 @@ def delete_query end end - def sort_by_via_table_header(column_name) - find(".generic-table--sort-header a", text: column_name.upcase).click + def click_table_header_to_open_action_menu(column_name) + find(".generic-table--sort-header #menu-#{column_name.downcase}-button").click + end + + def sort_via_action_menu(column_name, direction:) + raise ArgumentError, "direction should be :asc or :desc" unless %i[asc desc].include?(direction) + + find(".generic-table--sort-header a[data-test-selector='#{column_name.downcase}-sort-#{direction}']").click end def expect_sort_order_via_table_header(column_name, direction:) raise ArgumentError, "direction should be :asc or :desc" unless %i[asc desc].include?(direction) - find(".generic-table--sort-header .#{direction} a", text: column_name.upcase) + find(".generic-table--sort-header .#{direction} .Button-label", text: column_name.upcase) end def set_page_size(size) From 9c26b601f2a486527a9a937db82a167cbe4a6c7a Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Wed, 16 Oct 2024 11:26:39 +0200 Subject: [PATCH 022/115] [#52147] add feature specs for action menu --- spec/features/projects/projects_index_spec.rb | 119 ++++++++++++++++++ spec/support/pages/projects/index.rb | 32 +++++ 2 files changed, 151 insertions(+) diff --git a/spec/features/projects/projects_index_spec.rb b/spec/features/projects/projects_index_spec.rb index b0bbfa035cb7..742d23cca189 100644 --- a/spec/features/projects/projects_index_spec.rb +++ b/spec/features/projects/projects_index_spec.rb @@ -1484,4 +1484,123 @@ def load_and_open_filters(user) projects_page.expect_columns("Name", "Created on", "Status") end end + + context "when using the action menu", with_settings: { enabled_projects_columns: %w[created_at name project_status] } do + before do + login_as(admin) + visit projects_path + end + + describe "moving a column" do + it "moves the selected column one place to the left and right" do + projects_page.expect_columns_in_order("Created on", "Name", "Status") + + # Move "Name" column to the left + projects_page.click_table_header_to_open_action_menu("Name") + projects_page.move_column_via_action_menu("Name", direction: :left) + wait_for_reload + + # Name was moved left? + projects_page.expect_columns_in_order("Name", "Created on", "Status") + + # Now move it back to the right once + projects_page.click_table_header_to_open_action_menu("Name") + projects_page.move_column_via_action_menu("Name", direction: :right) + wait_for_reload + + # Original position should have been restored + projects_page.expect_columns_in_order("Created on", "Name", "Status") + + # Looking at the leftmost column + projects_page.click_table_header_to_open_action_menu("created_at") + projects_page.within("#menu-created_at-overlay") do + # It should allow us to move the column right + expect(page) + .to have_css("a[data-test-selector='created_at-move-col-right']", text: I18n.t(:label_move_column_right)) + + # It should not allow us to move the column further left + expect(page) + .to have_no_css("a[data-test-selector='created_at-move-col-left']", text: I18n.t(:label_move_column_left)) + end + + # Looking at the rightmost column + projects_page.click_table_header_to_open_action_menu("project_status") + projects_page.within("#menu-project_status-overlay") do + # It should allow us to move the column further left + expect(page) + .to have_css("a[data-test-selector='project_status-move-col-left']", text: I18n.t(:label_move_column_left)) + + # It should not allow us to move the column right + expect(page) + .to have_no_css("a[data-test-selector='project_status-move-col-right']", text: I18n.t(:label_move_column_right)) + end + end + end + + describe "removing a column" do + it "removes the column from the table view" do + projects_page.expect_columns_in_order("Created on", "Name", "Status") + + # Remove "Name" column + projects_page.click_table_header_to_open_action_menu("Name") + projects_page.remove_column_via_action_menu("Name") + wait_for_reload + + # Name was removed + projects_page.expect_columns_in_order("Created on", "Status") + + # Remove "Status" column, too + projects_page.click_table_header_to_open_action_menu("project_status") + projects_page.remove_column_via_action_menu("project_status") + wait_for_reload + + # It was removed + projects_page.expect_columns_in_order("Created on") + end + end + + describe "adding a column" do + it "opens the configure view dialog" do + projects_page.click_table_header_to_open_action_menu("Name") + projects_page.click_add_column_in_action_menu("Name") + + # Configure view dialog was opened + expect(page).to have_css("#op-project-list-configure-dialog") + end + end + + describe "filtering by column", + with_settings: { enabled_projects_columns: %w[created_at identifier project_status] } do + it "adds the filter for a selected column" do + projects_page.click_table_header_to_open_action_menu("created_at") + projects_page.expect_filter_option_in_action_menu("created_at") + projects_page.filter_by_column_via_action_menu("created_at") + + # Filter component is visible + expect(page).to have_select("add_filter_select") + # Filter for column is visible and can now be specified by the user + expect(page).to have_css(".advanced-filters--filter-name[for='created_at']") + end + + it "adds the filter for a selected column that has a different filter mapped to its column" do + projects_page.click_table_header_to_open_action_menu("project_status") + projects_page.expect_filter_option_in_action_menu("project_status") + projects_page.filter_by_column_via_action_menu("project_status") + + # Filter component is visible + expect(page).to have_select("add_filter_select") + # Filter for column is visible. Note that the filter name is different from the column attribute! + expect(page).to have_css(".advanced-filters--filter-name[for='project_status_code']") + end + + it "does not offer to filter if the column has no associated filter" do + # There is no filter mapping for the identifier column: we should not get the option to filter by it + projects_page.click_table_header_to_open_action_menu("identifier") + projects_page.expect_no_filter_option_in_action_menu("identifier") + + # Filters have not been activated and are therefore not visible + expect(page).to have_no_select("add_filter_select") + end + end + end end diff --git a/spec/support/pages/projects/index.rb b/spec/support/pages/projects/index.rb index 465a71cb2490..925a4beef8bb 100644 --- a/spec/support/pages/projects/index.rb +++ b/spec/support/pages/projects/index.rb @@ -198,6 +198,11 @@ def expect_columns(*column_names) end end + def expect_columns_in_order(*column_names) + columns = page.find_all("#project-table th .Button-label") + expect(column_names.map(&:upcase)).to eq(columns.map { |c| c.text.upcase }) + end + def expect_no_columns(*column_names) column_names.each do |column_name| expect(page).to have_no_css("th", text: column_name.upcase) @@ -482,6 +487,33 @@ def sort_via_action_menu(column_name, direction:) find(".generic-table--sort-header a[data-test-selector='#{column_name.downcase}-sort-#{direction}']").click end + def move_column_via_action_menu(column_name, direction:) + raise ArgumentError, "direction should be :left or :right" unless %i[left right].include?(direction) + + find(".generic-table--sort-header a[data-test-selector='#{column_name.downcase}-move-col-#{direction}']").click + end + + def remove_column_via_action_menu(column_name) + find(".generic-table--sort-header a[data-test-selector='#{column_name.downcase}-remove-column']").click + end + + def click_add_column_in_action_menu(column_name) + find(".generic-table--sort-header a[data-test-selector='#{column_name.downcase}-add-column']").click + end + + def expect_filter_option_in_action_menu(column_name) + expect(page).to have_css("[data-test-selector='#{column_name.downcase}-filter-by']", + text: I18n.t(:label_filter_by)) + end + + def expect_no_filter_option_in_action_menu(column_name) + expect(page).to have_no_css("[data-test-selector='#{column_name.downcase}-filter-by']") + end + + def filter_by_column_via_action_menu(column_name) + page.find("[data-test-selector='#{column_name.downcase}-filter-by']", text: I18n.t(:label_filter_by)).click + end + def expect_sort_order_via_table_header(column_name, direction:) raise ArgumentError, "direction should be :asc or :desc" unless %i[asc desc].include?(direction) From cad54f8d53ba2b312935a7080d1038972f6c23c0 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Thu, 17 Oct 2024 11:15:27 +0200 Subject: [PATCH 023/115] [#52147] ensure columns are always readable --- app/helpers/sort_helper.rb | 3 ++- frontend/src/global_styles/primer/_overrides.sass | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/helpers/sort_helper.rb b/app/helpers/sort_helper.rb index 72b0611f8772..fff342606e95 100644 --- a/app/helpers/sort_helper.rb +++ b/app/helpers/sort_helper.rb @@ -410,7 +410,8 @@ def action_menu(column, table_columns, caption, default_order, filter_column_map def action_button(menu, caption) menu.with_show_button(scheme: :link, color: :default, text_transform: :uppercase, - underline: false, display: :inline_flex) do |button| + underline: false, display: :inline_flex, + classes: "generic-table--action-menu-button") do |button| button.with_trailing_action_icon(icon: :"triangle-down") h(caption).to_s end diff --git a/frontend/src/global_styles/primer/_overrides.sass b/frontend/src/global_styles/primer/_overrides.sass index 7fe48e178abf..1311f30db0f0 100644 --- a/frontend/src/global_styles/primer/_overrides.sass +++ b/frontend/src/global_styles/primer/_overrides.sass @@ -98,3 +98,9 @@ sub-header, // Todo: Remove once https://github.com/primer/view_components/pull/3087 is merged .FormControl-spacingWrapper row-gap: var(--stack-gap-normal) + +// When our generic table uses the quick action menu, column headers are cut off and no longer fully readable. +// This fix ensures that column headers are always displayed. An example for such a table is the project list. +.generic-table + .generic-table--action-menu-button + min-width: max-content !important From 85fa8e3f0a6d3b6736b037a5f33142476ddbf809 Mon Sep 17 00:00:00 2001 From: Eric Guo Date: Fri, 11 Oct 2024 17:08:55 +0800 Subject: [PATCH 024/115] Bump secure_headers version --- Gemfile | 2 +- Gemfile.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile b/Gemfile index 5e3fb6ba9ed5..568b576fe00e 100644 --- a/Gemfile +++ b/Gemfile @@ -137,7 +137,7 @@ gem "rack-protection", "~> 3.2.0" gem "rack-attack", "~> 6.7.0" # CSP headers -gem "secure_headers", "~> 6.5.0" +gem "secure_headers", "~> 6.7.0" # Browser detection for incompatibility checks gem "browser", "~> 6.0.0" diff --git a/Gemfile.lock b/Gemfile.lock index f082e37caf41..2d131ba722fd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1057,7 +1057,7 @@ GEM sanitize (6.1.3) crass (~> 1.0.2) nokogiri (>= 1.12.0) - secure_headers (6.5.0) + secure_headers (6.7.0) selenium-devtools (0.129.0) selenium-webdriver (~> 4.2) selenium-webdriver (4.25.0) @@ -1365,7 +1365,7 @@ DEPENDENCIES ruby-progressbar (~> 1.13.0) rubytree (~> 2.1.0) sanitize (~> 6.1.0) - secure_headers (~> 6.5.0) + secure_headers (~> 6.7.0) selenium-devtools selenium-webdriver (~> 4.20) semantic (~> 1.6.1) From e60e98d2c240555c59da830bf9828e4474f69461 Mon Sep 17 00:00:00 2001 From: Eric Guo Date: Fri, 11 Oct 2024 23:54:15 +0800 Subject: [PATCH 025/115] Fix spec due to https://github.com/github/secure_headers/pull/506 --- spec/support/shared/with_direct_uploads.rb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/spec/support/shared/with_direct_uploads.rb b/spec/support/shared/with_direct_uploads.rb index c3ae4fd5e98d..bbe9c1e8a222 100644 --- a/spec/support/shared/with_direct_uploads.rb +++ b/spec/support/shared/with_direct_uploads.rb @@ -65,17 +65,17 @@ def around(example) csp_config = SecureHeaders::Configuration.instance_variable_get(:@default_config).csp - connect_src = csp_config.connect_src.dup - form_action = csp_config.form_action.dup + connect_src = csp_config[:connect_src].dup + form_action = csp_config[:form_action].dup begin - csp_config.connect_src << "test-bucket.s3.amazonaws.com" - csp_config.form_action << "test-bucket.s3.amazonaws.com" + csp_config[:connect_src] << "test-bucket.s3.amazonaws.com" + csp_config[:form_action] << "test-bucket.s3.amazonaws.com" example.run ensure - csp_config.connect_src = connect_src - csp_config.form_action = form_action + csp_config[:connect_src] = connect_src + csp_config[:form_action] = form_action end end From 7c0f8c4ec6c7076a1d4a2818f8a38c1d93c44aa7 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Thu, 17 Oct 2024 15:05:49 +0200 Subject: [PATCH 026/115] [#52147] show the quick action menu for non-sortable columns Since you can do more things, such as (re)moving a column, it would be a shame to not offer these options. For non-sortable columns, we will just not offer the sort-action. --- app/components/projects/table_component.html.erb | 12 +----------- app/components/projects/table_component.rb | 7 +++++-- app/helpers/projects_helper.rb | 4 +++- app/helpers/sort_helper.rb | 13 +++++++------ spec/features/projects/projects_index_spec.rb | 10 ++++++++++ spec/support/pages/projects/index.rb | 5 +++++ 6 files changed, 31 insertions(+), 20 deletions(-) diff --git a/app/components/projects/table_component.html.erb b/app/components/projects/table_component.html.erb index 5e16a55c21de..8386cc0afb61 100644 --- a/app/components/projects/table_component.html.erb +++ b/app/components/projects/table_component.html.erb @@ -54,8 +54,6 @@ See COPYRIGHT and LICENSE files for more details.
- <% elsif sortable_column?(column) %> - <%= build_sort_header column.attribute, order_options(column, turbo: true) %> <% elsif column.attribute == :favored %>
@@ -67,15 +65,7 @@ See COPYRIGHT and LICENSE files for more details.
<% else %> - -
-
- - <%= column.caption %> - -
-
- + <%= quick_action_table_header column.attribute, order_options(column, turbo: true) %> <% end %> <% end %> diff --git a/app/components/projects/table_component.rb b/app/components/projects/table_component.rb index e82659385814..dfd0f4d3fba6 100644 --- a/app/components/projects/table_component.rb +++ b/app/components/projects/table_component.rb @@ -63,7 +63,7 @@ def container_class ## # The project sort by is handled differently - def build_sort_header(column, options) + def quick_action_table_header(column, options) helpers.projects_sort_header_tag(column, query.selects.map(&:attribute), **options, param: :json) end @@ -119,7 +119,10 @@ def href_only_when_not_sort_lft end def order_options(select, turbo: false) - options = { caption: select.caption } + options = { + caption: select.caption, + sortable: sortable_column?(select) + } if turbo options[:data] = { "turbo-stream": true } diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index e508d83b4b91..c01a2c1bc15c 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -31,10 +31,12 @@ module ProjectsHelper PROJECTS_QUERY_PARAM_NAMES = %i[query_id filters columns sortBy per_page page].freeze PROJECTS_FILTER_FOR_COLUMN_MAPPING = { + "description" => nil, "identifier" => nil, "name" => "id", "project_status" => "project_status_code", - "required_disk_space" => nil + "required_disk_space" => nil, + "status_explanation" => nil }.freeze # Just like sort_header tag but removes sorting by diff --git a/app/helpers/sort_helper.rb b/app/helpers/sort_helper.rb index fff342606e95..1d195b4c13d5 100644 --- a/app/helpers/sort_helper.rb +++ b/app/helpers/sort_helper.rb @@ -349,7 +349,8 @@ def with_sort_header_options(column, allowed_params: nil, **options) options[:title] = sort_header_title(column, caption, options) within_sort_header_tag_hierarchy(options, sort_class(column)) do - yield(column, caption, default_order, allowed_params:, param:, lang:, title: options[:title], data:) + yield(column, caption, default_order, allowed_params:, param:, lang:, title: options[:title], + sortable: options.fetch(:sortable, false), data:) end end @@ -390,6 +391,7 @@ def action_menu(column, table_columns, caption, default_order, filter_column_map caption ||= column.to_s.humanize filter = find_filter_for_column(column, filter_column_mapping) + sortable = html_options.delete(:sortable) # `param` is not needed in the `content_arguments`, but should remain in the `html_options`. # It is important for keeping the current state in the GET parameters of each link used in @@ -398,9 +400,9 @@ def action_menu(column, table_columns, caption, default_order, filter_column_map render Primer::Alpha::ActionMenu.new(menu_id: "menu-#{column}") do |menu| action_button(menu, caption) - sort_actions(menu, column, default_order, content_args:, allowed_params:, **html_options) - # Some columns do not offer a filter. Only show the option when filtering is possible. + # Some columns are not sortable or do not offer a suitable filter. Omit those actions for them. + sort_actions(menu, column, default_order, content_args:, allowed_params:, **html_options) if sortable filter_action(menu, column, filter, content_args:) if filter move_column_actions(menu, column, table_columns, content_args:, allowed_params:, **html_options) @@ -439,10 +441,10 @@ def sort_actions(menu, column, default_order, content_args:, allowed_params: nil href: asc_sort_link)) do |item| item.with_leading_visual_icon(icon: :"sort-asc") end + menu.with_divider end def filter_action(menu, column, filter, content_args:) - menu.with_divider menu.with_item(**menu_options(label: t(:label_filter_by), content_args: content_args.merge( data: { @@ -453,14 +455,13 @@ def filter_action(menu, column, filter, content_args:) ))) do |item| item.with_leading_visual_icon(icon: :filter) end + menu.with_divider end def move_column_actions(menu, column, selected_columns, content_args:, allowed_params: nil, **html_options) column_pos = selected_columns.index(column) return unless column_pos - menu.with_divider - # Add left shift action if possible (i.e. current column is not the leftmost one) if column_pos > 0 add_shift_action(menu, column, selected_columns, content_args, allowed_params, html_options, direction: :left) diff --git a/spec/features/projects/projects_index_spec.rb b/spec/features/projects/projects_index_spec.rb index 742d23cca189..18179dd78f44 100644 --- a/spec/features/projects/projects_index_spec.rb +++ b/spec/features/projects/projects_index_spec.rb @@ -1537,6 +1537,16 @@ def load_and_open_filters(user) end end + describe "sorting a column", + with_settings: { enabled_projects_columns: %w[created_at name project_status description] } do + it "does not offer the sorting options for columns that are not sortable" do + projects_page.expect_columns_in_order("Created on", "Name", "Status", "Description") + + projects_page.click_table_header_to_open_action_menu("Description") + projects_page.expect_no_sorting_option_in_action_menu("Description") + end + end + describe "removing a column" do it "removes the column from the table view" do projects_page.expect_columns_in_order("Created on", "Name", "Status") diff --git a/spec/support/pages/projects/index.rb b/spec/support/pages/projects/index.rb index 925a4beef8bb..480bc3cbfdd5 100644 --- a/spec/support/pages/projects/index.rb +++ b/spec/support/pages/projects/index.rb @@ -487,6 +487,11 @@ def sort_via_action_menu(column_name, direction:) find(".generic-table--sort-header a[data-test-selector='#{column_name.downcase}-sort-#{direction}']").click end + def expect_no_sorting_option_in_action_menu(column_name) + expect(page).to have_no_css("[data-test-selector='#{column_name.downcase}-sort-asc']") + expect(page).to have_no_css("[data-test-selector='#{column_name.downcase}-sort-desc']") + end + def move_column_via_action_menu(column_name, direction:) raise ArgumentError, "direction should be :left or :right" unless %i[left right].include?(direction) From 52713081d7dee5a4ea2cb4fa597890d9a7b91c10 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Thu, 17 Oct 2024 15:12:04 +0200 Subject: [PATCH 027/115] [#52147] provide sort helper spec --- spec/helpers/sort_helper_spec.rb | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/spec/helpers/sort_helper_spec.rb b/spec/helpers/sort_helper_spec.rb index 5dc96b8c22a3..b0afa4fd80d4 100644 --- a/spec/helpers/sort_helper_spec.rb +++ b/spec/helpers/sort_helper_spec.rb @@ -277,7 +277,7 @@ def session; @session ||= {}; end %w[name id description], {}, **options) end - let(:options) { { param: :json } } + let(:options) { { param: :json, sortable: true } } let(:sort_criteria) { SortHelper::SortCriteria.new } let(:action_menu) do @@ -310,6 +310,16 @@ def session; @session ||= {}; end expect(sort_asc["href"]).to eq("/projects?sortBy=%5B%5B%22id%22%2C%22asc%22%5D%5D") end + context "with a column that is not sortable" do + let(:options) { { param: :json, sortable: false } } + + it "does not show the sorting actions in the action-menu" do + expect(action_menu.at_css("action-list .ActionListItem a[data-test-selector='id-sort-desc']")).to be_nil + + expect(action_menu.at_css("action-list .ActionListItem a[data-test-selector='id-sort-asc']")).to be_nil + end + end + it "shows an action to move columns left and right" do move_left = action_menu.at_css("action-list .ActionListItem a[data-test-selector='id-move-col-left']") expect(move_left.at_css(".ActionListItem-label").text.strip).to eq("Move column left") From 63fbdbdb049ce1394600ca727d01558c89ed7043 Mon Sep 17 00:00:00 2001 From: Eric Schubert Date: Thu, 17 Oct 2024 15:30:35 +0200 Subject: [PATCH 028/115] [#57817] replaced record destroy with persistence service - updated action menu item construction method --- .../custom_fields/hierarchy/item_component.html.erb | 3 +-- .../admin/custom_fields/hierarchy/item_component.rb | 2 +- .../admin/custom_fields/hierarchy/items_controller.rb | 11 ++++++++--- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/app/components/admin/custom_fields/hierarchy/item_component.html.erb b/app/components/admin/custom_fields/hierarchy/item_component.html.erb index 5ff3bf6cf6a1..a1f989219537 100644 --- a/app/components/admin/custom_fields/hierarchy/item_component.html.erb +++ b/app/components/admin/custom_fields/hierarchy/item_component.html.erb @@ -50,10 +50,9 @@ See COPYRIGHT and LICENSE files for more details. menu.with_show_button(icon: "kebab-horizontal", scheme: :invisible, "aria-label": I18n.t("custom_fields.admin.items.actions")) - delete_item(menu) + deletion_action_item(menu) end end end end - %> diff --git a/app/components/admin/custom_fields/hierarchy/item_component.rb b/app/components/admin/custom_fields/hierarchy/item_component.rb index c9f06cb11dd2..2a22a4b7d537 100644 --- a/app/components/admin/custom_fields/hierarchy/item_component.rb +++ b/app/components/admin/custom_fields/hierarchy/item_component.rb @@ -45,7 +45,7 @@ def short_text "(#{@hierarchy_item.short})" end - def delete_item(menu) + def deletion_action_item(menu) menu.with_item(label: I18n.t(:button_delete), scheme: :danger, tag: :a, diff --git a/app/controllers/admin/custom_fields/hierarchy/items_controller.rb b/app/controllers/admin/custom_fields/hierarchy/items_controller.rb index b9dbaf383d3a..542b2c80563d 100644 --- a/app/controllers/admin/custom_fields/hierarchy/items_controller.rb +++ b/app/controllers/admin/custom_fields/hierarchy/items_controller.rb @@ -66,9 +66,14 @@ def create end def destroy - # TODO: user persistence service - @hierarchy_item.destroy - update_via_turbo_stream(component: ItemsComponent.new(custom_field: @custom_field)) + ::CustomFields::Hierarchy::HierarchicalItemService + .new + .delete_branch(item: @hierarchy_item) + .either( + ->(_) { update_via_turbo_stream(component: ItemsComponent.new(custom_field: @custom_field)) }, + ->(errors) { update_flash_message_via_turbo_stream(message: errors.full_messages, scheme: :danger) } + ) + respond_with_turbo_streams end From a8fc3183a46627fe0c353de0c6f71c894eddf00e Mon Sep 17 00:00:00 2001 From: ulferts Date: Thu, 17 Oct 2024 15:42:04 +0200 Subject: [PATCH 029/115] spec existing behaviour of migration --- .../add_validity_period_to_journals_spec.rb | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 spec/migrations/add_validity_period_to_journals_spec.rb diff --git a/spec/migrations/add_validity_period_to_journals_spec.rb b/spec/migrations/add_validity_period_to_journals_spec.rb new file mode 100644 index 000000000000..4478cc197851 --- /dev/null +++ b/spec/migrations/add_validity_period_to_journals_spec.rb @@ -0,0 +1,184 @@ +#-- 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 Rails.root.join("db/migrate/20230608151123_add_validity_period_to_journals.rb") + +RSpec.describe AddValidityPeriodToJournals, type: :model do + # Silencing migration logs, since we are not interested in that during testing + subject do + ActiveRecord::Migration.suppress_messages do + described_class + .new + .tap { _1.migrate(:down) } + .tap { _1.migrate(:up) } + end + end + + let(:zero_time) { 30.minutes.ago } + let(:initial_journal_time) { zero_time } # Created with the work package -> No conflicts + let(:second_journal_time) { zero_time + 1.minute } # Created one minute later -> Conflicts with 3 + let(:third_journal_time) { zero_time + 1.minute } # Created one minute later -> Conflicts with 2 + let(:fourth_journal_time) { zero_time + 2.minutes } # Created two minutes later -> Conflicts with 5, 6 and 7 + let(:fourth_journal_update_time) { zero_time + 3.minutes } # Updated three minute later -> Not relevant for this migration + let(:fifth_journal_time) { zero_time + 2.minutes } # Created two minutes later -> Conflicts with 4, 6 and 7 + let(:sixth_journal_time) { zero_time + 2.minutes } # Created two minutes later -> Conflicts with 5, 5 and 7 + let(:seventh_journal_time) { zero_time + 2.minutes } # Created two minutes later -> Conflicts with 4, 5 and 6 + + let(:work_package) do + create(:work_package) do + Journal.destroy_all + end + end + + let(:user) { create(:user) } + + let!(:initial_journal) do + create(:work_package_journal, + version: 1, + user:, + created_at: initial_journal_time, + updated_at: initial_journal_time, + validity_period: zero_time...zero_time + 1.minute, + journable: work_package).reload + end + let!(:second_journal) do + create(:work_package_journal, + version: 2, + user:, + created_at: second_journal_time, + updated_at: second_journal_time, + validity_period: zero_time + 2.minutes...zero_time + 3.minutes, + journable: work_package).reload + end + let!(:third_journal) do + create(:work_package_journal, + version: 3, + user:, + created_at: third_journal_time, + updated_at: third_journal_time, + validity_period: zero_time + 4.minutes...zero_time + 5.minutes, + journable: work_package).reload + end + let!(:fourth_journal) do + create(:work_package_journal, + version: 4, + user:, + created_at: fourth_journal_time, + updated_at: fourth_journal_update_time, + validity_period: zero_time + 6.minutes...zero_time + 7.minutes, + journable: work_package).reload + end + let!(:fifth_journal) do + create(:work_package_journal, + version: 5, + user:, + created_at: fifth_journal_time, + updated_at: fifth_journal_time, + validity_period: zero_time + 8.minutes...zero_time + 9.minutes, + journable: work_package).reload + end + let!(:sixth_journal) do + create(:work_package_journal, + version: 6, + user:, + created_at: sixth_journal_time, + updated_at: sixth_journal_time, + validity_period: zero_time + 10.minutes...zero_time + 11.minutes, + journable: work_package).reload + end + let!(:seventh_journal) do + create(:work_package_journal, + version: 7, + user:, + created_at: seventh_journal_time, + updated_at: seventh_journal_time, + validity_period: zero_time + 11.minutes...zero_time + 12.minutes, + journable: work_package).reload + end + + it "resets the overlapping journals", :aggregate_failures do # rubocop:disable RSpec/ExampleLength + subject + + initial_journal.reload + second_journal.reload + third_journal.reload + fourth_journal.reload + fifth_journal.reload + sixth_journal.reload + seventh_journal.reload + + expect(initial_journal.created_at).to eql initial_journal_time + expect(initial_journal.updated_at).to eql initial_journal_time + expect(initial_journal.validity_period.begin).to eql initial_journal_time + expect(initial_journal.validity_period.end.strftime("%s%L").to_i).to eql second_journal_time.strftime("%s%L").to_i - 1 + + expect(second_journal.created_at.strftime("%s%L").to_i).to eql third_journal_time.strftime("%s%L").to_i - 1 + expect(second_journal.updated_at.strftime("%s%L").to_i).to eql third_journal_time.strftime("%s%L").to_i - 1 + expect(second_journal.validity_period.begin.strftime("%s%L").to_i).to eql third_journal_time.strftime("%s%L").to_i - 1 + expect(second_journal.validity_period.end.strftime("%s%L").to_i).to eql third_journal_time.strftime("%s%L").to_i + + expect(third_journal.created_at.strftime("%s%L").to_i).to eql third_journal_time.strftime("%s%L").to_i + expect(third_journal.updated_at.strftime("%s%L").to_i).to eql third_journal_time.strftime("%s%L").to_i + expect(third_journal.validity_period.begin.strftime("%s%L").to_i).to eql third_journal_time.strftime("%s%L").to_i + # Since the fourth journal had to be moved 3 times (by 3 ms in total) to avoid conflicts with its subsequent 3 journals, + # the validity period ends at the old fourth journal time minus 3 ms. + expect(third_journal.validity_period.end.strftime("%s%L").to_i).to eql fourth_journal_time.strftime("%s%L").to_i - 3 + + # The fourth journal had to be moved 3 times to avoid conflicts with its subsequent 3 journals. + # All timestamps had to be moved by 1 ms each time. + expect(fourth_journal.created_at.strftime("%s%L").to_i).to eql fourth_journal_time.strftime("%s%L").to_i - 3 + # This time is not updated at all. It already had a different time than the created_at. + # It now overlaps with the fifth, sixth and seventh journal. This can happen and is okay. + # It might e.g. be, that the comment on the journal was updated. + expect(fourth_journal.updated_at.strftime("%s%L").to_i).to eql fourth_journal_update_time.strftime("%s%L").to_i + expect(fourth_journal.validity_period.begin.strftime("%s%L").to_i).to eql fourth_journal_time.strftime("%s%L").to_i - 3 + expect(fourth_journal.validity_period.end.strftime("%s%L").to_i).to eql fifth_journal_time.strftime("%s%L").to_i - 2 + + # The fifth journal had to be moved 2 times to avoid conflicts with its subsequent 2 journals. + # All timestamps had to be moved by 1 ms each time. + expect(fifth_journal.created_at.strftime("%s%L").to_i).to eql fifth_journal_time.strftime("%s%L").to_i - 2 + expect(fifth_journal.updated_at.strftime("%s%L").to_i).to eql fifth_journal_time.strftime("%s%L").to_i - 2 + expect(fifth_journal.validity_period.begin.strftime("%s%L").to_i).to eql fifth_journal_time.strftime("%s%L").to_i - 2 + expect(fifth_journal.validity_period.end.strftime("%s%L").to_i).to eql sixth_journal_time.strftime("%s%L").to_i - 1 + + # The sixth journal had to be moved 1 times to avoid conflicts with its subsequent 1 journals. + # All timestamps had to be moved by 1 ms. + expect(sixth_journal.created_at.strftime("%s%L").to_i).to eql sixth_journal_time.strftime("%s%L").to_i - 1 + expect(sixth_journal.updated_at.strftime("%s%L").to_i).to eql sixth_journal_time.strftime("%s%L").to_i - 1 + expect(sixth_journal.validity_period.begin.strftime("%s%L").to_i).to eql sixth_journal_time.strftime("%s%L").to_i - 1 + expect(sixth_journal.validity_period.end.strftime("%s%L").to_i).to eql seventh_journal_time.strftime("%s%L").to_i + + # The seventh journal is the last in the list of conflicting journals so it itself had not to be moved. + expect(seventh_journal.created_at.strftime("%s%L").to_i).to eql seventh_journal_time.strftime("%s%L").to_i + expect(seventh_journal.updated_at.strftime("%s%L").to_i).to eql seventh_journal_time.strftime("%s%L").to_i + expect(seventh_journal.validity_period.begin.strftime("%s%L").to_i).to eql seventh_journal_time.strftime("%s%L").to_i + # Since it is the currently last journal, it's range is open-ended. + expect(seventh_journal.validity_period.end).to be_nil + end +end From 37250d0c8e6349b3e7c08ba0edbefea661624e12 Mon Sep 17 00:00:00 2001 From: ulferts Date: Thu, 17 Oct 2024 16:11:40 +0200 Subject: [PATCH 030/115] use a custom matcher for readability --- .../add_validity_period_to_journals_spec.rb | 71 ++++++++++++------- 1 file changed, 44 insertions(+), 27 deletions(-) diff --git a/spec/migrations/add_validity_period_to_journals_spec.rb b/spec/migrations/add_validity_period_to_journals_spec.rb index 4478cc197851..4132ba70d011 100644 --- a/spec/migrations/add_validity_period_to_journals_spec.rb +++ b/spec/migrations/add_validity_period_to_journals_spec.rb @@ -58,6 +58,9 @@ let(:user) { create(:user) } + # The validity_periods defined herein are completely irrelevant for the specs. + # They are just added here so that the inserted journals are valid. + # The migration will remove the validity_period column on its down migration so that data will be lost then. let!(:initial_journal) do create(:work_package_journal, version: 1, @@ -122,6 +125,20 @@ journable: work_package).reload end + # Comparing DateTime objects with a precision of 1 ms proves to be difficult. + # Attempting to just do DateTime.current - 0.001.seconds fails due to floating point inaccuracies. + RSpec::Matchers.define :be_x_ms_earlier_than do |reference_time, ms| + match do |time| + reference_time.strftime("%s%L").to_i - time.strftime("%s%L").to_i == ms + end + + failure_message do |time| + "expected #{time.strftime('%Y-%m-%d %H:%M:%S.%N')} to be #{ms} ms " \ + "before #{reference_time.strftime('%Y-%m-%d %H:%M:%S.%N')}, " \ + "but has a difference of #{reference_time.strftime('%s%L').to_i - time.strftime('%s%L').to_i} ms" + end + end + it "resets the overlapping journals", :aggregate_failures do # rubocop:disable RSpec/ExampleLength subject @@ -133,51 +150,51 @@ sixth_journal.reload seventh_journal.reload - expect(initial_journal.created_at).to eql initial_journal_time - expect(initial_journal.updated_at).to eql initial_journal_time - expect(initial_journal.validity_period.begin).to eql initial_journal_time - expect(initial_journal.validity_period.end.strftime("%s%L").to_i).to eql second_journal_time.strftime("%s%L").to_i - 1 + expect(initial_journal.created_at).to be_x_ms_earlier_than initial_journal_time, 0 + expect(initial_journal.updated_at).to be_x_ms_earlier_than initial_journal_time, 0 + expect(initial_journal.validity_period.begin).to be_x_ms_earlier_than initial_journal_time, 0 + expect(initial_journal.validity_period.end).to be_x_ms_earlier_than second_journal_time, 1 - expect(second_journal.created_at.strftime("%s%L").to_i).to eql third_journal_time.strftime("%s%L").to_i - 1 - expect(second_journal.updated_at.strftime("%s%L").to_i).to eql third_journal_time.strftime("%s%L").to_i - 1 - expect(second_journal.validity_period.begin.strftime("%s%L").to_i).to eql third_journal_time.strftime("%s%L").to_i - 1 - expect(second_journal.validity_period.end.strftime("%s%L").to_i).to eql third_journal_time.strftime("%s%L").to_i + expect(second_journal.created_at).to be_x_ms_earlier_than third_journal_time, 1 + expect(second_journal.updated_at).to be_x_ms_earlier_than third_journal_time, 1 + expect(second_journal.validity_period.begin).to be_x_ms_earlier_than third_journal_time, 1 + expect(second_journal.validity_period.end).to be_x_ms_earlier_than third_journal_time, 0 - expect(third_journal.created_at.strftime("%s%L").to_i).to eql third_journal_time.strftime("%s%L").to_i - expect(third_journal.updated_at.strftime("%s%L").to_i).to eql third_journal_time.strftime("%s%L").to_i - expect(third_journal.validity_period.begin.strftime("%s%L").to_i).to eql third_journal_time.strftime("%s%L").to_i + expect(third_journal.created_at).to be_x_ms_earlier_than third_journal_time, 0 + expect(third_journal.updated_at).to be_x_ms_earlier_than third_journal_time, 0 + expect(third_journal.validity_period.begin).to be_x_ms_earlier_than third_journal_time, 0 # Since the fourth journal had to be moved 3 times (by 3 ms in total) to avoid conflicts with its subsequent 3 journals, # the validity period ends at the old fourth journal time minus 3 ms. - expect(third_journal.validity_period.end.strftime("%s%L").to_i).to eql fourth_journal_time.strftime("%s%L").to_i - 3 + expect(third_journal.validity_period.end).to be_x_ms_earlier_than fourth_journal_time, 3 # The fourth journal had to be moved 3 times to avoid conflicts with its subsequent 3 journals. # All timestamps had to be moved by 1 ms each time. - expect(fourth_journal.created_at.strftime("%s%L").to_i).to eql fourth_journal_time.strftime("%s%L").to_i - 3 + expect(fourth_journal.created_at).to be_x_ms_earlier_than fourth_journal_time, 3 # This time is not updated at all. It already had a different time than the created_at. # It now overlaps with the fifth, sixth and seventh journal. This can happen and is okay. # It might e.g. be, that the comment on the journal was updated. - expect(fourth_journal.updated_at.strftime("%s%L").to_i).to eql fourth_journal_update_time.strftime("%s%L").to_i - expect(fourth_journal.validity_period.begin.strftime("%s%L").to_i).to eql fourth_journal_time.strftime("%s%L").to_i - 3 - expect(fourth_journal.validity_period.end.strftime("%s%L").to_i).to eql fifth_journal_time.strftime("%s%L").to_i - 2 + expect(fourth_journal.updated_at).to be_x_ms_earlier_than fourth_journal_update_time, 0 + expect(fourth_journal.validity_period.begin).to be_x_ms_earlier_than fourth_journal_time, 3 + expect(fourth_journal.validity_period.end).to be_x_ms_earlier_than fifth_journal_time, 2 # The fifth journal had to be moved 2 times to avoid conflicts with its subsequent 2 journals. # All timestamps had to be moved by 1 ms each time. - expect(fifth_journal.created_at.strftime("%s%L").to_i).to eql fifth_journal_time.strftime("%s%L").to_i - 2 - expect(fifth_journal.updated_at.strftime("%s%L").to_i).to eql fifth_journal_time.strftime("%s%L").to_i - 2 - expect(fifth_journal.validity_period.begin.strftime("%s%L").to_i).to eql fifth_journal_time.strftime("%s%L").to_i - 2 - expect(fifth_journal.validity_period.end.strftime("%s%L").to_i).to eql sixth_journal_time.strftime("%s%L").to_i - 1 + expect(fifth_journal.created_at).to be_x_ms_earlier_than fifth_journal_time, 2 + expect(fifth_journal.updated_at).to be_x_ms_earlier_than fifth_journal_time, 2 + expect(fifth_journal.validity_period.begin).to be_x_ms_earlier_than fifth_journal_time, 2 + expect(fifth_journal.validity_period.end).to be_x_ms_earlier_than sixth_journal_time, 1 # The sixth journal had to be moved 1 times to avoid conflicts with its subsequent 1 journals. # All timestamps had to be moved by 1 ms. - expect(sixth_journal.created_at.strftime("%s%L").to_i).to eql sixth_journal_time.strftime("%s%L").to_i - 1 - expect(sixth_journal.updated_at.strftime("%s%L").to_i).to eql sixth_journal_time.strftime("%s%L").to_i - 1 - expect(sixth_journal.validity_period.begin.strftime("%s%L").to_i).to eql sixth_journal_time.strftime("%s%L").to_i - 1 - expect(sixth_journal.validity_period.end.strftime("%s%L").to_i).to eql seventh_journal_time.strftime("%s%L").to_i + expect(sixth_journal.created_at).to be_x_ms_earlier_than sixth_journal_time, 1 + expect(sixth_journal.updated_at).to be_x_ms_earlier_than sixth_journal_time, 1 + expect(sixth_journal.validity_period.begin).to be_x_ms_earlier_than sixth_journal_time, 1 + expect(sixth_journal.validity_period.end).to be_x_ms_earlier_than seventh_journal_time, 0 # The seventh journal is the last in the list of conflicting journals so it itself had not to be moved. - expect(seventh_journal.created_at.strftime("%s%L").to_i).to eql seventh_journal_time.strftime("%s%L").to_i - expect(seventh_journal.updated_at.strftime("%s%L").to_i).to eql seventh_journal_time.strftime("%s%L").to_i - expect(seventh_journal.validity_period.begin.strftime("%s%L").to_i).to eql seventh_journal_time.strftime("%s%L").to_i + expect(seventh_journal.created_at).to be_x_ms_earlier_than seventh_journal_time, 0 + expect(seventh_journal.updated_at).to be_x_ms_earlier_than seventh_journal_time, 0 + expect(seventh_journal.validity_period.begin).to be_x_ms_earlier_than seventh_journal_time, 0 # Since it is the currently last journal, it's range is open-ended. expect(seventh_journal.validity_period.end).to be_nil end From 40d10e0f0bacce21081d88a1b233baeb71d2b453 Mon Sep 17 00:00:00 2001 From: ulferts Date: Thu, 17 Oct 2024 17:12:25 +0200 Subject: [PATCH 031/115] use faster algorithm for fixing timestamp inconsistencies --- ...8151123_add_validity_period_to_journals.rb | 182 ++++++++++-------- 1 file changed, 101 insertions(+), 81 deletions(-) diff --git a/db/migrate/20230608151123_add_validity_period_to_journals.rb b/db/migrate/20230608151123_add_validity_period_to_journals.rb index 40c97406f6c3..639576905894 100644 --- a/db/migrate/20230608151123_add_validity_period_to_journals.rb +++ b/db/migrate/20230608151123_add_validity_period_to_journals.rb @@ -17,108 +17,128 @@ def change end def fix_all_journal_timestamps - max_attempts = attempts = (ENV["MAX_JOURNAL_TIMESTAMPS_ATTEMPTS"].presence && ENV["MAX_JOURNAL_TIMESTAMPS_ATTEMPTS"].to_i) || - Journal.all.maximum(:version) + return unless current_max_journal_version - invalid_journables = nil + say "Fixing potential timestamp inconsistencies on existing journals." - loop do - invalid_journables = fix_journal_timestamps(invalid_journables) + say "Creating utility table to improve the performance of subsequent actions." + create_successor_journals_utility_table - break if invalid_journables.empty? + current_max_journal_version.downto(1).each do |version| + say_with_time "Fixing timestamps for journals with version #{version}." do + fixed_journals = fix_journal_timestamps(version) - if attempts == 0 - raise <<~MSG.squish - There are still journals with timestamps after their successors timestamp. - Aborting after #{max_attempts}. - Run the migration again with the env variable MAX_JOURNAL_TIMESTAMPS_ATTEMPTS set to a higher value. - MSG - else - say "Journals with timestamps after their successors timestamp remain in the database. Retrying..." + say "Fixed timestamps for #{fixed_journals.cmdtuples} journals.", true if fixed_journals.cmdtuples.positive? end - - attempts -= 1 end - say "All journals' timestamps in the database are correct." + say "Done." + + drop_successor_journals_utility_table end - def fix_journal_timestamps(invalid_journables) - limit_condition = invalid_journables ? "AND journable_id IN (#{invalid_journables.uniq.join(', ')})" : "" - - # Update journals with their timestamps after the timestamp of their successor - # (as identified by the journal belonging to the same journable and having the smallest version - # larger than the journal's). - # If one such is found, the: - # * created_at is set to be the minimum of the journal's and the successor's created_at. But of the successor's value some - # small amount needs to be subtracted as later on the validity_period is calculated as a range from the predecessor's - # created_at to the successor's created_at. If the two values would be equal, the range would be empty - # resulting in an error. - # * updated_at is only altered if created_at and updated_at are equal, meaning the journal has never been updated. - # A journal might very well be updated after its successor was created, because as a note can be updated - # by the user at anytime. - # This might have the consequence of the updated_at wrongfully being after the created_at of the successor, - # but this will not be invalid. - - updated = select_rows <<~SQL.squish - UPDATE journals - SET - created_at = LEAST(journals.created_at, successors.created_at - interval '1 ms'), - updated_at = CASE - WHEN journals.created_at = journals.updated_at - THEN LEAST(journals.created_at, successors.created_at - interval '1 ms') - ELSE journals.updated_at - END - - FROM ( + def create_successor_journals_utility_table + suppress_messages do + create_table :successor_journals, id: false do |t| + t.references :predecessor, null: true, index: false + t.references :successor, null: true, index: false + end + + execute <<~SQL.squish + INSERT INTO + successor_journals SELECT - predecessors.id, - successors.created_at + predecessors.id predecessor_id, + successors.id successor_id FROM journals predecessors LEFT JOIN LATERAL (SELECT DISTINCT ON (journable_type, journable_id) * - FROM journals successors - WHERE successors.version > predecessors.version - AND successors.journable_id = predecessors.journable_id - AND successors.journable_type = predecessors.journable_type - AND successors.created_at <= predecessors.created_at - ORDER BY successors.journable_type ASC, + FROM journals successors + WHERE successors.version > predecessors.version + AND successors.journable_id = predecessors.journable_id + AND successors.journable_type = predecessors.journable_type + ORDER BY successors.journable_type ASC, successors.journable_id ASC, successors.version ASC) successors ON successors.journable_id = predecessors.journable_id AND successors.journable_type = predecessors.journable_type - ) successors - WHERE successors.id = journals.id - AND successors.created_at <= journals.created_at - #{limit_condition} - RETURNING journals.journable_id - SQL + SQL + + add_index :successor_journals, :predecessor_id + add_index :successor_journals, :successor_id + end + end + + def drop_successor_journals_utility_table + suppress_messages do + drop_table :successor_journals + end + end - updated.flatten + def current_max_journal_version + @current_max_journal_version ||= suppress_messages { select_one("SELECT MAX(version) FROM journals")["max"] } + end + + # Update journals with their timestamps after the timestamp of their successor + # (as identified by the journal belonging to the same journable and having the smallest version + # larger than the journal's). + # If one such is found, the: + # * created_at is set to be the minimum of the journal's and the successor's created_at. But of the successor's value some + # small amount needs to be subtracted as later on the validity_period is calculated as a range from the predecessor's + # created_at to the successor's created_at. If the two values would be equal, the range would be empty + # resulting in an error. + # * updated_at is only altered if created_at and updated_at are equal, meaning the journal has never been updated. + # A journal might very well be updated after its successor was created, because as a note can be updated + # by the user at anytime. + # This might have the consequence of the updated_at wrongfully being after the created_at of the successor, + # but this will not be invalid. + def fix_journal_timestamps(version) + suppress_messages do + execute <<~SQL.squish + UPDATE journals + SET + created_at = LEAST(journals.created_at, successors.created_at - INTERVAL '1ms'), + updated_at = CASE + WHEN journals.created_at = journals.updated_at + THEN LEAST(journals.created_at, successors.created_at - INTERVAL '1ms') + ELSE journals.updated_at + END + FROM successor_journals, journals successors + WHERE successor_journals.predecessor_id = journals.id AND successor_journals.successor_id = successors.id + AND successors.created_at <= journals.created_at + AND journals.version = #{version} + SQL + end end def write_validity_period - execute <<~SQL.squish - UPDATE journals - SET validity_period = values.validity_period - FROM ( - SELECT - predecessors.id, - tstzrange(predecessors.created_at, successors.created_at) validity_period - FROM - journals predecessors - LEFT JOIN LATERAL (SELECT DISTINCT ON (journable_type, journable_id) * - FROM journals successors - WHERE successors.version > predecessors.version - AND successors.journable_id = predecessors.journable_id - ORDER BY successors.journable_type ASC, - successors.journable_id ASC, - successors.version ASC) successors - ON successors.journable_id = predecessors.journable_id - AND successors.journable_type = predecessors.journable_type - ) values - WHERE values.id = journals.id - SQL + say_with_time "Writing validity periods for journals." do + suppress_messages do + execute <<~SQL.squish + UPDATE journals + SET validity_period = values.validity_period + FROM ( + SELECT + predecessors.id, + tstzrange(predecessors.created_at, successors.created_at) validity_period + FROM + journals predecessors + LEFT JOIN LATERAL (SELECT DISTINCT ON (journable_type, journable_id) * + FROM journals successors + WHERE successors.version > predecessors.version + AND successors.journable_id = predecessors.journable_id + ORDER BY successors.journable_type ASC, + successors.journable_id ASC, + successors.version ASC) successors + ON successors.journable_id = predecessors.journable_id + AND successors.journable_type = predecessors.journable_type + ) values + WHERE values.id = journals.id + SQL + end + end + + say "Done." end def add_validity_period_constraint From 8788c700e26f5ce010833a10642bae763e158d1a Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 17 Oct 2024 18:42:47 +0300 Subject: [PATCH 032/115] [#58460] Rename Display comments preferences to "Newest on top" & "Newest at the bottom" (#16984) [#58460] Rename Display comments options https://community.openproject.org/work_packages/58460 --- app/views/users/_preferences.html.erb | 4 ++-- config/locales/en.yml | 2 -- docs/user-guide/my-account/README.md | 8 ++++---- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/app/views/users/_preferences.html.erb b/app/views/users/_preferences.html.erb index 37d8fe87686e..c670eed8d9d5 100644 --- a/app/views/users/_preferences.html.erb +++ b/app/views/users/_preferences.html.erb @@ -42,8 +42,8 @@ See COPYRIGHT and LICENSE files for more details.
<%= pref_fields.select :comments_sorting, - [[t(:label_chronological_order), 'asc'], - [t(:label_reverse_chronological_order), 'desc']], + [[t("activities.work_packages.activity_tab.label_sort_asc"), 'asc'], + [t("activities.work_packages.activity_tab.label_sort_desc"), 'desc']], container_class: (defined? input_size) ? "-#{input_size}" : '' %>
<%= pref_fields.check_box :warn_on_leaving_unsaved %>
diff --git a/config/locales/en.yml b/config/locales/en.yml index 1da6dc23e8ae..22346a469f6f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2178,7 +2178,6 @@ en: label_check_uncheck_all_in_row: "Check/Uncheck all in row" label_child_element: "Child element" label_choices: "Choices" - label_chronological_order: "Oldest first" label_close_versions: "Close completed versions" label_closed_work_packages: "closed" label_collapse: "Collapse" @@ -2544,7 +2543,6 @@ en: label_required: "required" label_requires: "requires" label_result_plural: "Results" - label_reverse_chronological_order: "Newest first" label_revision: "Revision" label_revision_id: "Revision %{value}" label_revision_plural: "Revisions" diff --git a/docs/user-guide/my-account/README.md b/docs/user-guide/my-account/README.md index bd499c5e3765..f8df85c716b1 100644 --- a/docs/user-guide/my-account/README.md +++ b/docs/user-guide/my-account/README.md @@ -78,7 +78,7 @@ This mode is recommended for users with visuals impairment. ### Select the dark mode -In the dropdown menu **Mode** you can pick the color mode. The default setting is the **Light mode**. You can also select the **Dark mode**, which will change the color theme of the OpenProject instance for you. +In the dropdown menu **Mode** you can pick the color mode. The default setting is the **Light mode**. You can also select the **Dark mode**, which will change the color theme of the OpenProject instance for you. > [!NOTE] > The dark mode ignores parts of the configured design. Top header and side menu colors are entirely overridden for this mode. Only the accent color and the primary button color are kept, but are calculated to brighter variants. @@ -87,9 +87,9 @@ In the dropdown menu **Mode** you can pick the color mode. The default setting i ### Change the order to display comments -You can select the order of the comments (for example of the comments for a work package which appear in the Activity tab). You can select the **oldest first** or **newest first** to display the comments. +You can select the order of the comments (for example of the comments for a work package which appear in the Activity tab). You can select the **newest at the bottom** or **newest on top** to display the comments. -If you choose newest first the latest comment will appear on top in the Activity list. +If you choose newest on top, the latest comment will appear on top in the Activity list. ### Backlogs settings @@ -246,7 +246,7 @@ Default: Enable daily email reminders: 2am, Monday - Friday. You can choose to receive emails immediately, or only on certain days and times, temporarily pause reminder emails, or opt for no reminders at all. > [!IMPORTANT] -> If you have selected the *immediately when someone mentions me* option, you will only be notified once, i.e. this reminder will not be duplicated in a daily reminder. +> If you have selected the *immediately when someone mentions me* option, you will only be notified once, i.e. this reminder will not be duplicated in a daily reminder. You can also opt-in to receive **email alerts for other items (that are not work packages)** whenever one of your project members: From 889df416bcb05a29494e6bc93a07b2924dfcc66b Mon Sep 17 00:00:00 2001 From: OpenProject Actions CI Date: Fri, 18 Oct 2024 03:13:42 +0000 Subject: [PATCH 033/115] update locales from crowdin [ci skip] --- config/locales/crowdin/af.yml | 20 +++++++++++-- config/locales/crowdin/ar.yml | 20 +++++++++++-- config/locales/crowdin/az.yml | 20 +++++++++++-- config/locales/crowdin/be.yml | 20 +++++++++++-- config/locales/crowdin/bg.yml | 20 +++++++++++-- config/locales/crowdin/ca.yml | 20 +++++++++++-- config/locales/crowdin/ckb-IR.yml | 20 +++++++++++-- config/locales/crowdin/cs.yml | 20 +++++++++++-- config/locales/crowdin/da.yml | 20 +++++++++++-- config/locales/crowdin/de.yml | 20 +++++++++++-- config/locales/crowdin/el.yml | 20 +++++++++++-- config/locales/crowdin/eo.yml | 20 +++++++++++-- config/locales/crowdin/es.yml | 20 +++++++++++-- config/locales/crowdin/et.yml | 20 +++++++++++-- config/locales/crowdin/eu.yml | 20 +++++++++++-- config/locales/crowdin/fa.yml | 20 +++++++++++-- config/locales/crowdin/fi.yml | 20 +++++++++++-- config/locales/crowdin/fil.yml | 20 +++++++++++-- config/locales/crowdin/fr.yml | 20 +++++++++++-- config/locales/crowdin/he.yml | 20 +++++++++++-- config/locales/crowdin/hi.yml | 20 +++++++++++-- config/locales/crowdin/hr.yml | 20 +++++++++++-- config/locales/crowdin/hu.yml | 20 +++++++++++-- config/locales/crowdin/id.yml | 20 +++++++++++-- config/locales/crowdin/it.yml | 20 +++++++++++-- config/locales/crowdin/ja.yml | 20 +++++++++++-- config/locales/crowdin/js-zh-TW.yml | 4 +-- config/locales/crowdin/ka.yml | 20 +++++++++++-- config/locales/crowdin/kk.yml | 20 +++++++++++-- config/locales/crowdin/ko.yml | 20 +++++++++++-- config/locales/crowdin/lt.yml | 20 +++++++++++-- config/locales/crowdin/lv.yml | 20 +++++++++++-- config/locales/crowdin/mn.yml | 20 +++++++++++-- config/locales/crowdin/ms.yml | 20 +++++++++++-- config/locales/crowdin/ne.yml | 20 +++++++++++-- config/locales/crowdin/nl.yml | 20 +++++++++++-- config/locales/crowdin/no.yml | 20 +++++++++++-- config/locales/crowdin/pl.yml | 20 +++++++++++-- config/locales/crowdin/pt-BR.yml | 20 +++++++++++-- config/locales/crowdin/pt-PT.yml | 20 +++++++++++-- config/locales/crowdin/ro.yml | 20 +++++++++++-- config/locales/crowdin/ru.yml | 20 +++++++++++-- config/locales/crowdin/rw.yml | 20 +++++++++++-- config/locales/crowdin/si.yml | 20 +++++++++++-- config/locales/crowdin/sk.yml | 20 +++++++++++-- config/locales/crowdin/sl.yml | 20 +++++++++++-- config/locales/crowdin/sr.yml | 20 +++++++++++-- config/locales/crowdin/sv.yml | 20 +++++++++++-- config/locales/crowdin/th.yml | 20 +++++++++++-- config/locales/crowdin/tr.yml | 20 +++++++++++-- config/locales/crowdin/uk.yml | 20 +++++++++++-- config/locales/crowdin/uz.yml | 20 +++++++++++-- config/locales/crowdin/vi.yml | 20 +++++++++++-- config/locales/crowdin/zh-CN.yml | 20 +++++++++++-- config/locales/crowdin/zh-TW.yml | 28 ++++++++++++++----- .../bim/config/locales/crowdin/ru.seeders.yml | 4 +-- .../config/locales/crowdin/zh-TW.yml | 2 +- 57 files changed, 927 insertions(+), 171 deletions(-) diff --git a/config/locales/crowdin/af.yml b/config/locales/crowdin/af.yml index 27ab8c3379d4..ba7cd2ad276b 100644 --- a/config/locales/crowdin/af.yml +++ b/config/locales/crowdin/af.yml @@ -24,6 +24,22 @@ af: activities: index: no_results_title_text: There has not been any activity for the project within this time frame. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -208,7 +224,7 @@ af: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2041,7 +2057,6 @@ af: label_check_uncheck_all_in_row: "Check/Uncheck all in row" label_child_element: "Child element" label_choices: "Choices" - label_chronological_order: "Oldest first" label_close_versions: "Maak voltooide weergawes toe" label_closed_work_packages: "gesluit" label_collapse: "Vou in" @@ -2407,7 +2422,6 @@ af: label_required: "required" label_requires: "requires" label_result_plural: "Results" - label_reverse_chronological_order: "Newest first" label_revision: "Revision" label_revision_id: "Revision %{value}" label_revision_plural: "Revisions" diff --git a/config/locales/crowdin/ar.yml b/config/locales/crowdin/ar.yml index 16bc489f699f..1884e9848247 100644 --- a/config/locales/crowdin/ar.yml +++ b/config/locales/crowdin/ar.yml @@ -24,6 +24,22 @@ ar: activities: index: no_results_title_text: ولم يكن هناك أي نشاط للمشروع ضمن هذا الإطار الزمني. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -208,7 +224,7 @@ ar: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2181,7 +2197,6 @@ ar: label_check_uncheck_all_in_row: "اختيار / إلغاء اختيار كل شيء في السطر" label_child_element: "العنصر التابع" label_choices: "Choices" - label_chronological_order: "Oldest first" label_close_versions: "إغلاق الإصدارات المنجزة" label_closed_work_packages: "مغلق" label_collapse: "مطوي" @@ -2547,7 +2562,6 @@ ar: label_required: "مطلوب" label_requires: "متطلبات" label_result_plural: "نتائج" - label_reverse_chronological_order: "Newest first" label_revision: "مراجعة" label_revision_id: "مراجعة%{value}" label_revision_plural: "مراجعات" diff --git a/config/locales/crowdin/az.yml b/config/locales/crowdin/az.yml index 28a46622c6d9..8ee1798b1021 100644 --- a/config/locales/crowdin/az.yml +++ b/config/locales/crowdin/az.yml @@ -24,6 +24,22 @@ az: activities: index: no_results_title_text: There has not been any activity for the project within this time frame. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -208,7 +224,7 @@ az: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2041,7 +2057,6 @@ az: label_check_uncheck_all_in_row: "Check/Uncheck all in row" label_child_element: "Child element" label_choices: "Choices" - label_chronological_order: "Oldest first" label_close_versions: "Close completed versions" label_closed_work_packages: "closed" label_collapse: "Collapse" @@ -2407,7 +2422,6 @@ az: label_required: "required" label_requires: "requires" label_result_plural: "Results" - label_reverse_chronological_order: "Newest first" label_revision: "Revision" label_revision_id: "Revision %{value}" label_revision_plural: "Revisions" diff --git a/config/locales/crowdin/be.yml b/config/locales/crowdin/be.yml index b54db1c96124..d8e8b074b54c 100644 --- a/config/locales/crowdin/be.yml +++ b/config/locales/crowdin/be.yml @@ -24,6 +24,22 @@ be: activities: index: no_results_title_text: Не было ніякай актыўнасці па праекту на працягу гэтага часу. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: Зараз ніякіх плагінаў не ўстаноўлена. @@ -208,7 +224,7 @@ be: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2111,7 +2127,6 @@ be: label_check_uncheck_all_in_row: "Check/Uncheck all in row" label_child_element: "Child element" label_choices: "Choices" - label_chronological_order: "Oldest first" label_close_versions: "Close completed versions" label_closed_work_packages: "closed" label_collapse: "Згарнуць" @@ -2477,7 +2492,6 @@ be: label_required: "required" label_requires: "requires" label_result_plural: "Results" - label_reverse_chronological_order: "Newest first" label_revision: "Revision" label_revision_id: "Revision %{value}" label_revision_plural: "Revisions" diff --git a/config/locales/crowdin/bg.yml b/config/locales/crowdin/bg.yml index 19d398637ac9..cf86a2b2ccfc 100644 --- a/config/locales/crowdin/bg.yml +++ b/config/locales/crowdin/bg.yml @@ -24,6 +24,22 @@ bg: activities: index: no_results_title_text: В рамките на този период от време по проекта не са извършвани никакви дейности. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: В момента няма инсталирани приставки. @@ -208,7 +224,7 @@ bg: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2041,7 +2057,6 @@ bg: label_check_uncheck_all_in_row: "Проверете/махнете отметката на всички в реда" label_child_element: "Елемент наследник" label_choices: "Choices" - label_chronological_order: "Oldest first" label_close_versions: "Затвори завършени версии" label_closed_work_packages: "затворен" label_collapse: "Свий" @@ -2407,7 +2422,6 @@ bg: label_required: "required" label_requires: "requires" label_result_plural: "Резултати" - label_reverse_chronological_order: "Newest first" label_revision: "Преразглеждане" label_revision_id: "Редакция %{value}" label_revision_plural: "Ревизии" diff --git a/config/locales/crowdin/ca.yml b/config/locales/crowdin/ca.yml index 1f71e427d1ae..6eaa784a97b2 100644 --- a/config/locales/crowdin/ca.yml +++ b/config/locales/crowdin/ca.yml @@ -24,6 +24,22 @@ ca: activities: index: no_results_title_text: No hi hagut activitat al projecte en aquesta finestra de temps. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: Actualment no hi ha connectors disponibles. @@ -205,7 +221,7 @@ ca: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2037,7 +2053,6 @@ ca: label_check_uncheck_all_in_row: "Marca/desmarca tot a la fila" label_child_element: "Element fill" label_choices: "Opcions" - label_chronological_order: "Més antic primer" label_close_versions: "Tancar versions completades" label_closed_work_packages: "tancat" label_collapse: "Replega" @@ -2403,7 +2418,6 @@ ca: label_required: "requerit" label_requires: "requereix" label_result_plural: "Resultats" - label_reverse_chronological_order: "El més nou primer" label_revision: "Revisió" label_revision_id: "Revisió %{value}" label_revision_plural: "Revisions" diff --git a/config/locales/crowdin/ckb-IR.yml b/config/locales/crowdin/ckb-IR.yml index d5b9f4b32c0d..ae34e5b2bb6b 100644 --- a/config/locales/crowdin/ckb-IR.yml +++ b/config/locales/crowdin/ckb-IR.yml @@ -24,6 +24,22 @@ ckb-IR: activities: index: no_results_title_text: There has not been any activity for the project within this time frame. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -208,7 +224,7 @@ ckb-IR: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2041,7 +2057,6 @@ ckb-IR: label_check_uncheck_all_in_row: "Check/Uncheck all in row" label_child_element: "Child element" label_choices: "Choices" - label_chronological_order: "Oldest first" label_close_versions: "Close completed versions" label_closed_work_packages: "closed" label_collapse: "Collapse" @@ -2407,7 +2422,6 @@ ckb-IR: label_required: "required" label_requires: "requires" label_result_plural: "Results" - label_reverse_chronological_order: "Newest first" label_revision: "Revision" label_revision_id: "Revision %{value}" label_revision_plural: "Revisions" diff --git a/config/locales/crowdin/cs.yml b/config/locales/crowdin/cs.yml index f758fe95b73c..d3de7cc771c1 100644 --- a/config/locales/crowdin/cs.yml +++ b/config/locales/crowdin/cs.yml @@ -24,6 +24,22 @@ cs: activities: index: no_results_title_text: V tomto časovém rámci nebyla pro projekt žádná aktivita. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: V současné době nejsou nainstalovány žádné pluginy. @@ -208,7 +224,7 @@ cs: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2111,7 +2127,6 @@ cs: label_check_uncheck_all_in_row: "Zaškrtnout/Odznačit vše v řádku" label_child_element: "Podřazený prvek" label_choices: "Volby" - label_chronological_order: "Od nejstarších" label_close_versions: "Zavřít dokončené verze" label_closed_work_packages: "uzavřeno" label_collapse: "Sbalit" @@ -2477,7 +2492,6 @@ cs: label_required: "vyžadováno" label_requires: "vyžaduje" label_result_plural: "Výsledky" - label_reverse_chronological_order: "Od nejnovějších" label_revision: "Revize" label_revision_id: "Revize %{value}" label_revision_plural: "Revize" diff --git a/config/locales/crowdin/da.yml b/config/locales/crowdin/da.yml index 708448ebb6fe..7a4cb2d6ee27 100644 --- a/config/locales/crowdin/da.yml +++ b/config/locales/crowdin/da.yml @@ -24,6 +24,22 @@ da: activities: index: no_results_title_text: Der har ikke været nogen aktivitet for projektet i tidsperioden. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -206,7 +222,7 @@ da: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2039,7 +2055,6 @@ da: label_check_uncheck_all_in_row: "Markér/Afmarkér alle i rækken" label_child_element: "Underelement" label_choices: "Choices" - label_chronological_order: "Oldest first" label_close_versions: "Luk afsluttede versioner" label_closed_work_packages: "lukket" label_collapse: "Fold sammen" @@ -2405,7 +2420,6 @@ da: label_required: "required" label_requires: "requires" label_result_plural: "Resultater" - label_reverse_chronological_order: "Newest first" label_revision: "Revidering" label_revision_id: "Revidering %{value}" label_revision_plural: "Revideringer" diff --git a/config/locales/crowdin/de.yml b/config/locales/crowdin/de.yml index d7f5944f307a..c758b960c888 100644 --- a/config/locales/crowdin/de.yml +++ b/config/locales/crowdin/de.yml @@ -24,6 +24,22 @@ de: activities: index: no_results_title_text: Innerhalb dieses Zeitraums haben keine Aktivitäten in dem Projekt stattgefunden. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: Es sind derzeit keine Plugins installiert. @@ -205,7 +221,7 @@ de: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2035,7 +2051,6 @@ de: label_check_uncheck_all_in_row: "Alle in Zeile an/abwählen" label_child_element: "Kind-Element" label_choices: "Auswahlmöglichkeiten" - label_chronological_order: "Älteste zuerst" label_close_versions: "Abgeschlossene Versionen schließen" label_closed_work_packages: "Geschlossen" label_collapse: "Zuklappen" @@ -2401,7 +2416,6 @@ de: label_required: "benötigt von" label_requires: "benötigt" label_result_plural: "Ergebnisse" - label_reverse_chronological_order: "Neueste zuerst" label_revision: "Revision" label_revision_id: "Revision %{value}" label_revision_plural: "Revisionen" diff --git a/config/locales/crowdin/el.yml b/config/locales/crowdin/el.yml index 3b018bc583c7..2b19065485e6 100644 --- a/config/locales/crowdin/el.yml +++ b/config/locales/crowdin/el.yml @@ -24,6 +24,22 @@ el: activities: index: no_results_title_text: Δεν υπήρξε καμία δραστηριότητα για το έργο εντός αυτού του χρονικού πλαισίου. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -204,7 +220,7 @@ el: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2037,7 +2053,6 @@ el: label_check_uncheck_all_in_row: "Επιλογή/Αποεπιλογή όλων στη σειρά" label_child_element: "Στοιχείο-παιδί" label_choices: "Choices" - label_chronological_order: "Oldest first" label_close_versions: "Κλείσιμο ολοκληρωμένων εκδόσεων" label_closed_work_packages: "κλειστό" label_collapse: "Σύμπτυξη" @@ -2403,7 +2418,6 @@ el: label_required: "απαιτείται" label_requires: "απαιτεί" label_result_plural: "Αποτελέσματα" - label_reverse_chronological_order: "Newest first" label_revision: "Αναθεώρηση" label_revision_id: "Αναθεώρηση %{value}" label_revision_plural: "Αναθεωρήσεις" diff --git a/config/locales/crowdin/eo.yml b/config/locales/crowdin/eo.yml index dcf25553f95d..742a15255c33 100644 --- a/config/locales/crowdin/eo.yml +++ b/config/locales/crowdin/eo.yml @@ -24,6 +24,22 @@ eo: activities: index: no_results_title_text: There has not been any activity for the project within this time frame. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -208,7 +224,7 @@ eo: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2041,7 +2057,6 @@ eo: label_check_uncheck_all_in_row: "Elekti/Malelekti la tutan vicon" label_child_element: "Ida elemento" label_choices: "Choices" - label_chronological_order: "Oldest first" label_close_versions: "Fermi kompletigitajn versiojn" label_closed_work_packages: "fermita" label_collapse: "Maletendi" @@ -2407,7 +2422,6 @@ eo: label_required: "required" label_requires: "requires" label_result_plural: "Results" - label_reverse_chronological_order: "Newest first" label_revision: "Revision" label_revision_id: "Revision %{value}" label_revision_plural: "Revisions" diff --git a/config/locales/crowdin/es.yml b/config/locales/crowdin/es.yml index 9d03c0bb85bf..58f686c3615f 100644 --- a/config/locales/crowdin/es.yml +++ b/config/locales/crowdin/es.yml @@ -24,6 +24,22 @@ es: activities: index: no_results_title_text: No se ha producido ninguna actividad en el proyecto en este período de tiempo. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: Actualmente no hay ningún plugin instalado. @@ -205,7 +221,7 @@ es: blankslate: title: "Su lista de artículos está vacía" description: "Comience añadiendo elementos al campo personalizado de tipo jerarquía. Cada elemento puede utilizarse para crear una jerarquía debajo de él. Para navegar y crear subelementos dentro de una jerarquía, haga clic en el elemento creado." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2038,7 +2054,6 @@ es: label_check_uncheck_all_in_row: "Marcar/Desmarcar toda la fila" label_child_element: "Elementos secundarios" label_choices: "Opciones" - label_chronological_order: "Antiguos primero" label_close_versions: "Cerrar versiones completadas" label_closed_work_packages: "cerrado" label_collapse: "Colapsar" @@ -2404,7 +2419,6 @@ es: label_required: "requerido" label_requires: "requiere" label_result_plural: "Resultados" - label_reverse_chronological_order: "Recientes primero" label_revision: "Revisión" label_revision_id: "Revisión %{value}" label_revision_plural: "Revisiones" diff --git a/config/locales/crowdin/et.yml b/config/locales/crowdin/et.yml index e7f09652022f..3c4212068361 100644 --- a/config/locales/crowdin/et.yml +++ b/config/locales/crowdin/et.yml @@ -24,6 +24,22 @@ et: activities: index: no_results_title_text: There has not been any activity for the project within this time frame. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -208,7 +224,7 @@ et: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2041,7 +2057,6 @@ et: label_check_uncheck_all_in_row: "Check/Uncheck all in row" label_child_element: "Child element" label_choices: "Choices" - label_chronological_order: "Oldest first" label_close_versions: "Sule lõpetatud versioonid" label_closed_work_packages: "suletud" label_collapse: "Koonda" @@ -2407,7 +2422,6 @@ et: label_required: "required" label_requires: "requires" label_result_plural: "Tulemused" - label_reverse_chronological_order: "Newest first" label_revision: "Sissekanne" label_revision_id: "Sissekande kood %{value}" label_revision_plural: "Sissekanded" diff --git a/config/locales/crowdin/eu.yml b/config/locales/crowdin/eu.yml index 52f788e528a1..2e08d6cb168d 100644 --- a/config/locales/crowdin/eu.yml +++ b/config/locales/crowdin/eu.yml @@ -24,6 +24,22 @@ eu: activities: index: no_results_title_text: There has not been any activity for the project within this time frame. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -208,7 +224,7 @@ eu: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2041,7 +2057,6 @@ eu: label_check_uncheck_all_in_row: "Check/Uncheck all in row" label_child_element: "Child element" label_choices: "Choices" - label_chronological_order: "Oldest first" label_close_versions: "Close completed versions" label_closed_work_packages: "closed" label_collapse: "Collapse" @@ -2407,7 +2422,6 @@ eu: label_required: "required" label_requires: "requires" label_result_plural: "Results" - label_reverse_chronological_order: "Newest first" label_revision: "Revision" label_revision_id: "Revision %{value}" label_revision_plural: "Revisions" diff --git a/config/locales/crowdin/fa.yml b/config/locales/crowdin/fa.yml index 7adf029da3a0..858a2119ef6e 100644 --- a/config/locales/crowdin/fa.yml +++ b/config/locales/crowdin/fa.yml @@ -24,6 +24,22 @@ fa: activities: index: no_results_title_text: در این بازه زمانی عملیاتی برای این پروژه انجام نشده است. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -208,7 +224,7 @@ fa: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2041,7 +2057,6 @@ fa: label_check_uncheck_all_in_row: "Check/Uncheck all in row" label_child_element: "Child element" label_choices: "Choices" - label_chronological_order: "Oldest first" label_close_versions: "Close completed versions" label_closed_work_packages: "بسته" label_collapse: "فرو ریختن" @@ -2407,7 +2422,6 @@ fa: label_required: "required" label_requires: "requires" label_result_plural: "Results" - label_reverse_chronological_order: "Newest first" label_revision: "Revision" label_revision_id: "Revision %{value}" label_revision_plural: "Revisions" diff --git a/config/locales/crowdin/fi.yml b/config/locales/crowdin/fi.yml index ff8c0624478f..799408a67c92 100644 --- a/config/locales/crowdin/fi.yml +++ b/config/locales/crowdin/fi.yml @@ -24,6 +24,22 @@ fi: activities: index: no_results_title_text: Ei tapahtumia projektissa tällä aikavälillä. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -208,7 +224,7 @@ fi: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2041,7 +2057,6 @@ fi: label_check_uncheck_all_in_row: "Valitse/poista kaikki valinnat riviltä" label_child_element: "Alemman tason elementti" label_choices: "Choices" - label_chronological_order: "Oldest first" label_close_versions: "Sulje valmiit versiot" label_closed_work_packages: "suljettu" label_collapse: "Pienennä" @@ -2407,7 +2422,6 @@ fi: label_required: "Required" label_requires: "edellyttää" label_result_plural: "Tulokset" - label_reverse_chronological_order: "Newest first" label_revision: "Revisio" label_revision_id: "Revisio %{value}" label_revision_plural: "Revisiot" diff --git a/config/locales/crowdin/fil.yml b/config/locales/crowdin/fil.yml index 14222ddd3cae..b64413e1b740 100644 --- a/config/locales/crowdin/fil.yml +++ b/config/locales/crowdin/fil.yml @@ -24,6 +24,22 @@ fil: activities: index: no_results_title_text: May mga walang mga pagkikilos sa proyekto sa panahong ito. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -208,7 +224,7 @@ fil: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2041,7 +2057,6 @@ fil: label_check_uncheck_all_in_row: "I-check/I-uncheck ang lahat sa hilera" label_child_element: "Elementong bata" label_choices: "Choices" - label_chronological_order: "Oldest first" label_close_versions: "Isara ang nakompletong bersyon" label_closed_work_packages: "isinara" label_collapse: "Bumagsak" @@ -2407,7 +2422,6 @@ fil: label_required: "kailangan" label_requires: "kinakailangan" label_result_plural: "Mga resulta" - label_reverse_chronological_order: "Newest first" label_revision: "Rebisyon" label_revision_id: "Rebisyon %{value}" label_revision_plural: "Mga rebisyon" diff --git a/config/locales/crowdin/fr.yml b/config/locales/crowdin/fr.yml index bf5d67538d9a..07926b7f485a 100644 --- a/config/locales/crowdin/fr.yml +++ b/config/locales/crowdin/fr.yml @@ -24,6 +24,22 @@ fr: activities: index: no_results_title_text: Il n’y a pas eu d'activité pour le projet pendant cette période. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: Aucun plugin n'est actuellement installé. @@ -208,7 +224,7 @@ fr: blankslate: title: "Votre liste d'articles est vide" description: "Commencez par ajouter des éléments au champ personnalisé de type hiérarchie. Chaque élément peut être utilisé pour créer une hiérarchie en dessous de lui. Pour naviguer et créer des sous-éléments à l'intérieur d'une hiérarchie, cliquez sur l'élément créé." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2040,7 +2056,6 @@ fr: label_check_uncheck_all_in_row: "Cocher/Décocher tout dans la ligne" label_child_element: "Élément enfant" label_choices: "Choix" - label_chronological_order: "Le plus ancien en premier" label_close_versions: "Fermer les versions complètes" label_closed_work_packages: "clôturé" label_collapse: "Regrouper" @@ -2406,7 +2421,6 @@ fr: label_required: "requis" label_requires: "requiert" label_result_plural: "Résultats" - label_reverse_chronological_order: "Le plus récent en premier" label_revision: "Révision" label_revision_id: "Révision %{value}" label_revision_plural: "Révisions" diff --git a/config/locales/crowdin/he.yml b/config/locales/crowdin/he.yml index f9e947776734..4c9f90722be8 100644 --- a/config/locales/crowdin/he.yml +++ b/config/locales/crowdin/he.yml @@ -24,6 +24,22 @@ he: activities: index: no_results_title_text: בפרק זמן זה לא הייתה כל פעילות עבור הפרויקט. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -208,7 +224,7 @@ he: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2111,7 +2127,6 @@ he: label_check_uncheck_all_in_row: "Check/Uncheck all in row" label_child_element: "Child element" label_choices: "Choices" - label_chronological_order: "Oldest first" label_close_versions: "סגור גרסאות שהושלמו" label_closed_work_packages: "סגור" label_collapse: "צמצם" @@ -2477,7 +2492,6 @@ he: label_required: "required" label_requires: "requires" label_result_plural: "תוצאות" - label_reverse_chronological_order: "Newest first" label_revision: "Revision" label_revision_id: "Revision %{value}" label_revision_plural: "Revisions" diff --git a/config/locales/crowdin/hi.yml b/config/locales/crowdin/hi.yml index 2ab5d4b6ef54..1db35a3cb536 100644 --- a/config/locales/crowdin/hi.yml +++ b/config/locales/crowdin/hi.yml @@ -24,6 +24,22 @@ hi: activities: index: no_results_title_text: इस समय सीमा के भीतर परियोजना के लिए कोई भी गतिविधि नहीं हुई है । + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -208,7 +224,7 @@ hi: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2039,7 +2055,6 @@ hi: label_check_uncheck_all_in_row: "Check/Uncheck all in row" label_child_element: "Child element" label_choices: "Choices" - label_chronological_order: "Oldest first" label_close_versions: "पूर्ण संस्करण बंद करें" label_closed_work_packages: "बंद" label_collapse: "संक्षिप्त करें" @@ -2405,7 +2420,6 @@ hi: label_required: "required" label_requires: "requires" label_result_plural: "Results" - label_reverse_chronological_order: "Newest first" label_revision: "Revision" label_revision_id: "Revision %{value}" label_revision_plural: "Revisions" diff --git a/config/locales/crowdin/hr.yml b/config/locales/crowdin/hr.yml index 6ffdff09a126..038de1a70d3b 100644 --- a/config/locales/crowdin/hr.yml +++ b/config/locales/crowdin/hr.yml @@ -24,6 +24,22 @@ hr: activities: index: no_results_title_text: There has not been any activity for the project within this time frame. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -208,7 +224,7 @@ hr: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2076,7 +2092,6 @@ hr: label_check_uncheck_all_in_row: "Označi/Odznači sve u retku" label_child_element: "Podređeni element" label_choices: "Choices" - label_chronological_order: "Oldest first" label_close_versions: "Blizu finalne verzije" label_closed_work_packages: "zaključen" label_collapse: "Prikaži sve" @@ -2442,7 +2457,6 @@ hr: label_required: "potrebne" label_requires: "zahtijeva" label_result_plural: "Rezultati" - label_reverse_chronological_order: "Newest first" label_revision: "Revizija" label_revision_id: "Revizija %{value}" label_revision_plural: "Revizije" diff --git a/config/locales/crowdin/hu.yml b/config/locales/crowdin/hu.yml index 0857c0764412..b5d50035f541 100644 --- a/config/locales/crowdin/hu.yml +++ b/config/locales/crowdin/hu.yml @@ -24,6 +24,22 @@ hu: activities: index: no_results_title_text: Nem volt semmilyen tevékenység a projektben ebben az időszakban. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -207,7 +223,7 @@ hu: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2038,7 +2054,6 @@ hu: label_check_uncheck_all_in_row: "Megjelöl/Jelölést töröl minden sorban" label_child_element: "Gyermek alkotó" label_choices: "Choices" - label_chronological_order: "Régiek elől" label_close_versions: "Lezárja a befejezett változatokat" label_closed_work_packages: "lezárt" label_collapse: "Collapse" @@ -2404,7 +2419,6 @@ hu: label_required: "kötelező" label_requires: "igényel" label_result_plural: "Eredmények" - label_reverse_chronological_order: "Újak elől" label_revision: "Felülvizsgálat" label_revision_id: "Felülvizsgálat %{value}" label_revision_plural: "Felülvizsgálatok" diff --git a/config/locales/crowdin/id.yml b/config/locales/crowdin/id.yml index 604122bb68d0..8f1d2655b791 100644 --- a/config/locales/crowdin/id.yml +++ b/config/locales/crowdin/id.yml @@ -24,6 +24,22 @@ id: activities: index: no_results_title_text: Belum ada kegiatan untuk proyek dalam jangka waktu ini. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -202,7 +218,7 @@ id: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -1999,7 +2015,6 @@ id: label_check_uncheck_all_in_row: "Check/Uncheck all in row" label_child_element: "Sub-elemen" label_choices: "Choices" - label_chronological_order: "Terlama dulu" label_close_versions: "Tutup versi yang sudah komplit" label_closed_work_packages: "berakhir" label_collapse: "Tampilkan" @@ -2365,7 +2380,6 @@ id: label_required: "dibutuhkan" label_requires: "dibutuhkan" label_result_plural: "Hasil" - label_reverse_chronological_order: "Newest first" label_revision: "Revisi" label_revision_id: "Revisi %{value}" label_revision_plural: "Revisi" diff --git a/config/locales/crowdin/it.yml b/config/locales/crowdin/it.yml index b80d53faf894..956bc77ce9f9 100644 --- a/config/locales/crowdin/it.yml +++ b/config/locales/crowdin/it.yml @@ -24,6 +24,22 @@ it: activities: index: no_results_title_text: Non c'è stata alcuna attività per il progetto in questo lasso di tempo. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: Al momento non vi sono plugin installati. @@ -205,7 +221,7 @@ it: blankslate: title: "La tua lista di elementi è vuota" description: "Inizia aggiungendo elementi al campo personalizzato di tipo gerarchia. Ogni elemento può essere utilizzato per creare una gerarchia sottostante. Per navigare e creare sottoelementi all'interno di una gerarchia, fai clic sull'elemento creato." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2038,7 +2054,6 @@ it: label_check_uncheck_all_in_row: "Seleziona/deseleziona tutto nella riga" label_child_element: "Elemento figlio" label_choices: "Scelte" - label_chronological_order: "Prima i meno recenti" label_close_versions: "Chiudi le versioni complete" label_closed_work_packages: "chiuso" label_collapse: "Comprimi" @@ -2404,7 +2419,6 @@ it: label_required: "obbligatorio" label_requires: "richiede" label_result_plural: "Risultati" - label_reverse_chronological_order: "Prima i più recenti" label_revision: "Revisione" label_revision_id: "Revisione %{value}" label_revision_plural: "Revisioni" diff --git a/config/locales/crowdin/ja.yml b/config/locales/crowdin/ja.yml index 99e57d7935a3..5ed41d59b7c9 100644 --- a/config/locales/crowdin/ja.yml +++ b/config/locales/crowdin/ja.yml @@ -24,6 +24,22 @@ ja: activities: index: no_results_title_text: この期間内にプロジェクトの活動はありませんでした。 + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -206,7 +222,7 @@ ja: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2002,7 +2018,6 @@ ja: label_check_uncheck_all_in_row: "すべての行をチェック/チェックを外す" label_child_element: "子要素" label_choices: "Choices" - label_chronological_order: "Oldest first" label_close_versions: "完了したバージョンを終了にする" label_closed_work_packages: "完了" label_collapse: "折りたたむ" @@ -2368,7 +2383,6 @@ ja: label_required: "必須" label_requires: "要求" label_result_plural: "検索結果" - label_reverse_chronological_order: "Newest first" label_revision: "リビジョン" label_revision_id: "リビジョン%{value}" label_revision_plural: "リビジョン" diff --git a/config/locales/crowdin/js-zh-TW.yml b/config/locales/crowdin/js-zh-TW.yml index c0424ebab661..2e110a8ea0b0 100644 --- a/config/locales/crowdin/js-zh-TW.yml +++ b/config/locales/crowdin/js-zh-TW.yml @@ -687,7 +687,7 @@ zh-TW: description: "額外通知" date_alerts: title: "日期提醒" - description: "當「執行者、負責人或監看者」進行中的工作項目,其重要日期臨近時,自動發出通知。" + description: "當「執行者、負責人或監看者」之開啟的工作項目,其重要日期臨近時,自動發出通知。" teaser_text: "透過日期提醒,您將收到即將到來的開始或結束日期的通知,這樣您就不會錯過或忘記重要的截止日期。" overdue: 逾期 project_specific: @@ -1002,7 +1002,7 @@ zh-TW: created_by_me: "由我建立" assigned_to_me: "分配給我" recently_created: "最近創建的" - all_open: "所有「進行中」工作" + all_open: "所有 「開啟中」 工作" overdue: "逾期" summary: "總覽" shared_with_users: "成員參與的" diff --git a/config/locales/crowdin/ka.yml b/config/locales/crowdin/ka.yml index 593d209ceabb..fe2446faa2c8 100644 --- a/config/locales/crowdin/ka.yml +++ b/config/locales/crowdin/ka.yml @@ -24,6 +24,22 @@ ka: activities: index: no_results_title_text: There has not been any activity for the project within this time frame. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -208,7 +224,7 @@ ka: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2041,7 +2057,6 @@ ka: label_check_uncheck_all_in_row: "Check/Uncheck all in row" label_child_element: "შვილი ელემენტი" label_choices: "Choices" - label_chronological_order: "ჯერ უძველესი" label_close_versions: "Close completed versions" label_closed_work_packages: "დახურულია" label_collapse: "ჩახურვა" @@ -2407,7 +2422,6 @@ ka: label_required: "აუცილებელია" label_requires: "ითხოვს" label_result_plural: "შედეგები" - label_reverse_chronological_order: "ჯერ უახლესი" label_revision: "რევიზია" label_revision_id: "Revision %{value}" label_revision_plural: "რევიზიები" diff --git a/config/locales/crowdin/kk.yml b/config/locales/crowdin/kk.yml index 42ac13e733a6..e7c23c537093 100644 --- a/config/locales/crowdin/kk.yml +++ b/config/locales/crowdin/kk.yml @@ -24,6 +24,22 @@ kk: activities: index: no_results_title_text: There has not been any activity for the project within this time frame. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -208,7 +224,7 @@ kk: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2041,7 +2057,6 @@ kk: label_check_uncheck_all_in_row: "Check/Uncheck all in row" label_child_element: "Child element" label_choices: "Choices" - label_chronological_order: "Oldest first" label_close_versions: "Close completed versions" label_closed_work_packages: "closed" label_collapse: "Collapse" @@ -2407,7 +2422,6 @@ kk: label_required: "required" label_requires: "requires" label_result_plural: "Results" - label_reverse_chronological_order: "Newest first" label_revision: "Revision" label_revision_id: "Revision %{value}" label_revision_plural: "Revisions" diff --git a/config/locales/crowdin/ko.yml b/config/locales/crowdin/ko.yml index 7088f7c071ef..8c22a76c4a93 100644 --- a/config/locales/crowdin/ko.yml +++ b/config/locales/crowdin/ko.yml @@ -24,6 +24,22 @@ ko: activities: index: no_results_title_text: 이 시간 프레임 내에서 프로젝트에 대한 활동이 없습니다. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: 현재 설치된 플러그인이 없습니다. @@ -208,7 +224,7 @@ ko: blankslate: title: "항목 목록이 비어 있습니다" description: "먼저 유형 계층의 사용자 지정 필드에 항목을 추가하여 시작하세요. 각 항목을 사용하여 그 아래에 계층을 만들 수 있습니다. 계층 내에서 하위 항목을 탐색하고 만들려면 생성된 항목을 클릭하세요." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2005,7 +2021,6 @@ ko: label_check_uncheck_all_in_row: "모든 행에서 확인/취소" label_child_element: "자식 요소" label_choices: "선택 사항" - label_chronological_order: "오래된순" label_close_versions: "완료된 버전 닫기" label_closed_work_packages: "닫음" label_collapse: "축소" @@ -2371,7 +2386,6 @@ ko: label_required: "필수" label_requires: "필수" label_result_plural: "결과" - label_reverse_chronological_order: "최신순" label_revision: "수정" label_revision_id: "수정 %{value}" label_revision_plural: "수정" diff --git a/config/locales/crowdin/lt.yml b/config/locales/crowdin/lt.yml index 2d28d9e1e3a2..9bcbd7184b76 100644 --- a/config/locales/crowdin/lt.yml +++ b/config/locales/crowdin/lt.yml @@ -24,6 +24,22 @@ lt: activities: index: no_results_title_text: Šiuo periodu projekte nieko neįvyko. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: Šio metu nėra įdiegtų priedų. @@ -205,7 +221,7 @@ lt: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2108,7 +2124,6 @@ lt: label_check_uncheck_all_in_row: "Žymėti/atžymėti visus eilutėje" label_child_element: "Vaiko elementas" label_choices: "Pasirinkimai" - label_chronological_order: "Seniausias pirmas" label_close_versions: "Uždaryti užbaigtas versijas" label_closed_work_packages: "uždarytas" label_collapse: "Sutraukti" @@ -2474,7 +2489,6 @@ lt: label_required: "reikalingas" label_requires: "turi turėti" label_result_plural: "Rezultatai" - label_reverse_chronological_order: "Naujausias pirmas" label_revision: "Revizija" label_revision_id: "Revizija %{value}" label_revision_plural: "Revizijos" diff --git a/config/locales/crowdin/lv.yml b/config/locales/crowdin/lv.yml index 1ed6d1eedab5..4cc0f186b4da 100644 --- a/config/locales/crowdin/lv.yml +++ b/config/locales/crowdin/lv.yml @@ -24,6 +24,22 @@ lv: activities: index: no_results_title_text: There has not been any activity for the project within this time frame. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -208,7 +224,7 @@ lv: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2076,7 +2092,6 @@ lv: label_check_uncheck_all_in_row: "Izvēlēties/noņemt visus rindā" label_child_element: "Pakārtotais elements" label_choices: "Choices" - label_chronological_order: "Oldest first" label_close_versions: "Close completed versions" label_closed_work_packages: "slēgti" label_collapse: "Collapse" @@ -2442,7 +2457,6 @@ lv: label_required: "required" label_requires: "requires" label_result_plural: "Results" - label_reverse_chronological_order: "Newest first" label_revision: "Revision" label_revision_id: "Revision %{value}" label_revision_plural: "Revisions" diff --git a/config/locales/crowdin/mn.yml b/config/locales/crowdin/mn.yml index 7e380361aeb7..e4ced19229b5 100644 --- a/config/locales/crowdin/mn.yml +++ b/config/locales/crowdin/mn.yml @@ -24,6 +24,22 @@ mn: activities: index: no_results_title_text: There has not been any activity for the project within this time frame. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -208,7 +224,7 @@ mn: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2041,7 +2057,6 @@ mn: label_check_uncheck_all_in_row: "Check/Uncheck all in row" label_child_element: "Child element" label_choices: "Choices" - label_chronological_order: "Oldest first" label_close_versions: "Close completed versions" label_closed_work_packages: "closed" label_collapse: "Collapse" @@ -2407,7 +2422,6 @@ mn: label_required: "required" label_requires: "requires" label_result_plural: "Results" - label_reverse_chronological_order: "Newest first" label_revision: "Revision" label_revision_id: "Revision %{value}" label_revision_plural: "Revisions" diff --git a/config/locales/crowdin/ms.yml b/config/locales/crowdin/ms.yml index d111833a579b..21c6ec7427d1 100644 --- a/config/locales/crowdin/ms.yml +++ b/config/locales/crowdin/ms.yml @@ -24,6 +24,22 @@ ms: activities: index: no_results_title_text: Tiada sebarang aktiviti untuk projek dalam tempoh masa ini. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: Pada masa ini tiada sebarang plugin yang dipasang. @@ -207,7 +223,7 @@ ms: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2004,7 +2020,6 @@ ms: label_check_uncheck_all_in_row: "Tanda/Nyahtanda semua dalam baris" label_child_element: "Elemen anak" label_choices: "Pilihan" - label_chronological_order: "Paling lama dahulu" label_close_versions: "Tutup versi yang telah selesai" label_closed_work_packages: "ditutup" label_collapse: "Sembunyi" @@ -2370,7 +2385,6 @@ ms: label_required: "diperlukan" label_requires: "memerlukan" label_result_plural: "Keputusan" - label_reverse_chronological_order: "Terbaharu dahulu" label_revision: "Semakan" label_revision_id: "Semakan %{value}" label_revision_plural: "Semakan" diff --git a/config/locales/crowdin/ne.yml b/config/locales/crowdin/ne.yml index b8397cf21b40..e1ce42f8973f 100644 --- a/config/locales/crowdin/ne.yml +++ b/config/locales/crowdin/ne.yml @@ -24,6 +24,22 @@ ne: activities: index: no_results_title_text: यो अवधिमा आयोजनाको कुनै पनि क्रियाकलाप भएको छैन । + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -208,7 +224,7 @@ ne: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2041,7 +2057,6 @@ ne: label_check_uncheck_all_in_row: "Check/Uncheck all in row" label_child_element: "Child element" label_choices: "Choices" - label_chronological_order: "Oldest first" label_close_versions: "Close completed versions" label_closed_work_packages: "closed" label_collapse: "Collapse" @@ -2407,7 +2422,6 @@ ne: label_required: "required" label_requires: "requires" label_result_plural: "Results" - label_reverse_chronological_order: "Newest first" label_revision: "Revision" label_revision_id: "Revision %{value}" label_revision_plural: "Revisions" diff --git a/config/locales/crowdin/nl.yml b/config/locales/crowdin/nl.yml index 45639539406a..48dd2f87ae9e 100644 --- a/config/locales/crowdin/nl.yml +++ b/config/locales/crowdin/nl.yml @@ -24,6 +24,22 @@ nl: activities: index: no_results_title_text: Er is geen activiteit voor deze projecten binnen deze periode. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -205,7 +221,7 @@ nl: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2038,7 +2054,6 @@ nl: label_check_uncheck_all_in_row: "Selecteer/Deselecteer alles in rij" label_child_element: "Onderliggend element" label_choices: "Choices" - label_chronological_order: "Oudste eerst" label_close_versions: "Voltooide versies sluiten" label_closed_work_packages: "gesloten" label_collapse: "Samenvouwen" @@ -2404,7 +2419,6 @@ nl: label_required: "vereist" label_requires: "vereist" label_result_plural: "Resultaten" - label_reverse_chronological_order: "Nieuwste eerst" label_revision: "Revisie" label_revision_id: "Revisie %{value}" label_revision_plural: "Revisies" diff --git a/config/locales/crowdin/no.yml b/config/locales/crowdin/no.yml index a2fef01d4800..13c65a581469 100644 --- a/config/locales/crowdin/no.yml +++ b/config/locales/crowdin/no.yml @@ -24,6 +24,22 @@ activities: index: no_results_title_text: Det har ikke vært noen aktivitet i dette prosjektet i valgt periode + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: Det er for tiden ingen utvidelser installert. @@ -208,7 +224,7 @@ blankslate: title: "Din liste over elementer er tom" description: "Start ved å legge elementer til i det tilpassede feltet av typehierarkiet. Hvert element kan brukes til å opprette et hierarkisk buk på det. For å navigere og opprette underelementer i et hierarki, klikk på det opprettede elementet." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2040,7 +2056,6 @@ label_check_uncheck_all_in_row: "Sjekk/Avmerk alle på rad" label_child_element: "Underordnet element" label_choices: "Valg" - label_chronological_order: "Eldste først" label_close_versions: "Lukk fullførte versjoner" label_closed_work_packages: "lukket" label_collapse: "Skjul" @@ -2406,7 +2421,6 @@ label_required: "påkrevd" label_requires: "krever" label_result_plural: "Resultater" - label_reverse_chronological_order: "Nyeste først" label_revision: "Revisjon" label_revision_id: "Revisjon %{value}" label_revision_plural: "Revisjoner" diff --git a/config/locales/crowdin/pl.yml b/config/locales/crowdin/pl.yml index d0ee7c1a73c2..e75f666ba083 100644 --- a/config/locales/crowdin/pl.yml +++ b/config/locales/crowdin/pl.yml @@ -24,6 +24,22 @@ pl: activities: index: no_results_title_text: W tym okresie nie było żadnych działań związanych z projektem. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: Obecnie nie ma żadnych zainstalowanych wtyczek. @@ -205,7 +221,7 @@ pl: blankslate: title: "Twoja lista elementów jest pusta" description: "Zacznij od dodawania elementów do pola niestandardowego typu hierarchii. Każdy element może być użyty do tworzenia hierarchii poniżej niego. Aby nawigować i tworzyć podelementy wewnątrz hierarchii, kliknij utworzony element." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2108,7 +2124,6 @@ pl: label_check_uncheck_all_in_row: "Sprawdź/Odznacz wszystko w wierszu" label_child_element: "Element podrzędny" label_choices: "Wybory" - label_chronological_order: "Od najstarszych" label_close_versions: "Zamknij zakończone wersje" label_closed_work_packages: "Zamknięte" label_collapse: "Zwiń" @@ -2474,7 +2489,6 @@ pl: label_required: "wymagane" label_requires: "wymaga" label_result_plural: "Wyniki" - label_reverse_chronological_order: "Od najnowszych" label_revision: "Rewizja" label_revision_id: "Rewizja %{value}" label_revision_plural: "Rewizje" diff --git a/config/locales/crowdin/pt-BR.yml b/config/locales/crowdin/pt-BR.yml index b23b3c6ea1d2..fed4e04b5e9f 100644 --- a/config/locales/crowdin/pt-BR.yml +++ b/config/locales/crowdin/pt-BR.yml @@ -24,6 +24,22 @@ pt-BR: activities: index: no_results_title_text: Não há nenhuma atividade no projeto neste período de tempo. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: Atualmente, não existem plugins instalados. @@ -207,7 +223,7 @@ pt-BR: blankslate: title: "Sua lista de itens está vazia" description: "Comece adicionando itens ao campo personalizado do tipo hierarquia. Cada item pode ser utilizado para construir uma hierarquia abaixo dele. Para navegar e criar subitens dentro dessa hierarquia, clique no item que você criou." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2039,7 +2055,6 @@ pt-BR: label_check_uncheck_all_in_row: "Marcar/Desmarcar todos na linha" label_child_element: "Elemento filho" label_choices: "Escolhas" - label_chronological_order: "Mais antigo primeiro" label_close_versions: "Fechar versões concluídas" label_closed_work_packages: "fechado" label_collapse: "Recolher" @@ -2405,7 +2420,6 @@ pt-BR: label_required: "requerido" label_requires: "requer" label_result_plural: "Resultados" - label_reverse_chronological_order: "Mais recentes primeiro" label_revision: "Revisão" label_revision_id: "Revisão %{value}" label_revision_plural: "Revisões" diff --git a/config/locales/crowdin/pt-PT.yml b/config/locales/crowdin/pt-PT.yml index 656e0ff88dc1..f863922390b2 100644 --- a/config/locales/crowdin/pt-PT.yml +++ b/config/locales/crowdin/pt-PT.yml @@ -24,6 +24,22 @@ pt-PT: activities: index: no_results_title_text: Não ocorreu nenhuma atividade neste projeto dentro deste espaço de tempo. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: Atualmente não há plugins instalados. @@ -206,7 +222,7 @@ pt-PT: blankslate: title: "A sua lista de itens está vazia" description: "Comece por adicionar itens ao campo personalizado do tipo hierarquia. Cada item pode ser utilizado para criar uma hierarquia abaixo dele. Para navegar e criar subitens dentro de uma hierarquia, clique no item criado." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2039,7 +2055,6 @@ pt-PT: label_check_uncheck_all_in_row: "Marcar/desmarcar tudo na linha" label_child_element: "Elemento filho" label_choices: "Escolhas" - label_chronological_order: "Mais antigas primeiro" label_close_versions: "Fechar versões concluídas" label_closed_work_packages: "fechado" label_collapse: "Colapso" @@ -2405,7 +2420,6 @@ pt-PT: label_required: "necessário" label_requires: "necessita" label_result_plural: "Resultados" - label_reverse_chronological_order: "Mais recentes primeiro" label_revision: "Revisão" label_revision_id: "Revisão %{value}" label_revision_plural: "Revisões" diff --git a/config/locales/crowdin/ro.yml b/config/locales/crowdin/ro.yml index 1d76b63e957a..c02ae2a66dae 100644 --- a/config/locales/crowdin/ro.yml +++ b/config/locales/crowdin/ro.yml @@ -24,6 +24,22 @@ ro: activities: index: no_results_title_text: Nu s-a înregistrat nicio activitate pentru acest proiect în această perioadă de timp. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -208,7 +224,7 @@ ro: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2076,7 +2092,6 @@ ro: label_check_uncheck_all_in_row: "Bifare/debifare toate pe rând" label_child_element: "Element copil" label_choices: "Choices" - label_chronological_order: "Întâi cele mai vechi" label_close_versions: "Închidere versiuni complete" label_closed_work_packages: "închis" label_collapse: "Restrângere" @@ -2442,7 +2457,6 @@ ro: label_required: "obligatoriu" label_requires: "Această caracteristică necesită un cont la" label_result_plural: "Rezultate" - label_reverse_chronological_order: "Întâi cele mai noi" label_revision: "Revizie" label_revision_id: "Revizia %{value}" label_revision_plural: "Revizii" diff --git a/config/locales/crowdin/ru.yml b/config/locales/crowdin/ru.yml index 6b460ced005f..0903a132e2e4 100644 --- a/config/locales/crowdin/ru.yml +++ b/config/locales/crowdin/ru.yml @@ -24,6 +24,22 @@ ru: activities: index: no_results_title_text: В течение этого периода времени не было никакой деятельности по проекту. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: В настоящее время плагины не установлены. @@ -207,7 +223,7 @@ ru: blankslate: title: "Ваш список элементов пуст" description: "Начните с добавления элементов в настраиваемое поле иерархии. Каждый элемент может быть использован для создания иерархии ниже его. Чтобы переместить и создать подпункты внутри иерархии, нажмите на созданный элемент." - more_actions: "Еще действия" + actions: "Item actions" placeholder: label: "Название элемента" short: "Краткое имя" @@ -2110,7 +2126,6 @@ ru: label_check_uncheck_all_in_row: "Поставить/снять все флажки в строке" label_child_element: "Дочерний элемент" label_choices: "Выбор" - label_chronological_order: "Самый старый в начале" label_close_versions: "Закрыть завершённые этапы" label_closed_work_packages: "закрыт" label_collapse: "свернуть" @@ -2476,7 +2491,6 @@ ru: label_required: "требуется" label_requires: "требуется" label_result_plural: "Результаты" - label_reverse_chronological_order: "Самый новый в начале" label_revision: "Редакция" label_revision_id: "Редакция %{value}" label_revision_plural: "Редакции" diff --git a/config/locales/crowdin/rw.yml b/config/locales/crowdin/rw.yml index e98d68970e41..d3cec9675f4b 100644 --- a/config/locales/crowdin/rw.yml +++ b/config/locales/crowdin/rw.yml @@ -24,6 +24,22 @@ rw: activities: index: no_results_title_text: There has not been any activity for the project within this time frame. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -208,7 +224,7 @@ rw: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2041,7 +2057,6 @@ rw: label_check_uncheck_all_in_row: "Check/Uncheck all in row" label_child_element: "Child element" label_choices: "Choices" - label_chronological_order: "Oldest first" label_close_versions: "Close completed versions" label_closed_work_packages: "closed" label_collapse: "Collapse" @@ -2407,7 +2422,6 @@ rw: label_required: "required" label_requires: "requires" label_result_plural: "Results" - label_reverse_chronological_order: "Newest first" label_revision: "Revision" label_revision_id: "Revision %{value}" label_revision_plural: "Revisions" diff --git a/config/locales/crowdin/si.yml b/config/locales/crowdin/si.yml index 02b50431331e..4cf5f76f5100 100644 --- a/config/locales/crowdin/si.yml +++ b/config/locales/crowdin/si.yml @@ -24,6 +24,22 @@ si: activities: index: no_results_title_text: මෙම කාල රාමුව තුළ ව්යාපෘතිය සඳහා කිසිදු ක්රියාකාරකමක් සිදු වී නොමැත. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -208,7 +224,7 @@ si: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2041,7 +2057,6 @@ si: label_check_uncheck_all_in_row: "පේළියේ සියලු පරීක්ෂා කරන්න/ඉවත් කරන්න" label_child_element: "ළමා අංගයක්" label_choices: "Choices" - label_chronological_order: "Oldest first" label_close_versions: "අවසන් කරන ලද අනුවාද" label_closed_work_packages: "වසා" label_collapse: "බිඳවැටීම" @@ -2407,7 +2422,6 @@ si: label_required: "අවශ්ය" label_requires: "අවශ්ය" label_result_plural: "ප්රතිඵල" - label_reverse_chronological_order: "Newest first" label_revision: "සංශෝධනය" label_revision_id: "සංශෝධනය %{value}" label_revision_plural: "සංශෝධන" diff --git a/config/locales/crowdin/sk.yml b/config/locales/crowdin/sk.yml index 19572a822792..5f5ab1294fc7 100644 --- a/config/locales/crowdin/sk.yml +++ b/config/locales/crowdin/sk.yml @@ -24,6 +24,22 @@ sk: activities: index: no_results_title_text: Počas tohto časového obdobia nebola vykonaná žiadna aktivita na projekte. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -208,7 +224,7 @@ sk: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2111,7 +2127,6 @@ sk: label_check_uncheck_all_in_row: "Označiť/zrušiť označenie všetkých v riadku" label_child_element: "Prvok - potomok" label_choices: "Choices" - label_chronological_order: "Oldest first" label_close_versions: "Zavrieť ukončené verzie" label_closed_work_packages: "uzatvorené" label_collapse: "Zbaliť" @@ -2477,7 +2492,6 @@ sk: label_required: "požadované" label_requires: "vyžaduje" label_result_plural: "Výsledky" - label_reverse_chronological_order: "Newest first" label_revision: "Revízia" label_revision_id: "Revízia %{value}" label_revision_plural: "Revízie" diff --git a/config/locales/crowdin/sl.yml b/config/locales/crowdin/sl.yml index 8c1c842bd23b..c4852130d3f8 100644 --- a/config/locales/crowdin/sl.yml +++ b/config/locales/crowdin/sl.yml @@ -24,6 +24,22 @@ sl: activities: index: no_results_title_text: Na projektu ni bilo nobene aktivnosti v izbranem časovnem okviru + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -207,7 +223,7 @@ sl: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2108,7 +2124,6 @@ sl: label_check_uncheck_all_in_row: "Potrdite / počistite vse vrstice" label_child_element: "Podrejeni elementi" label_choices: "Choices" - label_chronological_order: "Oldest first" label_close_versions: "Zapri dokončane različice" label_closed_work_packages: "Zaprto" label_collapse: "Strni" @@ -2474,7 +2489,6 @@ sl: label_required: "Zahtevano" label_requires: "Potrebuje" label_result_plural: "Rezultati" - label_reverse_chronological_order: "Newest first" label_revision: "Revizija" label_revision_id: "Revizija %{value}" label_revision_plural: "Revizije" diff --git a/config/locales/crowdin/sr.yml b/config/locales/crowdin/sr.yml index 42b6a8a66670..62edcfdebd34 100644 --- a/config/locales/crowdin/sr.yml +++ b/config/locales/crowdin/sr.yml @@ -24,6 +24,22 @@ sr: activities: index: no_results_title_text: There has not been any activity for the project within this time frame. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -208,7 +224,7 @@ sr: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2076,7 +2092,6 @@ sr: label_check_uncheck_all_in_row: "Check/Uncheck all in row" label_child_element: "Child element" label_choices: "Choices" - label_chronological_order: "Oldest first" label_close_versions: "Close completed versions" label_closed_work_packages: "closed" label_collapse: "Collapse" @@ -2442,7 +2457,6 @@ sr: label_required: "required" label_requires: "requires" label_result_plural: "Results" - label_reverse_chronological_order: "Newest first" label_revision: "Revision" label_revision_id: "Revision %{value}" label_revision_plural: "Revisions" diff --git a/config/locales/crowdin/sv.yml b/config/locales/crowdin/sv.yml index e9f1cff01069..672f27bcc09b 100644 --- a/config/locales/crowdin/sv.yml +++ b/config/locales/crowdin/sv.yml @@ -24,6 +24,22 @@ sv: activities: index: no_results_title_text: Det har inte förekommit någon aktivitet för projektet inom denna tidsram. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -208,7 +224,7 @@ sv: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2040,7 +2056,6 @@ sv: label_check_uncheck_all_in_row: "Markera/avmarkera allt på raden" label_child_element: "Underordnade element" label_choices: "Choices" - label_chronological_order: "Oldest first" label_close_versions: "Stäng slutförda versioner" label_closed_work_packages: "stängt" label_collapse: "Fäll ihop" @@ -2406,7 +2421,6 @@ sv: label_required: "obligatorisk" label_requires: "kräver" label_result_plural: "Resultat" - label_reverse_chronological_order: "Newest first" label_revision: "Revision" label_revision_id: "Revision %{value}" label_revision_plural: "Revisioner" diff --git a/config/locales/crowdin/th.yml b/config/locales/crowdin/th.yml index cd91ced570e4..c439ccff3efa 100644 --- a/config/locales/crowdin/th.yml +++ b/config/locales/crowdin/th.yml @@ -24,6 +24,22 @@ th: activities: index: no_results_title_text: ไม่มีกิจกรรมสำหรับโปรเจคนี้ + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -208,7 +224,7 @@ th: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2006,7 +2022,6 @@ th: label_check_uncheck_all_in_row: "เลือก/ไม่เลือก ทั้งหมดในแถว" label_child_element: "Child element" label_choices: "Choices" - label_chronological_order: "Oldest first" label_close_versions: "ปิดเวอร์ชั่นที่เสร็จแล้ว" label_closed_work_packages: "ปิด" label_collapse: "ยุบ" @@ -2372,7 +2387,6 @@ th: label_required: "required" label_requires: "requires" label_result_plural: "ผลลัพธ์" - label_reverse_chronological_order: "Newest first" label_revision: "การปรับปรุง" label_revision_id: "ปรับปรุงค่า %{value}" label_revision_plural: "การปรับปรุง" diff --git a/config/locales/crowdin/tr.yml b/config/locales/crowdin/tr.yml index 093a7d5a1150..eecb7e03868f 100644 --- a/config/locales/crowdin/tr.yml +++ b/config/locales/crowdin/tr.yml @@ -24,6 +24,22 @@ tr: activities: index: no_results_title_text: Bu zaman dilimi içerisinde proje için herhangi bir faaliyet olmamıştır. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -208,7 +224,7 @@ tr: blankslate: title: "Öğeler listeniz boş" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2040,7 +2056,6 @@ tr: label_check_uncheck_all_in_row: "Sıralı tümü işaretle / işaretini kaldır" label_child_element: "Alt eleman" label_choices: "Choices" - label_chronological_order: "Önce en eski" label_close_versions: "Tamamlanmış sürümleri kapat" label_closed_work_packages: "kapalı" label_collapse: "Daralt" @@ -2406,7 +2421,6 @@ tr: label_required: "gerekli" label_requires: "gerektirir" label_result_plural: "Sonuçlar" - label_reverse_chronological_order: "Önce en yeni" label_revision: "Gözden geçirme" label_revision_id: "Düzeltme %{value}" label_revision_plural: "Düzeltmeler" diff --git a/config/locales/crowdin/uk.yml b/config/locales/crowdin/uk.yml index 1f0ae3fd51f3..5c657f313162 100644 --- a/config/locales/crowdin/uk.yml +++ b/config/locales/crowdin/uk.yml @@ -24,6 +24,22 @@ uk: activities: index: no_results_title_text: Протягом цього часу не було жодної діяльності для проекту. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: Зараз немає встановлених плагінів. @@ -203,7 +219,7 @@ uk: blankslate: title: "Ваш список елементів порожній" description: "Спочатку додайте елементи до користувацького поля ієрархії типів. Створити ієрархію можна для кожного елемента. Щоб переходити між піделементами ієрархії і додавати\nїх, натисніть створений елемент." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2105,7 +2121,6 @@ uk: label_check_uncheck_all_in_row: "Позначте/зніміть всі прапорці в рядку" label_child_element: "Дочірній елемент" label_choices: "Варіанти вибору" - label_chronological_order: "Спочатку старіші" label_close_versions: "Закрити завершені версії" label_closed_work_packages: "зачинено" label_collapse: "Згорнути" @@ -2471,7 +2486,6 @@ uk: label_required: "вимагається" label_requires: "вимагається" label_result_plural: "Результати" - label_reverse_chronological_order: "Спочатку нові" label_revision: "Ревізія" label_revision_id: "Ревізія %{value}" label_revision_plural: "Ревізії" diff --git a/config/locales/crowdin/uz.yml b/config/locales/crowdin/uz.yml index 1cdd461e4b83..8f5e5cae79c5 100644 --- a/config/locales/crowdin/uz.yml +++ b/config/locales/crowdin/uz.yml @@ -24,6 +24,22 @@ uz: activities: index: no_results_title_text: There has not been any activity for the project within this time frame. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: There are currently no plugins installed. @@ -208,7 +224,7 @@ uz: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2041,7 +2057,6 @@ uz: label_check_uncheck_all_in_row: "Check/Uncheck all in row" label_child_element: "Child element" label_choices: "Choices" - label_chronological_order: "Oldest first" label_close_versions: "Close completed versions" label_closed_work_packages: "closed" label_collapse: "Collapse" @@ -2407,7 +2422,6 @@ uz: label_required: "required" label_requires: "requires" label_result_plural: "Results" - label_reverse_chronological_order: "Newest first" label_revision: "Revision" label_revision_id: "Revision %{value}" label_revision_plural: "Revisions" diff --git a/config/locales/crowdin/vi.yml b/config/locales/crowdin/vi.yml index 3470df59dbe8..9185f587e8d5 100644 --- a/config/locales/crowdin/vi.yml +++ b/config/locales/crowdin/vi.yml @@ -24,6 +24,22 @@ vi: activities: index: no_results_title_text: Đã không có bất kỳ hoạt động nào cho dự án trong khung thời gian này. + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: Hiện tại không có plugin nào được cài đặt. @@ -210,7 +226,7 @@ vi: blankslate: title: "Your list of items is empty" description: "Start by adding items to the custom field of type hierarchy. Each item can be used to create a hierarchy bellow it. To navigate and create sub-items inside a hierarchy click on the created item." - more_actions: "More actions" + actions: "Item actions" placeholder: label: "Item label" short: "Short name" @@ -2008,7 +2024,6 @@ vi: label_check_uncheck_all_in_row: "Chọn/Bỏ chọn tất cả trong hàng" label_child_element: "Phần tử con" label_choices: "Lựa chọn" - label_chronological_order: "Cũ nhất trước" label_close_versions: "Đóng các phiên bản đã hoàn thành" label_closed_work_packages: "đã đóng" label_collapse: "Thu gọn" @@ -2374,7 +2389,6 @@ vi: label_required: "bắt buộc" label_requires: "bắt buộc" label_result_plural: "Các kết quả" - label_reverse_chronological_order: "Mới nhất trước" label_revision: "Sửa đổi" label_revision_id: "Sửa đổi %{value}" label_revision_plural: "Sửa đổi" diff --git a/config/locales/crowdin/zh-CN.yml b/config/locales/crowdin/zh-CN.yml index 601356102178..cb45827e8f72 100644 --- a/config/locales/crowdin/zh-CN.yml +++ b/config/locales/crowdin/zh-CN.yml @@ -24,6 +24,22 @@ zh-CN: activities: index: no_results_title_text: 在这个时间范围内没有任何项目的活动。 + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: 目前没有安装插件。 @@ -205,7 +221,7 @@ zh-CN: blankslate: title: "您的条目列表为空" description: "首先,将条目添加到类型层次结构的自定义字段中。每个条目都可以用于在其下方创建一个层次结构。要导航并在层次结构中创建子条目,请点击已创建的条目。" - more_actions: "更多操作" + actions: "Item actions" placeholder: label: "项目标签" short: "短名" @@ -2001,7 +2017,6 @@ zh-CN: label_check_uncheck_all_in_row: "选中/取消选中所有行" label_child_element: "子元素" label_choices: "选择" - label_chronological_order: "最早的优先" label_close_versions: "关闭已完成版本" label_closed_work_packages: "关闭" label_collapse: "折叠" @@ -2367,7 +2382,6 @@ zh-CN: label_required: "必填" label_requires: "要求" label_result_plural: "结果" - label_reverse_chronological_order: "最新的优先" label_revision: "修订" label_revision_id: "修订版本 %{value}" label_revision_plural: "修订" diff --git a/config/locales/crowdin/zh-TW.yml b/config/locales/crowdin/zh-TW.yml index dba044757abd..d27658a5f0bb 100644 --- a/config/locales/crowdin/zh-TW.yml +++ b/config/locales/crowdin/zh-TW.yml @@ -24,6 +24,22 @@ zh-TW: activities: index: no_results_title_text: 在這一時限內, 該專案沒有任何活動。 + work_packages: + activity_tab: + no_results_title_text: No activity to display + no_results_description_text: "Choose \"Show everything\" to show all activity and comments" + label_activity_show_all: "Show everything" + label_activity_show_only_comments: "Show comments only" + label_activity_show_only_changes: "Show changes only" + label_sort_asc: "Newest at the bottom" + label_sort_desc: "Newest on top" + label_type_to_comment: "Type here to comment" + label_submit_comment: "Submit comment" + changed_on: "changed on" + created_on: "created this on" + changed: "changed" + created: "created" + commented: "commented" admin: plugins: no_results_title_text: 目前沒有安裝任何外掛程式 @@ -207,7 +223,7 @@ zh-TW: blankslate: title: "您的欄位清單是空的" description: "首先將項目新增至層次結構的自訂欄位。每個項目都可用於在其下方建立一個層次結構。若要在層次結構內導覽和建立子項目,請按一下已建立的項目。" - more_actions: "更多操作" + actions: "Item actions" placeholder: label: "項目標籤" short: "簡稱" @@ -741,7 +757,7 @@ zh-TW: hours: "小時" spent_on: "日期" type: "類型" - ongoing: "進行中" + ongoing: "開啟中" type: description: "描述的預設文字" attribute_groups: "" @@ -2003,7 +2019,6 @@ zh-TW: label_check_uncheck_all_in_row: "選取/取消選取 全部" label_child_element: "子項目" label_choices: "選項" - label_chronological_order: "由舊排列至新" label_close_versions: "關閉已完成的版本" label_closed_work_packages: "已關閉" label_collapse: "收合" @@ -2273,8 +2288,8 @@ zh-TW: label_operator_equals_all: "包含" label_operator_shared_with_user_any: "任何" label_open_menu: "開啟選單" - label_open_work_packages: "進行中" - label_open_work_packages_plural: "進行中" + label_open_work_packages: "開啟中" + label_open_work_packages_plural: "開啟中" label_openproject_website: "OpenProject 網站" label_optional_description: "說明" label_options: "選項" @@ -2369,7 +2384,6 @@ zh-TW: label_required: "必要" label_requires: "需要" label_result_plural: "結果" - label_reverse_chronological_order: "由新排列至舊" label_revision: "修訂" label_revision_id: "修訂版 %{value}" label_revision_plural: "修訂" @@ -2519,7 +2533,7 @@ zh-TW: other: "%{count} 個留言" zero: "沒有留言" label_x_open_work_packages_abbr: - one: "1 進行中" + one: "1 開啟中" other: "%{count} 個未完成" zero: "0 個已開啟" label_x_work_packages: diff --git a/modules/bim/config/locales/crowdin/ru.seeders.yml b/modules/bim/config/locales/crowdin/ru.seeders.yml index 317d4873cb3e..e8fd2874e766 100644 --- a/modules/bim/config/locales/crowdin/ru.seeders.yml +++ b/modules/bim/config/locales/crowdin/ru.seeders.yml @@ -655,11 +655,11 @@ ru: description: Это краткое описание целей этого демонстрационного проекта по управлению BCF. ifc_models: item_0: - name: Hospital - Architecture (cc-by-sa-3.0 Autodesk Inc.) + name: Больница - Архитектура (CC-BY-SA-3.0 Autodesk Inc.) item_1: name: Hospital - Structural (cc-by-sa-3.0 Autodesk Inc.) item_2: - name: Hospital - Mechanical (cc-by-sa-3.0 Autodesk Inc.) + name: Больница - Механика (CC-BY-SA-3.0 Autodesk Inc.) categories: item_0: Категория 1 (будет изменена в настройках проекта) queries: diff --git a/modules/reporting/config/locales/crowdin/zh-TW.yml b/modules/reporting/config/locales/crowdin/zh-TW.yml index 22d0c7269be0..840da7555707 100644 --- a/modules/reporting/config/locales/crowdin/zh-TW.yml +++ b/modules/reporting/config/locales/crowdin/zh-TW.yml @@ -51,7 +51,7 @@ zh-TW: label_money: "現金價值" label_month_reporting: "月" label_new_report: "新建成本報表" - label_open: "進行中" + label_open: "開啟中" label_operator: "操作員" label_private_report_plural: "私密成本報告" label_progress_bar_explanation: "產生報告中..." From ffbb2467d32f99101f1ae01a6c7d7de9ad349365 Mon Sep 17 00:00:00 2001 From: OpenProject Actions CI Date: Fri, 18 Oct 2024 03:18:00 +0000 Subject: [PATCH 034/115] update locales from crowdin [ci skip] --- config/locales/crowdin/js-zh-TW.yml | 4 ++-- config/locales/crowdin/zh-TW.yml | 8 ++++---- modules/bim/config/locales/crowdin/ru.seeders.yml | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/config/locales/crowdin/js-zh-TW.yml b/config/locales/crowdin/js-zh-TW.yml index c0424ebab661..2e110a8ea0b0 100644 --- a/config/locales/crowdin/js-zh-TW.yml +++ b/config/locales/crowdin/js-zh-TW.yml @@ -687,7 +687,7 @@ zh-TW: description: "額外通知" date_alerts: title: "日期提醒" - description: "當「執行者、負責人或監看者」進行中的工作項目,其重要日期臨近時,自動發出通知。" + description: "當「執行者、負責人或監看者」之開啟的工作項目,其重要日期臨近時,自動發出通知。" teaser_text: "透過日期提醒,您將收到即將到來的開始或結束日期的通知,這樣您就不會錯過或忘記重要的截止日期。" overdue: 逾期 project_specific: @@ -1002,7 +1002,7 @@ zh-TW: created_by_me: "由我建立" assigned_to_me: "分配給我" recently_created: "最近創建的" - all_open: "所有「進行中」工作" + all_open: "所有 「開啟中」 工作" overdue: "逾期" summary: "總覽" shared_with_users: "成員參與的" diff --git a/config/locales/crowdin/zh-TW.yml b/config/locales/crowdin/zh-TW.yml index 8c48f3e45d72..839b1ad26618 100644 --- a/config/locales/crowdin/zh-TW.yml +++ b/config/locales/crowdin/zh-TW.yml @@ -723,7 +723,7 @@ zh-TW: hours: "小時" spent_on: "日期" type: "類型" - ongoing: "進行中" + ongoing: "開啟中" type: description: "描述的預設文字" attribute_groups: "" @@ -2246,8 +2246,8 @@ zh-TW: label_operator_equals_all: "包含" label_operator_shared_with_user_any: "任何" label_open_menu: "開啟選單" - label_open_work_packages: "進行中" - label_open_work_packages_plural: "進行中" + label_open_work_packages: "開啟中" + label_open_work_packages_plural: "開啟中" label_openproject_website: "OpenProject 網站" label_optional_description: "說明" label_options: "選項" @@ -2492,7 +2492,7 @@ zh-TW: other: "%{count} 個留言" zero: "沒有留言" label_x_open_work_packages_abbr: - one: "1 進行中" + one: "1 開啟中" other: "%{count} 個未完成" zero: "0 個已開啟" label_x_work_packages: diff --git a/modules/bim/config/locales/crowdin/ru.seeders.yml b/modules/bim/config/locales/crowdin/ru.seeders.yml index 317d4873cb3e..e8fd2874e766 100644 --- a/modules/bim/config/locales/crowdin/ru.seeders.yml +++ b/modules/bim/config/locales/crowdin/ru.seeders.yml @@ -655,11 +655,11 @@ ru: description: Это краткое описание целей этого демонстрационного проекта по управлению BCF. ifc_models: item_0: - name: Hospital - Architecture (cc-by-sa-3.0 Autodesk Inc.) + name: Больница - Архитектура (CC-BY-SA-3.0 Autodesk Inc.) item_1: name: Hospital - Structural (cc-by-sa-3.0 Autodesk Inc.) item_2: - name: Hospital - Mechanical (cc-by-sa-3.0 Autodesk Inc.) + name: Больница - Механика (CC-BY-SA-3.0 Autodesk Inc.) categories: item_0: Категория 1 (будет изменена в настройках проекта) queries: From 94e2ee58bb11b641028b0e009a72b53b1ebb6628 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Oct 2024 05:10:11 +0000 Subject: [PATCH 035/115] Bump lefthook from 1.7.18 to 1.7.21 Bumps [lefthook](https://github.com/evilmartians/lefthook) from 1.7.18 to 1.7.21. - [Release notes](https://github.com/evilmartians/lefthook/releases) - [Changelog](https://github.com/evilmartians/lefthook/blob/master/CHANGELOG.md) - [Commits](https://github.com/evilmartians/lefthook/compare/v1.7.18...v1.7.21) --- updated-dependencies: - dependency-name: lefthook dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index f082e37caf41..376651b40c4d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -701,7 +701,7 @@ GEM launchy (3.0.1) addressable (~> 2.8) childprocess (~> 5.0) - lefthook (1.7.18) + lefthook (1.7.21) letter_opener (1.10.0) launchy (>= 2.2, < 4) letter_opener_web (3.0.0) From bd9a56a5666de332f9d49095edd4a8c772492abc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Oct 2024 05:15:16 +0000 Subject: [PATCH 036/115] Bump axe-core-rspec from 4.10.0 to 4.10.1 Bumps [axe-core-rspec](https://github.com/dequelabs/axe-core-gems) from 4.10.0 to 4.10.1. - [Release notes](https://github.com/dequelabs/axe-core-gems/releases) - [Changelog](https://github.com/dequelabs/axe-core-gems/blob/develop/CHANGELOG.md) - [Commits](https://github.com/dequelabs/axe-core-gems/compare/v4.10.0...v4.10.1) --- updated-dependencies: - dependency-name: axe-core-rspec dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index f082e37caf41..5c461bdf2b82 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -360,12 +360,14 @@ GEM aws-sigv4 (~> 1.5) aws-sigv4 (1.10.0) aws-eventstream (~> 1, >= 1.0.2) - axe-core-api (4.10.0) + axe-core-api (4.10.1) dumb_delegator + ostruct virtus - axe-core-rspec (4.10.0) - axe-core-api (= 4.10.0) + axe-core-rspec (4.10.1) + axe-core-api (= 4.10.1) dumb_delegator + ostruct virtus axiom-types (0.1.1) descendants_tracker (~> 0.0.4) From a4f6777ce13768ad0ef069d77a3e134434fe409f Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Fri, 18 Oct 2024 09:46:39 +0200 Subject: [PATCH 037/115] [#52147] favorite column provides an action menu --- .../projects/table_component.html.erb | 20 +++++++-------- app/helpers/sort_helper.rb | 25 +++++++++++++------ 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/app/components/projects/table_component.html.erb b/app/components/projects/table_component.html.erb index 8386cc0afb61..d94300d71d6f 100644 --- a/app/components/projects/table_component.html.erb +++ b/app/components/projects/table_component.html.erb @@ -54,16 +54,16 @@ See COPYRIGHT and LICENSE files for more details.
- <% elsif column.attribute == :favored %> - -
-
- - <%= render(Primer::Beta::Octicon.new(icon: "star-fill", color: :subtle, "aria-label": I18n.t(:label_favorite))) %> - -
-
- + <%# elsif column.attribute == :favored %> + + + + + <%#= render(Primer::Beta::Octicon.new(icon: "star-fill", color: :subtle, "aria-label": I18n.t(:label_favorite))) %> + + + + <% else %> <%= quick_action_table_header column.attribute, order_options(column, turbo: true) %> <% end %> diff --git a/app/helpers/sort_helper.rb b/app/helpers/sort_helper.rb index 1d195b4c13d5..0b2522c4c76e 100644 --- a/app/helpers/sort_helper.rb +++ b/app/helpers/sort_helper.rb @@ -348,7 +348,12 @@ def with_sort_header_options(column, allowed_params: nil, **options) options[:title] = sort_header_title(column, caption, options) - within_sort_header_tag_hierarchy(options, sort_class(column)) do + additional_classes = "" + if column == :favored + additional_classes = "generic-table--header_centered generic-table--header_no-min-width" + end + + within_sort_header_tag_hierarchy(options, sort_class(column), additional_classes) do yield(column, caption, default_order, allowed_params:, param:, lang:, title: options[:title], sortable: options.fetch(:sortable, false), data:) end @@ -399,7 +404,7 @@ def action_menu(column, table_columns, caption, default_order, filter_column_map content_args = html_options.merge(rel: :nofollow, param: nil) render Primer::Alpha::ActionMenu.new(menu_id: "menu-#{column}") do |menu| - action_button(menu, caption) + action_button(menu, caption, favorite: column == :favored) # Some columns are not sortable or do not offer a suitable filter. Omit those actions for them. sort_actions(menu, column, default_order, content_args:, allowed_params:, **html_options) if sortable @@ -410,12 +415,18 @@ def action_menu(column, table_columns, caption, default_order, filter_column_map end end - def action_button(menu, caption) + def action_button(menu, caption, favorite: false) menu.with_show_button(scheme: :link, color: :default, text_transform: :uppercase, underline: false, display: :inline_flex, classes: "generic-table--action-menu-button") do |button| - button.with_trailing_action_icon(icon: :"triangle-down") - h(caption).to_s + if favorite + # This column only shows an icon, no text. + render Primer::Beta::Octicon.new(icon: "star-fill", color: :subtle, "aria-label": I18n.t(:label_favorite)) + else + button.with_trailing_action_icon(icon: :"triangle-down") + + h(caption).to_s + end end end @@ -562,10 +573,10 @@ def order_string(column, inverted: false) end end - def within_sort_header_tag_hierarchy(options, classes, &) + def within_sort_header_tag_hierarchy(options, classes, sort_header_classes = "", &) content_tag "th", options do content_tag "div", class: "generic-table--sort-header-outer" do - content_tag "div", class: "generic-table--sort-header" do + content_tag "div", class: "generic-table--sort-header #{sort_header_classes}" do content_tag("span", class: classes, &) end end From ea605553b35eaeb6fa436bea15b4d6fd7e51226e Mon Sep 17 00:00:00 2001 From: ulferts Date: Fri, 18 Oct 2024 10:43:47 +0200 Subject: [PATCH 038/115] use utitilty table also for vaditity_period writing --- ...8151123_add_validity_period_to_journals.rb | 76 +++++++++---------- 1 file changed, 36 insertions(+), 40 deletions(-) diff --git a/db/migrate/20230608151123_add_validity_period_to_journals.rb b/db/migrate/20230608151123_add_validity_period_to_journals.rb index 639576905894..696324b393fd 100644 --- a/db/migrate/20230608151123_add_validity_period_to_journals.rb +++ b/db/migrate/20230608151123_add_validity_period_to_journals.rb @@ -4,9 +4,13 @@ def change reversible do |direction| direction.up do + create_successor_journals_utility_table + fix_all_journal_timestamps write_validity_period + drop_successor_journals_utility_table + add_validity_period_constraint end end @@ -21,9 +25,6 @@ def fix_all_journal_timestamps say "Fixing potential timestamp inconsistencies on existing journals." - say "Creating utility table to improve the performance of subsequent actions." - create_successor_journals_utility_table - current_max_journal_version.downto(1).each do |version| say_with_time "Fixing timestamps for journals with version #{version}." do fixed_journals = fix_journal_timestamps(version) @@ -33,39 +34,39 @@ def fix_all_journal_timestamps end say "Done." - - drop_successor_journals_utility_table end def create_successor_journals_utility_table - suppress_messages do - create_table :successor_journals, id: false do |t| - t.references :predecessor, null: true, index: false - t.references :successor, null: true, index: false - end + say_with_time "Creating utility table to improve the performance of subsequent actions." do + suppress_messages do + create_table :successor_journals, id: false do |t| + t.references :predecessor, null: true, index: false + t.references :successor, null: true, index: false + end - execute <<~SQL.squish - INSERT INTO - successor_journals - SELECT - predecessors.id predecessor_id, - successors.id successor_id - FROM - journals predecessors - LEFT JOIN LATERAL (SELECT DISTINCT ON (journable_type, journable_id) * - FROM journals successors - WHERE successors.version > predecessors.version - AND successors.journable_id = predecessors.journable_id - AND successors.journable_type = predecessors.journable_type - ORDER BY successors.journable_type ASC, - successors.journable_id ASC, - successors.version ASC) successors - ON successors.journable_id = predecessors.journable_id - AND successors.journable_type = predecessors.journable_type - SQL + execute <<~SQL.squish + INSERT INTO + successor_journals + SELECT + predecessors.id predecessor_id, + successors.id successor_id + FROM + journals predecessors + LEFT JOIN LATERAL (SELECT DISTINCT ON (journable_type, journable_id) * + FROM journals successors + WHERE successors.version > predecessors.version + AND successors.journable_id = predecessors.journable_id + AND successors.journable_type = predecessors.journable_type + ORDER BY successors.journable_type ASC, + successors.journable_id ASC, + successors.version ASC) successors + ON successors.journable_id = predecessors.journable_id + AND successors.journable_type = predecessors.journable_type + SQL - add_index :successor_journals, :predecessor_id - add_index :successor_journals, :successor_id + add_index :successor_journals, :predecessor_id + add_index :successor_journals, :successor_id + end end end @@ -123,15 +124,10 @@ def write_validity_period tstzrange(predecessors.created_at, successors.created_at) validity_period FROM journals predecessors - LEFT JOIN LATERAL (SELECT DISTINCT ON (journable_type, journable_id) * - FROM journals successors - WHERE successors.version > predecessors.version - AND successors.journable_id = predecessors.journable_id - ORDER BY successors.journable_type ASC, - successors.journable_id ASC, - successors.version ASC) successors - ON successors.journable_id = predecessors.journable_id - AND successors.journable_type = predecessors.journable_type + LEFT JOIN successor_journals + ON successor_journals.predecessor_id = predecessors.id + LEFT JOIN journals successors + ON successor_journals.successor_id = successors.id ) values WHERE values.id = journals.id SQL From 8dcd011a4b172453288acbed248e928260b10e70 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Fri, 18 Oct 2024 10:51:58 +0200 Subject: [PATCH 039/115] [#52147] provide proper styling for icon-only columns --- .../projects/table_component.html.erb | 10 ---------- app/helpers/sort_helper.rb | 19 ++++++++++--------- .../src/global_styles/content/_table.sass | 2 ++ 3 files changed, 12 insertions(+), 19 deletions(-) diff --git a/app/components/projects/table_component.html.erb b/app/components/projects/table_component.html.erb index d94300d71d6f..59ffb873111c 100644 --- a/app/components/projects/table_component.html.erb +++ b/app/components/projects/table_component.html.erb @@ -54,16 +54,6 @@ See COPYRIGHT and LICENSE files for more details. - <%# elsif column.attribute == :favored %> - - - - - <%#= render(Primer::Beta::Octicon.new(icon: "star-fill", color: :subtle, "aria-label": I18n.t(:label_favorite))) %> - - - - <% else %> <%= quick_action_table_header column.attribute, order_options(column, turbo: true) %> <% end %> diff --git a/app/helpers/sort_helper.rb b/app/helpers/sort_helper.rb index 0b2522c4c76e..997774e7b12e 100644 --- a/app/helpers/sort_helper.rb +++ b/app/helpers/sort_helper.rb @@ -347,13 +347,9 @@ def with_sort_header_options(column, allowed_params: nil, **options) data = options.delete(:data) || {} options[:title] = sort_header_title(column, caption, options) + options[:icon_only_header] = column == :favored - additional_classes = "" - if column == :favored - additional_classes = "generic-table--header_centered generic-table--header_no-min-width" - end - - within_sort_header_tag_hierarchy(options, sort_class(column), additional_classes) do + within_sort_header_tag_hierarchy(options, sort_class(column)) do yield(column, caption, default_order, allowed_params:, param:, lang:, title: options[:title], sortable: options.fetch(:sortable, false), data:) end @@ -573,10 +569,15 @@ def order_string(column, inverted: false) end end - def within_sort_header_tag_hierarchy(options, classes, sort_header_classes = "", &) + def within_sort_header_tag_hierarchy(options, classes, &) + # A column with all icon and no text requires other styles: + icon_header = options.fetch(:icon_only_header, false) + outer_classes = icon_header ? "generic-table--header_no-padding" : "" + inner_classes = icon_header ? "generic-table--header_centered generic-table--header_no-min-width" : "" + content_tag "th", options do - content_tag "div", class: "generic-table--sort-header-outer" do - content_tag "div", class: "generic-table--sort-header #{sort_header_classes}" do + content_tag "div", class: "generic-table--sort-header-outer #{outer_classes}" do + content_tag "div", class: "generic-table--sort-header #{inner_classes}" do content_tag("span", class: classes, &) end end diff --git a/frontend/src/global_styles/content/_table.sass b/frontend/src/global_styles/content/_table.sass index f5566d7657db..aab02d6834d1 100644 --- a/frontend/src/global_styles/content/_table.sass +++ b/frontend/src/global_styles/content/_table.sass @@ -282,6 +282,8 @@ thead.-sticky th .generic-table--sort-header-outer padding: 0 12px 0 6px + &.generic-table--header_no-padding + padding: 0 .generic-table--header-outer, .generic-table--empty-header From e9e0bf7f2b0bd271d8e2c490285ce4dcb0245320 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Fri, 18 Oct 2024 11:50:35 +0200 Subject: [PATCH 040/115] [#52147] restore turbo-stream functionality --- app/helpers/sort_helper.rb | 65 ++++++++++++++++---------------------- 1 file changed, 28 insertions(+), 37 deletions(-) diff --git a/app/helpers/sort_helper.rb b/app/helpers/sort_helper.rb index 997774e7b12e..41b7b8fbeede 100644 --- a/app/helpers/sort_helper.rb +++ b/app/helpers/sort_helper.rb @@ -431,20 +431,14 @@ def sort_actions(menu, column, default_order, content_args:, allowed_params: nil asc_sort_link = projects_path(sort_by_options(column, "asc", default_order, allowed_params:, **html_options)) menu.with_item(**menu_options(label: t(:label_sort_descending), - content_args: content_args.merge( - data: { - "test-selector" => "#{column}-sort-desc" - } - ), + content_args:, + data: { "test-selector" => "#{column}-sort-desc" }, href: desc_sort_link)) do |item| item.with_leading_visual_icon(icon: :"sort-desc") end menu.with_item(**menu_options(label: t(:label_sort_ascending), - content_args: content_args.merge( - data: { - "test-selector" => "#{column}-sort-asc" - } - ), + content_args:, + data: { "test-selector" => "#{column}-sort-asc" }, href: asc_sort_link)) do |item| item.with_leading_visual_icon(icon: :"sort-asc") end @@ -453,13 +447,12 @@ def sort_actions(menu, column, default_order, content_args:, allowed_params: nil def filter_action(menu, column, filter, content_args:) menu.with_item(**menu_options(label: t(:label_filter_by), - content_args: content_args.merge( - data: { - "test-selector" => "#{column}-filter-by", - action: "table-action-menu#filterBy", - filter_name: filter - } - ))) do |item| + content_args:, + data: { + "test-selector" => "#{column}-filter-by", + action: "table-action-menu#filterBy", + filter_name: filter + })) do |item| item.with_leading_visual_icon(icon: :filter) end menu.with_divider @@ -489,11 +482,8 @@ def add_shift_action(menu, column, selected_columns, content_args, allowed_param shift_link = build_columns_link(shifted_columns, allowed_params:, **html_options) menu.with_item(**menu_options(label: t(label_key), - content_args: content_args.merge( - data: { - "test-selector" => test_selector - } - ), + content_args:, + data: { "test-selector" => test_selector }, href: shift_link)) do |item| item.with_leading_visual_icon(icon:) end @@ -506,22 +496,20 @@ def add_and_remove_column_actions(menu, column, selected_columns, content_args:, rm_column_link = build_columns_link(all_columns_except_this, allowed_params:, **html_options) menu.with_item(**menu_options(label: t(:label_add_column), - content_args: content_args.merge( - data: { - controller: "async-dialog", - "test-selector" => "#{column}-add-column" - } - ), + content_args:, + data: { + controller: "async-dialog", + "test-selector" => "#{column}-add-column" + }, href: config_view_modal_link)) do |item| item.with_leading_visual_icon(icon: :columns) end menu.with_divider menu.with_item(**menu_options(label: t(:label_remove_column), - content_args: content_args.merge( - data: { - "test-selector" => "#{column}-remove-column" - } - ), + content_args:, + data: { + "test-selector" => "#{column}-remove-column" + }, scheme: :danger, href: rm_column_link)) do |item| item.with_leading_visual_icon(icon: :trash) @@ -547,10 +535,13 @@ def shift_element(array, item, direction = :left) end def menu_options(label:, content_args:, **extra_args) - { - label:, - content_arguments: content_args.merge(title: label) - }.merge(extra_args) + content_arguments = content_args.merge(title: label) + + if extra_args[:data] + content_arguments[:data] = content_arguments.fetch(:data, {}).merge(extra_args.delete(:data)) + end + + { label:, content_arguments: }.merge(extra_args) end def sort_class(column) From 9b65c2df23a3a245092c7a9ffa136c6224db14ca Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Fri, 18 Oct 2024 11:55:40 +0200 Subject: [PATCH 041/115] [#52147] comments --- app/helpers/sort_helper.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/helpers/sort_helper.rb b/app/helpers/sort_helper.rb index 41b7b8fbeede..4eff2e3f3149 100644 --- a/app/helpers/sort_helper.rb +++ b/app/helpers/sort_helper.rb @@ -535,8 +535,10 @@ def shift_element(array, item, direction = :left) end def menu_options(label:, content_args:, **extra_args) + # The `title` should always be identical to `label`. content_arguments = content_args.merge(title: label) + # Since `data` might already be set, do not override it, but instead merge with the given extra arguments. if extra_args[:data] content_arguments[:data] = content_arguments.fetch(:data, {}).merge(extra_args.delete(:data)) end From 538724778c3a6012012deaf14707b0eab8f316b1 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Fri, 18 Oct 2024 12:03:02 +0200 Subject: [PATCH 042/115] [#52147] greenify sort_helper specs again --- app/helpers/sort_helper.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/helpers/sort_helper.rb b/app/helpers/sort_helper.rb index 4eff2e3f3149..3ec0eadd99e7 100644 --- a/app/helpers/sort_helper.rb +++ b/app/helpers/sort_helper.rb @@ -564,13 +564,13 @@ def order_string(column, inverted: false) def within_sort_header_tag_hierarchy(options, classes, &) # A column with all icon and no text requires other styles: - icon_header = options.fetch(:icon_only_header, false) - outer_classes = icon_header ? "generic-table--header_no-padding" : "" - inner_classes = icon_header ? "generic-table--header_centered generic-table--header_no-min-width" : "" + icon_header = options.delete(:icon_only_header) { false } + outer_classes = icon_header ? " generic-table--header_no-padding" : "" + inner_classes = icon_header ? " generic-table--header_centered generic-table--header_no-min-width" : "" content_tag "th", options do - content_tag "div", class: "generic-table--sort-header-outer #{outer_classes}" do - content_tag "div", class: "generic-table--sort-header #{inner_classes}" do + content_tag "div", class: "generic-table--sort-header-outer#{outer_classes}" do + content_tag "div", class: "generic-table--sort-header#{inner_classes}" do content_tag("span", class: classes, &) end end From c06ed0c690d2b7fc0dfd91ea21264c399c135199 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Fri, 18 Oct 2024 14:10:49 +0200 Subject: [PATCH 043/115] [#52147] not all project tables use quick actions In the project storage module, there is a project list that should not use these quick actions as the columns there are not sortable. Same for custom field settings. --- .../custom_field_projects/table_component.rb | 4 ++++ app/components/projects/table_component.html.erb | 14 +++++++++++++- app/components/projects/table_component.rb | 4 ++++ .../project_storages/projects/table_component.rb | 4 ++++ 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/app/components/admin/custom_fields/custom_field_projects/table_component.rb b/app/components/admin/custom_fields/custom_field_projects/table_component.rb index 5eb0f19a4cd5..9cded59b6114 100644 --- a/app/components/admin/custom_fields/custom_field_projects/table_component.rb +++ b/app/components/admin/custom_fields/custom_field_projects/table_component.rb @@ -39,6 +39,10 @@ def columns def sortable? false end + + def use_quick_action_table_headers? + false + end end end end diff --git a/app/components/projects/table_component.html.erb b/app/components/projects/table_component.html.erb index 59ffb873111c..a114faeff841 100644 --- a/app/components/projects/table_component.html.erb +++ b/app/components/projects/table_component.html.erb @@ -55,7 +55,19 @@ See COPYRIGHT and LICENSE files for more details. <% else %> - <%= quick_action_table_header column.attribute, order_options(column, turbo: true) %> + <% if use_quick_action_table_headers? %> + <%= quick_action_table_header column.attribute, order_options(column, turbo: true) %> + <% else %> + +
+
+ + <%= column.caption %> + +
+
+ + <% end %> <% end %> <% end %> diff --git a/app/components/projects/table_component.rb b/app/components/projects/table_component.rb index dfd0f4d3fba6..19525e2ffc98 100644 --- a/app/components/projects/table_component.rb +++ b/app/components/projects/table_component.rb @@ -135,6 +135,10 @@ def sortable_column?(select) sortable? && query.known_order?(select.attribute) end + def use_quick_action_table_headers? + true + end + def columns @columns ||= begin columns = query.selects.reject { |select| select.is_a?(::Queries::Selects::NotExistingSelect) } diff --git a/modules/storages/app/components/storages/project_storages/projects/table_component.rb b/modules/storages/app/components/storages/project_storages/projects/table_component.rb index ea1db72e0516..bcba0607e1f9 100644 --- a/modules/storages/app/components/storages/project_storages/projects/table_component.rb +++ b/modules/storages/app/components/storages/project_storages/projects/table_component.rb @@ -46,6 +46,10 @@ def sortable? false end + def use_quick_action_table_headers? + false + end + # Overwritten to avoid loading data that is not needed in this context def projects(query) @projects ||= query From f4a9ae72268cb719e42aac8d2d0267d4487b5975 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Fri, 18 Oct 2024 14:25:47 +0200 Subject: [PATCH 044/115] Fix rubocop cop name --- spec/controllers/concerns/authorization_spec.rb | 2 +- spec/models/project_queries/scopes/allowed_to_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/controllers/concerns/authorization_spec.rb b/spec/controllers/concerns/authorization_spec.rb index 16c9ae5283fa..a0ffc7cb622a 100644 --- a/spec/controllers/concerns/authorization_spec.rb +++ b/spec/controllers/concerns/authorization_spec.rb @@ -28,7 +28,7 @@ require "spec_helper" -RSpec.describe ApplicationController, "enforcement of authorization" do # rubocop:disable RSpec/RSpec/SpecFilePathFormat +RSpec.describe ApplicationController, "enforcement of authorization" do # rubocop:disable RSpec/SpecFilePathFormat shared_let(:user) { create(:user) } controller_setup = Module.new do diff --git a/spec/models/project_queries/scopes/allowed_to_spec.rb b/spec/models/project_queries/scopes/allowed_to_spec.rb index 1799392b0020..2678c1579e63 100644 --- a/spec/models/project_queries/scopes/allowed_to_spec.rb +++ b/spec/models/project_queries/scopes/allowed_to_spec.rb @@ -28,7 +28,7 @@ require "spec_helper" -RSpec.describe ProjectQuery, "#allowed to" do # rubocop:disable RSpec/RSpec/SpecFilePathFormat +RSpec.describe ProjectQuery, "#allowed to" do # rubocop:disable RSpec/SpecFilePathFormat shared_let(:user) { create(:user) } shared_let(:other_user) { create(:user) } From c6bafaebf1ee1d6acbbcc636911283dab66ba4e3 Mon Sep 17 00:00:00 2001 From: Christophe Bliard Date: Fri, 18 Oct 2024 14:27:02 +0200 Subject: [PATCH 045/115] Bump rubocop-openproject It adds a cop to check for abusive sleep usage in feature specs. --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index f9d780dd6a5e..eef095e51d02 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1024,7 +1024,7 @@ GEM rubocop (~> 1.41) rubocop-factory_bot (2.26.1) rubocop (~> 1.61) - rubocop-openproject (0.1.0) + rubocop-openproject (0.2.0) rubocop rubocop-performance (1.22.1) rubocop (>= 1.48.1, < 2.0) From 5791021fda98ad49eba3626cdd1cc4395f088aa1 Mon Sep 17 00:00:00 2001 From: OpenProject Actions CI Date: Sat, 19 Oct 2024 03:09:28 +0000 Subject: [PATCH 046/115] update locales from crowdin [ci skip] --- config/locales/crowdin/af.yml | 9 ++++++- config/locales/crowdin/ar.yml | 9 ++++++- config/locales/crowdin/az.yml | 9 ++++++- config/locales/crowdin/be.yml | 9 ++++++- config/locales/crowdin/bg.yml | 9 ++++++- config/locales/crowdin/ca.yml | 9 ++++++- config/locales/crowdin/ckb-IR.yml | 9 ++++++- config/locales/crowdin/cs.yml | 9 ++++++- config/locales/crowdin/da.yml | 9 ++++++- config/locales/crowdin/de.yml | 9 ++++++- config/locales/crowdin/el.yml | 9 ++++++- config/locales/crowdin/eo.yml | 9 ++++++- config/locales/crowdin/es.yml | 9 ++++++- config/locales/crowdin/et.yml | 9 ++++++- config/locales/crowdin/eu.yml | 9 ++++++- config/locales/crowdin/fa.yml | 9 ++++++- config/locales/crowdin/fi.yml | 9 ++++++- config/locales/crowdin/fil.yml | 9 ++++++- config/locales/crowdin/fr.yml | 9 ++++++- config/locales/crowdin/he.yml | 9 ++++++- config/locales/crowdin/hi.yml | 9 ++++++- config/locales/crowdin/hr.yml | 9 ++++++- config/locales/crowdin/hu.yml | 9 ++++++- config/locales/crowdin/id.yml | 9 ++++++- config/locales/crowdin/it.yml | 9 ++++++- config/locales/crowdin/ja.yml | 9 ++++++- config/locales/crowdin/ka.yml | 9 ++++++- config/locales/crowdin/kk.yml | 9 ++++++- config/locales/crowdin/ko.yml | 9 ++++++- config/locales/crowdin/lt.yml | 9 ++++++- config/locales/crowdin/lv.yml | 9 ++++++- config/locales/crowdin/mn.yml | 9 ++++++- config/locales/crowdin/ms.yml | 9 ++++++- config/locales/crowdin/ne.yml | 9 ++++++- config/locales/crowdin/nl.yml | 9 ++++++- config/locales/crowdin/no.yml | 9 ++++++- config/locales/crowdin/pl.yml | 9 ++++++- config/locales/crowdin/pt-BR.yml | 9 ++++++- config/locales/crowdin/pt-PT.yml | 9 ++++++- config/locales/crowdin/ro.yml | 9 ++++++- config/locales/crowdin/ru.yml | 9 ++++++- config/locales/crowdin/rw.yml | 9 ++++++- config/locales/crowdin/si.yml | 9 ++++++- config/locales/crowdin/sk.yml | 9 ++++++- config/locales/crowdin/sl.yml | 9 ++++++- config/locales/crowdin/sr.yml | 9 ++++++- config/locales/crowdin/sv.yml | 9 ++++++- config/locales/crowdin/th.yml | 9 ++++++- config/locales/crowdin/tr.yml | 9 ++++++- config/locales/crowdin/uk.yml | 9 ++++++- config/locales/crowdin/uz.yml | 9 ++++++- config/locales/crowdin/vi.yml | 9 ++++++- config/locales/crowdin/zh-CN.yml | 39 ++++++++++++++++++------------- config/locales/crowdin/zh-TW.yml | 39 ++++++++++++++++++------------- 54 files changed, 462 insertions(+), 84 deletions(-) diff --git a/config/locales/crowdin/af.yml b/config/locales/crowdin/af.yml index ba7cd2ad276b..bd7edaa04e5b 100644 --- a/config/locales/crowdin/af.yml +++ b/config/locales/crowdin/af.yml @@ -1993,6 +1993,7 @@ af: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "Revoke" + label_add_column: "Add column" label_applied_status: "Toegepaste status" label_archive_project: "Archive project" label_ascending: "Stygende" @@ -2161,8 +2162,9 @@ af: label_feeds_access_key_created_on: "RSS toegang sleutel %{value} gelede geskep" label_feeds_access_key_type: "RSS" label_file_plural: "Lêers" - label_filter_add: "Voeg filter by" label_filter: "Filtreerders" + label_filter_add: "Voeg filter by" + label_filter_by: "Filter by" label_filter_plural: "Filtreerders" label_filters_toggle: "Show/hide filters" label_float: "Dryf" @@ -2285,6 +2287,8 @@ af: label_months_from: "maande vanaf" label_more: "Meer" label_more_than_ago: "meer as dae gelede" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Skuif werkspakket" label_my_account: "Account settings" label_my_activity: "My activity" @@ -2407,6 +2411,7 @@ af: label_relation_new: "New relation" label_release_notes: "Release notes" label_remaining_work: "Oorblywende werk" + label_remove_column: "Remove column" label_remove_columns: "Remove selected columns" label_renamed: "renamed" label_reply_plural: "Replies" @@ -2447,8 +2452,10 @@ af: label_show_completed_versions: "Show completed versions" label_columns: "Kolomme" label_sort: "Rangskik" + label_sort_ascending: "Sort ascending" label_sort_by: "Sort by %{value}" label_sorted_by: "sorted by %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Move up" label_sort_highest: "Move to top" label_sort_lower: "Skuif af" diff --git a/config/locales/crowdin/ar.yml b/config/locales/crowdin/ar.yml index 1884e9848247..fc4b92d71590 100644 --- a/config/locales/crowdin/ar.yml +++ b/config/locales/crowdin/ar.yml @@ -2133,6 +2133,7 @@ ar: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "Revoke" + label_add_column: "Add column" label_applied_status: "الحالة المطبقة" label_archive_project: "Archive project" label_ascending: "ترتيب تصاعدي" @@ -2301,8 +2302,9 @@ ar: label_feeds_access_key_created_on: "مفتاح الوصول إلى RSS التي تم إنشاؤها %{value} من قبل" label_feeds_access_key_type: "اختصاراً لِ:\"Rich Site Summary\" \nReally Simple Syndication\" or\"" label_file_plural: "الملفّات" - label_filter_add: "إضافة عامل تصفية" label_filter: "عوامل التصفية" + label_filter_add: "إضافة عامل تصفية" + label_filter_by: "Filter by" label_filter_plural: "عوامل التصفية" label_filters_toggle: "Show/hide filters" label_float: "عدد عشري" @@ -2425,6 +2427,8 @@ ar: label_months_from: "أشهر من" label_more: "المزيد" label_more_than_ago: "منذ أكثر من أيام" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "نقل مجموعة العمل" label_my_account: "Account settings" label_my_activity: "My activity" @@ -2547,6 +2551,7 @@ ar: label_relation_new: "علاقة جديدة" label_release_notes: "ملاحظات الإصدار" label_remaining_work: "Remaining work" + label_remove_column: "Remove column" label_remove_columns: "إزالة الأعمدة المختارة" label_renamed: "إعادة التسمية" label_reply_plural: "الردود" @@ -2587,8 +2592,10 @@ ar: label_show_completed_versions: "عرض الإصدارات المنجزة" label_columns: "الأعمدة" label_sort: "فرز" + label_sort_ascending: "Sort ascending" label_sort_by: "ترتيب حسب %{value}" label_sorted_by: "مرتبة حسب %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "تحريك للأعلى" label_sort_highest: "الانتقال لأعلى الصفحة" label_sort_lower: "تحريك للأسفل" diff --git a/config/locales/crowdin/az.yml b/config/locales/crowdin/az.yml index 8ee1798b1021..70879159a9f8 100644 --- a/config/locales/crowdin/az.yml +++ b/config/locales/crowdin/az.yml @@ -1993,6 +1993,7 @@ az: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "Revoke" + label_add_column: "Add column" label_applied_status: "Applied status" label_archive_project: "Archive project" label_ascending: "Ascending" @@ -2161,8 +2162,9 @@ az: label_feeds_access_key_created_on: "RSS access key created %{value} ago" label_feeds_access_key_type: "RSS" label_file_plural: "Files" - label_filter_add: "Add filter" label_filter: "Filters" + label_filter_add: "Add filter" + label_filter_by: "Filter by" label_filter_plural: "Filters" label_filters_toggle: "Show/hide filters" label_float: "Float" @@ -2285,6 +2287,8 @@ az: label_months_from: "months from" label_more: "Daha çox" label_more_than_ago: "more than days ago" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Move work package" label_my_account: "Account settings" label_my_activity: "My activity" @@ -2407,6 +2411,7 @@ az: label_relation_new: "New relation" label_release_notes: "Release notes" label_remaining_work: "Remaining work" + label_remove_column: "Remove column" label_remove_columns: "Remove selected columns" label_renamed: "renamed" label_reply_plural: "Replies" @@ -2447,8 +2452,10 @@ az: label_show_completed_versions: "Show completed versions" label_columns: "Columns" label_sort: "Sort" + label_sort_ascending: "Sort ascending" label_sort_by: "Sort by %{value}" label_sorted_by: "sorted by %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Move up" label_sort_highest: "Move to top" label_sort_lower: "Move down" diff --git a/config/locales/crowdin/be.yml b/config/locales/crowdin/be.yml index d8e8b074b54c..40d1f3994459 100644 --- a/config/locales/crowdin/be.yml +++ b/config/locales/crowdin/be.yml @@ -2063,6 +2063,7 @@ be: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "Revoke" + label_add_column: "Add column" label_applied_status: "Applied status" label_archive_project: "Archive project" label_ascending: "Ascending" @@ -2231,8 +2232,9 @@ be: label_feeds_access_key_created_on: "RSS access key created %{value} ago" label_feeds_access_key_type: "RSS" label_file_plural: "Files" - label_filter_add: "Add filter" label_filter: "Filters" + label_filter_add: "Add filter" + label_filter_by: "Filter by" label_filter_plural: "Filters" label_filters_toggle: "Show/hide filters" label_float: "Float" @@ -2355,6 +2357,8 @@ be: label_months_from: "months from" label_more: "Яшчэ" label_more_than_ago: "more than days ago" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Move work package" label_my_account: "Account settings" label_my_activity: "My activity" @@ -2477,6 +2481,7 @@ be: label_relation_new: "New relation" label_release_notes: "Release notes" label_remaining_work: "Remaining work" + label_remove_column: "Remove column" label_remove_columns: "Remove selected columns" label_renamed: "renamed" label_reply_plural: "Replies" @@ -2517,8 +2522,10 @@ be: label_show_completed_versions: "Show completed versions" label_columns: "Columns" label_sort: "Sort" + label_sort_ascending: "Sort ascending" label_sort_by: "Sort by %{value}" label_sorted_by: "sorted by %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Move up" label_sort_highest: "Move to top" label_sort_lower: "Move down" diff --git a/config/locales/crowdin/bg.yml b/config/locales/crowdin/bg.yml index cf86a2b2ccfc..ad30b595eea5 100644 --- a/config/locales/crowdin/bg.yml +++ b/config/locales/crowdin/bg.yml @@ -1993,6 +1993,7 @@ bg: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "Отмени" + label_add_column: "Add column" label_applied_status: "Приложен статус" label_archive_project: "Archive project" label_ascending: "Възходящо" @@ -2161,8 +2162,9 @@ bg: label_feeds_access_key_created_on: "RSS ключ за достъп създаден преди %{value}" label_feeds_access_key_type: "RSS" label_file_plural: "Файлове" - label_filter_add: "Добавяне на филтър" label_filter: "Филтри" + label_filter_add: "Добавяне на филтър" + label_filter_by: "Filter by" label_filter_plural: "Филтри" label_filters_toggle: "Покажи/скрий филтрите" label_float: "Плаващ" @@ -2285,6 +2287,8 @@ bg: label_months_from: "месеца от" label_more: "Повече" label_more_than_ago: "повече от преди дни" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Премести работен пакет" label_my_account: "Account settings" label_my_activity: "Моята дейност" @@ -2407,6 +2411,7 @@ bg: label_relation_new: "Нова връзка" label_release_notes: "Бележки по изданието" label_remaining_work: "Оставаща работа" + label_remove_column: "Remove column" label_remove_columns: "Премахване на избраните колони" label_renamed: "преименуван" label_reply_plural: "Отговори" @@ -2447,8 +2452,10 @@ bg: label_show_completed_versions: "Покажи завършени версии" label_columns: "Колони" label_sort: "Сортиране" + label_sort_ascending: "Sort ascending" label_sort_by: "Sort by %{value}" label_sorted_by: "сортирани по %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Премести нагоре" label_sort_highest: "Премести най-горе" label_sort_lower: "Премести надолу" diff --git a/config/locales/crowdin/ca.yml b/config/locales/crowdin/ca.yml index 6eaa784a97b2..08144b06e6c8 100644 --- a/config/locales/crowdin/ca.yml +++ b/config/locales/crowdin/ca.yml @@ -1989,6 +1989,7 @@ ca: label_ical_access_key_generation_hint: "Generat automàticament en subscriure't a un calendari." label_ical_access_key_latest: "úlitma" label_ical_access_key_revoke: "Revoca" + label_add_column: "Add column" label_applied_status: "Estat aplicat" label_archive_project: "Arxiva el projecte" label_ascending: "Ascendent" @@ -2157,8 +2158,9 @@ ca: label_feeds_access_key_created_on: "Clau d'accés RSS creada fa %{value}" label_feeds_access_key_type: "RSS" label_file_plural: "Arxius" - label_filter_add: "Afegir un filtre" label_filter: "Filtres" + label_filter_add: "Afegir un filtre" + label_filter_by: "Filter by" label_filter_plural: "Filtres" label_filters_toggle: "Mostra/amaga filtres" label_float: "Flotant" @@ -2281,6 +2283,8 @@ ca: label_months_from: "mesos des de" label_more: "Més" label_more_than_ago: "fa més dies" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Moure el paquet de treball" label_my_account: "Account settings" label_my_activity: "La meva activitat" @@ -2403,6 +2407,7 @@ ca: label_relation_new: "Nova Relació" label_release_notes: "Notes de llançament" label_remaining_work: "Treball restant" + label_remove_column: "Remove column" label_remove_columns: "Treure les columnes seleccionades" label_renamed: "reanomenat" label_reply_plural: "Respostes" @@ -2443,8 +2448,10 @@ ca: label_show_completed_versions: "Mostra les versions acabades" label_columns: "Columnes" label_sort: "Ordena" + label_sort_ascending: "Sort ascending" label_sort_by: "Ordenar per %{value}" label_sorted_by: "ordenats per %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Mou amunt" label_sort_highest: "Moure al principi" label_sort_lower: "Mou cap avall" diff --git a/config/locales/crowdin/ckb-IR.yml b/config/locales/crowdin/ckb-IR.yml index ae34e5b2bb6b..c95954c04cdc 100644 --- a/config/locales/crowdin/ckb-IR.yml +++ b/config/locales/crowdin/ckb-IR.yml @@ -1993,6 +1993,7 @@ ckb-IR: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "Revoke" + label_add_column: "Add column" label_applied_status: "Applied status" label_archive_project: "Archive project" label_ascending: "Ascending" @@ -2161,8 +2162,9 @@ ckb-IR: label_feeds_access_key_created_on: "RSS access key created %{value} ago" label_feeds_access_key_type: "RSS" label_file_plural: "Files" - label_filter_add: "Add filter" label_filter: "Filters" + label_filter_add: "Add filter" + label_filter_by: "Filter by" label_filter_plural: "Filters" label_filters_toggle: "Show/hide filters" label_float: "Float" @@ -2285,6 +2287,8 @@ ckb-IR: label_months_from: "months from" label_more: "More" label_more_than_ago: "more than days ago" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Move work package" label_my_account: "Account settings" label_my_activity: "My activity" @@ -2407,6 +2411,7 @@ ckb-IR: label_relation_new: "New relation" label_release_notes: "Release notes" label_remaining_work: "Remaining work" + label_remove_column: "Remove column" label_remove_columns: "Remove selected columns" label_renamed: "renamed" label_reply_plural: "Replies" @@ -2447,8 +2452,10 @@ ckb-IR: label_show_completed_versions: "Show completed versions" label_columns: "Columns" label_sort: "Sort" + label_sort_ascending: "Sort ascending" label_sort_by: "Sort by %{value}" label_sorted_by: "sorted by %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Move up" label_sort_highest: "Move to top" label_sort_lower: "Move down" diff --git a/config/locales/crowdin/cs.yml b/config/locales/crowdin/cs.yml index d3de7cc771c1..02903e139ca3 100644 --- a/config/locales/crowdin/cs.yml +++ b/config/locales/crowdin/cs.yml @@ -2063,6 +2063,7 @@ cs: label_ical_access_key_generation_hint: "Automaticky vygenerováno při odebírání kalendáře." label_ical_access_key_latest: "poslední" label_ical_access_key_revoke: "Revoke" + label_add_column: "Add column" label_applied_status: "Přiřazený stav" label_archive_project: "Archivovat projekt" label_ascending: "Vzestupně" @@ -2231,8 +2232,9 @@ cs: label_feeds_access_key_created_on: "Přístupový klíč pro RSS byl vytvořen před %{value}" label_feeds_access_key_type: "RSS" label_file_plural: "Soubory" - label_filter_add: "Přidat filtr" label_filter: "Filtry" + label_filter_add: "Přidat filtr" + label_filter_by: "Filter by" label_filter_plural: "Filtry" label_filters_toggle: "Zobrazit/skrýt filtry" label_float: "Desetinné číslo" @@ -2355,6 +2357,8 @@ cs: label_months_from: "měsíců od" label_more: "Více" label_more_than_ago: "před více jak (dny)" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Přesunout pracovní balíček" label_my_account: "Account settings" label_my_activity: "Moje aktivita" @@ -2477,6 +2481,7 @@ cs: label_relation_new: "Nový vztah" label_release_notes: "Poznámky k verzi" label_remaining_work: "Zbývající práce" + label_remove_column: "Remove column" label_remove_columns: "Odstranit vybrané sloupce" label_renamed: "přejmenováno" label_reply_plural: "odpovědi" @@ -2517,8 +2522,10 @@ cs: label_show_completed_versions: "Zobrazit dokončené verze" label_columns: "Sloupce" label_sort: "Řadit" + label_sort_ascending: "Sort ascending" label_sort_by: "Seřadit podle %{value}" label_sorted_by: "seřazeno dle %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Přesunout výš" label_sort_highest: "Přesunout nahoru" label_sort_lower: "Přesunout níž" diff --git a/config/locales/crowdin/da.yml b/config/locales/crowdin/da.yml index 7a4cb2d6ee27..80ee33b392df 100644 --- a/config/locales/crowdin/da.yml +++ b/config/locales/crowdin/da.yml @@ -1991,6 +1991,7 @@ da: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "Revoke" + label_add_column: "Add column" label_applied_status: "Tildelt status" label_archive_project: "Archive project" label_ascending: "Stigende" @@ -2159,8 +2160,9 @@ da: label_feeds_access_key_created_on: "RSS adgangsnøgle lavet for %{value} siden" label_feeds_access_key_type: "RSS" label_file_plural: "Filer" - label_filter_add: "Tilføj filter" label_filter: "Filtre" + label_filter_add: "Tilføj filter" + label_filter_by: "Filter by" label_filter_plural: "Filtre" label_filters_toggle: "Show/hide filters" label_float: "Flyd" @@ -2283,6 +2285,8 @@ da: label_months_from: "måneder fra" label_more: "mere" label_more_than_ago: "mere end dage siden" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Flyt arbejdspakke" label_my_account: "Account settings" label_my_activity: "My activity" @@ -2405,6 +2409,7 @@ da: label_relation_new: "Ny forbindelse" label_release_notes: "Udgivelsesnoter" label_remaining_work: "Remaining work" + label_remove_column: "Remove column" label_remove_columns: "Fjern valgte kolonner" label_renamed: "omdøbt" label_reply_plural: "Svar" @@ -2445,8 +2450,10 @@ da: label_show_completed_versions: "Vis færdige versioner" label_columns: "Kolonner" label_sort: "Sorter" + label_sort_ascending: "Sort ascending" label_sort_by: "Sorter efter %{value}" label_sorted_by: "er sorteret efter %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Flyt op" label_sort_highest: "Flyt til toppen" label_sort_lower: "Flyt ned" diff --git a/config/locales/crowdin/de.yml b/config/locales/crowdin/de.yml index c758b960c888..e4397501a24d 100644 --- a/config/locales/crowdin/de.yml +++ b/config/locales/crowdin/de.yml @@ -1987,6 +1987,7 @@ de: label_ical_access_key_generation_hint: "Wird automatisch generiert, wenn ein Kalender abonniert wird." label_ical_access_key_latest: "neueste" label_ical_access_key_revoke: "Widerrufen" + label_add_column: "Add column" label_applied_status: "Zugewiesener Status" label_archive_project: "Projekt archivieren" label_ascending: "Aufsteigend" @@ -2155,8 +2156,9 @@ de: label_feeds_access_key_created_on: "Atom-Zugriffsschlüssel vor %{value} erstellt" label_feeds_access_key_type: "RSS" label_file_plural: "Dateien" - label_filter_add: "Filter hinzufügen" label_filter: "Filter" + label_filter_add: "Filter hinzufügen" + label_filter_by: "Filter by" label_filter_plural: "Filter" label_filters_toggle: "Filter ein-/ausblenden" label_float: "Gleitkommazahl" @@ -2279,6 +2281,8 @@ de: label_months_from: "Monate ab" label_more: "Mehr" label_more_than_ago: "vor mehr als (Tage)" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Arbeitspaket verschieben" label_my_account: "Kontoeinstellungen" label_my_activity: "Meine Aktivität" @@ -2401,6 +2405,7 @@ de: label_relation_new: "Neue Beziehung" label_release_notes: "Release Notes" label_remaining_work: "Verbleibender Aufwand" + label_remove_column: "Remove column" label_remove_columns: "Ausgewählte Spalten entfernen" label_renamed: "umbenannt" label_reply_plural: "Antworten" @@ -2441,8 +2446,10 @@ de: label_show_completed_versions: "Abgeschlossene Versionen anzeigen" label_columns: "Spalten" label_sort: "Sortierung" + label_sort_ascending: "Sort ascending" label_sort_by: "Sortiere nach %{value}" label_sorted_by: "sortiert nach %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Eins höher" label_sort_highest: "An den Anfang" label_sort_lower: "Eins tiefer" diff --git a/config/locales/crowdin/el.yml b/config/locales/crowdin/el.yml index 2b19065485e6..2a353d167904 100644 --- a/config/locales/crowdin/el.yml +++ b/config/locales/crowdin/el.yml @@ -1989,6 +1989,7 @@ el: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "Ανάκληση" + label_add_column: "Add column" label_applied_status: "Εφαρμόστηκε η κατάσταση" label_archive_project: "Αρχειοθέτηση έργου" label_ascending: "Αύξουσα" @@ -2157,8 +2158,9 @@ el: label_feeds_access_key_created_on: "Tο κλειδί πρόσβασης RSS δημιουργήθηκε πριν από %{value}" label_feeds_access_key_type: "RSS" label_file_plural: "Αρχεία" - label_filter_add: "Προσθήκη φίλτρου" label_filter: "Φίλτρα" + label_filter_add: "Προσθήκη φίλτρου" + label_filter_by: "Filter by" label_filter_plural: "Φίλτρα" label_filters_toggle: "Εμφάνιση/απόκρυψη φίλτρων" label_float: "Float" @@ -2281,6 +2283,8 @@ el: label_months_from: "μήνες από" label_more: "Περισσότερα" label_more_than_ago: "σε περισσότερο από ημέρες πριν" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Μετακίνηση πακέτου εργασίας" label_my_account: "Account settings" label_my_activity: "My activity" @@ -2403,6 +2407,7 @@ el: label_relation_new: "Νέα συσχέτιση" label_release_notes: "Σημειώσεις έκδοσης" label_remaining_work: "Remaining work" + label_remove_column: "Remove column" label_remove_columns: "Αφαιρέστε τις επιλεγμένες στήλες" label_renamed: "μετονομάστηκε" label_reply_plural: "Απαντήσεις" @@ -2443,8 +2448,10 @@ el: label_show_completed_versions: "Εμφάνιση ολοκληρωμένων εκδόσεων" label_columns: "Στήλες" label_sort: "Ταξινόμηση" + label_sort_ascending: "Sort ascending" label_sort_by: "Ταξινόμηση κατά %{value}" label_sorted_by: "ταξινομημένα κατά %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Μετακίνηση προς τα πάνω" label_sort_highest: "Μετακίνηση στην κορυφή" label_sort_lower: "Μετακίνηση προς τα κάτω" diff --git a/config/locales/crowdin/eo.yml b/config/locales/crowdin/eo.yml index 742a15255c33..a2cf49ae6f71 100644 --- a/config/locales/crowdin/eo.yml +++ b/config/locales/crowdin/eo.yml @@ -1993,6 +1993,7 @@ eo: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "Eksvalidigi" + label_add_column: "Add column" label_applied_status: "Aplikita stato" label_archive_project: "Aktivi projekton" label_ascending: "Kreskante" @@ -2161,8 +2162,9 @@ eo: label_feeds_access_key_created_on: "RSS access key created %{value} ago" label_feeds_access_key_type: "RSS" label_file_plural: "Dosieroj" - label_filter_add: "Add filter" label_filter: "Filtriloj" + label_filter_add: "Add filter" + label_filter_by: "Filter by" label_filter_plural: "Filtriloj" label_filters_toggle: "Show/hide filters" label_float: "Float" @@ -2285,6 +2287,8 @@ eo: label_months_from: "monatoj ekde" label_more: "Pli" label_more_than_ago: "pli da tagoj malantaŭe" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Movi la laborpakaĵon" label_my_account: "Account settings" label_my_activity: "My activity" @@ -2407,6 +2411,7 @@ eo: label_relation_new: "New relation" label_release_notes: "Release notes" label_remaining_work: "Remaining work" + label_remove_column: "Remove column" label_remove_columns: "Remove selected columns" label_renamed: "renamed" label_reply_plural: "Replies" @@ -2447,8 +2452,10 @@ eo: label_show_completed_versions: "Show completed versions" label_columns: "Kolumnoj" label_sort: "Ordigi" + label_sort_ascending: "Sort ascending" label_sort_by: "Sort by %{value}" label_sorted_by: "sorted by %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Move up" label_sort_highest: "Move to top" label_sort_lower: "Move down" diff --git a/config/locales/crowdin/es.yml b/config/locales/crowdin/es.yml index 58f686c3615f..3a2d6fa3cee9 100644 --- a/config/locales/crowdin/es.yml +++ b/config/locales/crowdin/es.yml @@ -1990,6 +1990,7 @@ es: label_ical_access_key_generation_hint: "Se genera automáticamente al suscribirse a un calendario." label_ical_access_key_latest: "último" label_ical_access_key_revoke: "Revocar" + label_add_column: "Add column" label_applied_status: "Estado aplicado" label_archive_project: "Archivar proyecto" label_ascending: "Ascendente" @@ -2158,8 +2159,9 @@ es: label_feeds_access_key_created_on: "Código de acceso RSS creado hace %{value}" label_feeds_access_key_type: "RSS" label_file_plural: "Archivos" - label_filter_add: "Añadir filtro" label_filter: "Filtros" + label_filter_add: "Añadir filtro" + label_filter_by: "Filter by" label_filter_plural: "Filtros" label_filters_toggle: "Mostrar/Ocultar filtros" label_float: "Desprender" @@ -2282,6 +2284,8 @@ es: label_months_from: "meses desde" label_more: "Más" label_more_than_ago: "más días atrás" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Mover el paquete de trabajo" label_my_account: "Ajustes de la cuenta" label_my_activity: "Mi actividad" @@ -2404,6 +2408,7 @@ es: label_relation_new: "Nueva relación" label_release_notes: "Notas de lanzamiento" label_remaining_work: "Trabajo restante" + label_remove_column: "Remove column" label_remove_columns: "Eliminar columnas seleccionadas" label_renamed: "nombre cambiado" label_reply_plural: "Respuestas" @@ -2444,8 +2449,10 @@ es: label_show_completed_versions: "Mostrar versiones terminadas" label_columns: "Columnas" label_sort: "Ordenar" + label_sort_ascending: "Sort ascending" label_sort_by: "Ordenar por %{value}" label_sorted_by: "ordenados por %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Mover hacia arriba" label_sort_highest: "Arriba de todo" label_sort_lower: "Mover hacia abajo" diff --git a/config/locales/crowdin/et.yml b/config/locales/crowdin/et.yml index 3c4212068361..d52bcf7cc162 100644 --- a/config/locales/crowdin/et.yml +++ b/config/locales/crowdin/et.yml @@ -1993,6 +1993,7 @@ et: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "Võta tagasi" + label_add_column: "Add column" label_applied_status: "Rakendatud olek" label_archive_project: "Archive project" label_ascending: "Kasvav" @@ -2161,8 +2162,9 @@ et: label_feeds_access_key_created_on: "RSS kasutuse võti on loodud %{value} tagasi" label_feeds_access_key_type: "RSS" label_file_plural: "Failid" - label_filter_add: "Lisa filter" label_filter: "Filtrid" + label_filter_add: "Lisa filter" + label_filter_by: "Filter by" label_filter_plural: "Filtrid" label_filters_toggle: "Show/hide filters" label_float: "Ujuvkomaarv" @@ -2285,6 +2287,8 @@ et: label_months_from: "kuu kaugusel" label_more: "Rohkem" label_more_than_ago: "vanem kui" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Tõsta teema ümber" label_my_account: "Account settings" label_my_activity: "My activity" @@ -2407,6 +2411,7 @@ et: label_relation_new: "Uus seos" label_release_notes: "Release notes" label_remaining_work: "Remaining work" + label_remove_column: "Remove column" label_remove_columns: "Eemalda valitud väljad" label_renamed: "ümber nimetatud" label_reply_plural: "Vastused" @@ -2447,8 +2452,10 @@ et: label_show_completed_versions: "Näita lõpetatud versioone" label_columns: "Veerud" label_sort: "Sorteeri" + label_sort_ascending: "Sort ascending" label_sort_by: "Sorteeri %{value} järgi" label_sorted_by: "Sorteeritud %{value} järgi" + label_sort_descending: "Sort descending" label_sort_higher: "Nihuta üles" label_sort_highest: "Nihuta esimeseks" label_sort_lower: "Nihuta alla" diff --git a/config/locales/crowdin/eu.yml b/config/locales/crowdin/eu.yml index 2e08d6cb168d..34ab35a007e3 100644 --- a/config/locales/crowdin/eu.yml +++ b/config/locales/crowdin/eu.yml @@ -1993,6 +1993,7 @@ eu: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "Revoke" + label_add_column: "Add column" label_applied_status: "Applied status" label_archive_project: "Archive project" label_ascending: "Ascending" @@ -2161,8 +2162,9 @@ eu: label_feeds_access_key_created_on: "RSS access key created %{value} ago" label_feeds_access_key_type: "RSS" label_file_plural: "Files" - label_filter_add: "Add filter" label_filter: "Filters" + label_filter_add: "Add filter" + label_filter_by: "Filter by" label_filter_plural: "Filters" label_filters_toggle: "Show/hide filters" label_float: "Float" @@ -2285,6 +2287,8 @@ eu: label_months_from: "months from" label_more: "More" label_more_than_ago: "more than days ago" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Move work package" label_my_account: "Account settings" label_my_activity: "My activity" @@ -2407,6 +2411,7 @@ eu: label_relation_new: "New relation" label_release_notes: "Release notes" label_remaining_work: "Remaining work" + label_remove_column: "Remove column" label_remove_columns: "Remove selected columns" label_renamed: "renamed" label_reply_plural: "Replies" @@ -2447,8 +2452,10 @@ eu: label_show_completed_versions: "Show completed versions" label_columns: "Columns" label_sort: "Sort" + label_sort_ascending: "Sort ascending" label_sort_by: "Sort by %{value}" label_sorted_by: "sorted by %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Move up" label_sort_highest: "Move to top" label_sort_lower: "Move down" diff --git a/config/locales/crowdin/fa.yml b/config/locales/crowdin/fa.yml index 858a2119ef6e..8d82fc68e10c 100644 --- a/config/locales/crowdin/fa.yml +++ b/config/locales/crowdin/fa.yml @@ -1993,6 +1993,7 @@ fa: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "Revoke" + label_add_column: "Add column" label_applied_status: "Applied status" label_archive_project: "Archive project" label_ascending: "Ascending" @@ -2161,8 +2162,9 @@ fa: label_feeds_access_key_created_on: "RSS access key created %{value} ago" label_feeds_access_key_type: "RSS" label_file_plural: "Files" - label_filter_add: "Add filter" label_filter: "فیلترها" + label_filter_add: "Add filter" + label_filter_by: "Filter by" label_filter_plural: "فیلترها" label_filters_toggle: "نمایش/پنهان کردن فیلتر" label_float: "Float" @@ -2285,6 +2287,8 @@ fa: label_months_from: "months from" label_more: "بیشتر" label_more_than_ago: "more than days ago" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Move work package" label_my_account: "Account settings" label_my_activity: "My activity" @@ -2407,6 +2411,7 @@ fa: label_relation_new: "New relation" label_release_notes: "Release notes" label_remaining_work: "کار باقی‌مانده" + label_remove_column: "Remove column" label_remove_columns: "Remove selected columns" label_renamed: "renamed" label_reply_plural: "Replies" @@ -2447,8 +2452,10 @@ fa: label_show_completed_versions: "Show completed versions" label_columns: "ستون‌ها" label_sort: "مرتب‌سازی" + label_sort_ascending: "Sort ascending" label_sort_by: "Sort by %{value}" label_sorted_by: "sorted by %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Move up" label_sort_highest: "انتقال به بالا" label_sort_lower: "Move down" diff --git a/config/locales/crowdin/fi.yml b/config/locales/crowdin/fi.yml index 799408a67c92..f639804aeade 100644 --- a/config/locales/crowdin/fi.yml +++ b/config/locales/crowdin/fi.yml @@ -1993,6 +1993,7 @@ fi: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "Revoke" + label_add_column: "Add column" label_applied_status: "Käytetty tila" label_archive_project: "Arkisto projekti" label_ascending: "Nouseva" @@ -2161,8 +2162,9 @@ fi: label_feeds_access_key_created_on: "RSS avain luotiin %{value} sitten" label_feeds_access_key_type: "RSS" label_file_plural: "Tiedostot" - label_filter_add: "Lisää suodatin" label_filter: "Suodattimet" + label_filter_add: "Lisää suodatin" + label_filter_by: "Filter by" label_filter_plural: "Suodattimet" label_filters_toggle: "Show/hide filters" label_float: "Liukuluku" @@ -2285,6 +2287,8 @@ fi: label_months_from: "kuukauden päässä" label_more: "Lisää" label_more_than_ago: "enemän kuin päivää sitten" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Siirrä tehtävä" label_my_account: "Account settings" label_my_activity: "My activity" @@ -2407,6 +2411,7 @@ fi: label_relation_new: "Uusi riippuvuus" label_release_notes: "Julkaisutiedot" label_remaining_work: "Remaining work" + label_remove_column: "Remove column" label_remove_columns: "Poista valitut sarakkeet" label_renamed: "uudelleennimetty" label_reply_plural: "Vastaukset" @@ -2447,8 +2452,10 @@ fi: label_show_completed_versions: "Näytä valmiit versiot" label_columns: "Sarakkeet" label_sort: "Järjestä" + label_sort_ascending: "Sort ascending" label_sort_by: "Lajittele %{value}" label_sorted_by: "lajitteluperuste %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Siirrä ylös" label_sort_highest: "Siirrä ylimmäiseksi" label_sort_lower: "Siirrä alas" diff --git a/config/locales/crowdin/fil.yml b/config/locales/crowdin/fil.yml index b64413e1b740..183838709d29 100644 --- a/config/locales/crowdin/fil.yml +++ b/config/locales/crowdin/fil.yml @@ -1993,6 +1993,7 @@ fil: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "Revoke" + label_add_column: "Add column" label_applied_status: "Estadong nilapat" label_archive_project: "I-archive ang proyekto" label_ascending: "Pataas" @@ -2161,8 +2162,9 @@ fil: label_feeds_access_key_created_on: "RSS access key na nillikha %{value} nakalipas" label_feeds_access_key_type: "RSS" label_file_plural: "Mga file" - label_filter_add: "Magdagdag ng filter" label_filter: "Mga nasala" + label_filter_add: "Magdagdag ng filter" + label_filter_by: "Filter by" label_filter_plural: "Mga nasala" label_filters_toggle: "Ipakita/itago ang mga salaan" label_float: "Lumutang" @@ -2285,6 +2287,8 @@ fil: label_months_from: "mga buwan mula sa" label_more: "Iba pa" label_more_than_ago: "mahigit sa araw nakalipas" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Ilipat ang work package" label_my_account: "Account settings" label_my_activity: "My activity" @@ -2407,6 +2411,7 @@ fil: label_relation_new: "Bagong relasyon" label_release_notes: "Paglabas ng mga talaan" label_remaining_work: "Remaining work" + label_remove_column: "Remove column" label_remove_columns: "Tanggalin ang mga napiling hanay" label_renamed: "binagong pangalan" label_reply_plural: "Mga sagot" @@ -2447,8 +2452,10 @@ fil: label_show_completed_versions: "Ipakita ang mga kompletong bersyon" label_columns: "Mga hanay" label_sort: "Ayusin" + label_sort_ascending: "Sort ascending" label_sort_by: "Ayusin sa %{value}" label_sorted_by: "inayos sa %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Lumipat pataas" label_sort_highest: "Ilipat sa ibabaw" label_sort_lower: "Ilipat sa ibaba" diff --git a/config/locales/crowdin/fr.yml b/config/locales/crowdin/fr.yml index 07926b7f485a..cd43b539119f 100644 --- a/config/locales/crowdin/fr.yml +++ b/config/locales/crowdin/fr.yml @@ -1992,6 +1992,7 @@ fr: label_ical_access_key_generation_hint: "Généré automatiquement lors de l'abonnement à un calendrier." label_ical_access_key_latest: "Dernier" label_ical_access_key_revoke: "Révoquer" + label_add_column: "Add column" label_applied_status: "Statut appliqué" label_archive_project: "Archiver le projet" label_ascending: "Croissant" @@ -2160,8 +2161,9 @@ fr: label_feeds_access_key_created_on: "Clé d'accès RSS créé il y a %{value}" label_feeds_access_key_type: "RSS" label_file_plural: "Fichiers" - label_filter_add: "Ajouter le filtre" label_filter: "Filtres" + label_filter_add: "Ajouter le filtre" + label_filter_by: "Filter by" label_filter_plural: "Filtres" label_filters_toggle: "Afficher/masquer les filtres" label_float: "Flottant" @@ -2284,6 +2286,8 @@ fr: label_months_from: "mois de" label_more: "Plus" label_more_than_ago: "il y a plus de quelques jours" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Déplacer le Lot de Travaux" label_my_account: "Paramètres du compte" label_my_activity: "Mon activité" @@ -2406,6 +2410,7 @@ fr: label_relation_new: "Nouvelle relation" label_release_notes: "Notes de version" label_remaining_work: "Travail restant" + label_remove_column: "Remove column" label_remove_columns: "Enlever les colonnes sélectionnées" label_renamed: "renommé" label_reply_plural: "Réponses" @@ -2446,8 +2451,10 @@ fr: label_show_completed_versions: "Afficher les versions terminées" label_columns: "Colonnes" label_sort: "Trier" + label_sort_ascending: "Sort ascending" label_sort_by: "Trier par %{value}" label_sorted_by: "trié par %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Déplacer vers le haut" label_sort_highest: "Déplacer en haut" label_sort_lower: "Déplacer vers le bas" diff --git a/config/locales/crowdin/he.yml b/config/locales/crowdin/he.yml index 4c9f90722be8..6b94675f8002 100644 --- a/config/locales/crowdin/he.yml +++ b/config/locales/crowdin/he.yml @@ -2063,6 +2063,7 @@ he: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "Revoke" + label_add_column: "Add column" label_applied_status: "מצב יישומית" label_archive_project: "Archive project" label_ascending: "סדר עולה" @@ -2231,8 +2232,9 @@ he: label_feeds_access_key_created_on: "RSS access key created %{value} ago" label_feeds_access_key_type: "RSS" label_file_plural: "קבצים" - label_filter_add: "Add filter" label_filter: "מסננים" + label_filter_add: "Add filter" + label_filter_by: "Filter by" label_filter_plural: "מסננים" label_filters_toggle: "Show/hide filters" label_float: "Float" @@ -2355,6 +2357,8 @@ he: label_months_from: "months from" label_more: "עוד" label_more_than_ago: "more than days ago" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Move work package" label_my_account: "Account settings" label_my_activity: "My activity" @@ -2477,6 +2481,7 @@ he: label_relation_new: "New relation" label_release_notes: "Release notes" label_remaining_work: "Remaining work" + label_remove_column: "Remove column" label_remove_columns: "Remove selected columns" label_renamed: "renamed" label_reply_plural: "Replies" @@ -2517,8 +2522,10 @@ he: label_show_completed_versions: "Show completed versions" label_columns: "עמודות" label_sort: "מיין" + label_sort_ascending: "Sort ascending" label_sort_by: "Sort by %{value}" label_sorted_by: "sorted by %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Move up" label_sort_highest: "Move to top" label_sort_lower: "Move down" diff --git a/config/locales/crowdin/hi.yml b/config/locales/crowdin/hi.yml index 1db35a3cb536..61e42ac0a4d4 100644 --- a/config/locales/crowdin/hi.yml +++ b/config/locales/crowdin/hi.yml @@ -1991,6 +1991,7 @@ hi: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "Revoke" + label_add_column: "Add column" label_applied_status: "लागू की गई स्थिति" label_archive_project: "Archive project" label_ascending: "आरोही" @@ -2159,8 +2160,9 @@ hi: label_feeds_access_key_created_on: "RSS access key created %{value} ago" label_feeds_access_key_type: "RSS" label_file_plural: "फ़ाइलें" - label_filter_add: "फ़िल्टर जोड़ें" label_filter: "फ़िल्टर" + label_filter_add: "फ़िल्टर जोड़ें" + label_filter_by: "Filter by" label_filter_plural: "फ़िल्टर" label_filters_toggle: "Show/hide filters" label_float: "फ्लोट" @@ -2283,6 +2285,8 @@ hi: label_months_from: "months from" label_more: "More" label_more_than_ago: "more than days ago" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Move work package" label_my_account: "Account settings" label_my_activity: "My activity" @@ -2405,6 +2409,7 @@ hi: label_relation_new: "New relation" label_release_notes: "Release notes" label_remaining_work: "Remaining work" + label_remove_column: "Remove column" label_remove_columns: "Remove selected columns" label_renamed: "renamed" label_reply_plural: "Replies" @@ -2445,8 +2450,10 @@ hi: label_show_completed_versions: "Show completed versions" label_columns: "स्तंभ" label_sort: "क्रम में लगायें" + label_sort_ascending: "Sort ascending" label_sort_by: "Sort by %{value}" label_sorted_by: "sorted by %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Move up" label_sort_highest: "Move to top" label_sort_lower: "Move down" diff --git a/config/locales/crowdin/hr.yml b/config/locales/crowdin/hr.yml index 038de1a70d3b..e59f407972dc 100644 --- a/config/locales/crowdin/hr.yml +++ b/config/locales/crowdin/hr.yml @@ -2028,6 +2028,7 @@ hr: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "Revoke" + label_add_column: "Add column" label_applied_status: "Dodijeljeni status" label_archive_project: "Archive project" label_ascending: "Uzlazno" @@ -2196,8 +2197,9 @@ hr: label_feeds_access_key_created_on: "RSS pristupni ključ kreiran %{value} prije" label_feeds_access_key_type: "RSS" label_file_plural: "Datoteke" - label_filter_add: "Dodaj filter" label_filter: "Filteri" + label_filter_add: "Dodaj filter" + label_filter_by: "Filter by" label_filter_plural: "Filteri" label_filters_toggle: "Pokaži/Sakrij filtere" label_float: "Plutajući" @@ -2320,6 +2322,8 @@ hr: label_months_from: "mjeseci od" label_more: "Više" label_more_than_ago: "prije nekoliko dana" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Premjesti radni paket" label_my_account: "Account settings" label_my_activity: "My activity" @@ -2442,6 +2446,7 @@ hr: label_relation_new: "Nova relacija" label_release_notes: "Napomene uz izdavanje" label_remaining_work: "Remaining work" + label_remove_column: "Remove column" label_remove_columns: "Izbriši odabrene stupce" label_renamed: "preimenovan" label_reply_plural: "Odgovori" @@ -2482,8 +2487,10 @@ hr: label_show_completed_versions: "Prikažite završene verzije" label_columns: "Stupci" label_sort: "Sortiraj" + label_sort_ascending: "Sort ascending" label_sort_by: "Sortiraj po %{value}" label_sorted_by: "sortirano po %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Pomakni gore" label_sort_highest: "Pomakni na vrh" label_sort_lower: "Pomakni dolje" diff --git a/config/locales/crowdin/hu.yml b/config/locales/crowdin/hu.yml index b5d50035f541..cb4c34d656b8 100644 --- a/config/locales/crowdin/hu.yml +++ b/config/locales/crowdin/hu.yml @@ -1990,6 +1990,7 @@ hu: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "Visszavonás" + label_add_column: "Add column" label_applied_status: "Alkalmazott státusz" label_archive_project: "Projekt archiválása" label_ascending: "Növekvő" @@ -2158,8 +2159,9 @@ hu: label_feeds_access_key_created_on: "RSS-hozzáférési kulcs készítette: %{value} ezelőtt " label_feeds_access_key_type: "RSS" label_file_plural: "Fájlok" - label_filter_add: "Szűrő hozzáadása" label_filter: "Szürő" + label_filter_add: "Szűrő hozzáadása" + label_filter_by: "Filter by" label_filter_plural: "Szűrők" label_filters_toggle: "Szűrők mutatása/elrejtése" label_float: "Mozgó" @@ -2282,6 +2284,8 @@ hu: label_months_from: "hónaptól" label_more: "További" label_more_than_ago: "több, nappal ezelőtt" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "A munkacsomag mozgatása" label_my_account: "Account settings" label_my_activity: "My activity" @@ -2404,6 +2408,7 @@ hu: label_relation_new: "Új kapcsolat" label_release_notes: "Kiadási megjegyzések" label_remaining_work: "Fennmaradó órák" + label_remove_column: "Remove column" label_remove_columns: "Eltávolítja a kijelölt oszlopokat" label_renamed: "átnevez" label_reply_plural: "Válaszok" @@ -2444,8 +2449,10 @@ hu: label_show_completed_versions: "Befejezett verziók megjelenítése" label_columns: "Oszlopok" label_sort: "Rendez" + label_sort_ascending: "Sort ascending" label_sort_by: "Rendezés %{value} szerint" label_sorted_by: "rendezve %{value} szerint" + label_sort_descending: "Sort descending" label_sort_higher: "Mozgatás felfelé" label_sort_highest: "Mozgatás felfelé" label_sort_lower: "Mozgás lefelé" diff --git a/config/locales/crowdin/id.yml b/config/locales/crowdin/id.yml index 8f1d2655b791..34b7f62f88f5 100644 --- a/config/locales/crowdin/id.yml +++ b/config/locales/crowdin/id.yml @@ -1951,6 +1951,7 @@ id: label_ical_access_key_generation_hint: "Dibuat secara otomatis saat berlangganan kalender." label_ical_access_key_latest: "terbaru" label_ical_access_key_revoke: "Menarik kembali" + label_add_column: "Add column" label_applied_status: "Status berjalan" label_archive_project: "Proyek arsip" label_ascending: "Askending" @@ -2119,8 +2120,9 @@ id: label_feeds_access_key_created_on: "RSS access key dibuat %{value} yang lalu" label_feeds_access_key_type: "RSS" label_file_plural: "File" - label_filter_add: "Tambah Filter" label_filter: "Filter" + label_filter_add: "Tambah Filter" + label_filter_by: "Filter by" label_filter_plural: "Filter" label_filters_toggle: "Tampilkan/Sembunyikan penyaringan" label_float: "Float" @@ -2243,6 +2245,8 @@ id: label_months_from: "dari bulan" label_more: "Lanjut" label_more_than_ago: "lusa yang lalu" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Pindahkan Paket-Penugasan" label_my_account: "Account settings" label_my_activity: "Aktivitas saya" @@ -2365,6 +2369,7 @@ id: label_relation_new: "Keterkaitan baru" label_release_notes: "Release notes" label_remaining_work: "Pekerjaan yang tersisa" + label_remove_column: "Remove column" label_remove_columns: "Hapus kolom terpilih" label_renamed: "telah diganti nama" label_reply_plural: "Balasan" @@ -2405,8 +2410,10 @@ id: label_show_completed_versions: "Tampilkan versi lengkap" label_columns: "Kolom" label_sort: "Urutkan" + label_sort_ascending: "Sort ascending" label_sort_by: "Urutkan %{value}" label_sorted_by: "diurutkan berdasarkan %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Naikkan" label_sort_highest: "Pindah paling atas" label_sort_lower: "Pindahkan ke bawah" diff --git a/config/locales/crowdin/it.yml b/config/locales/crowdin/it.yml index 956bc77ce9f9..488ecf2bc190 100644 --- a/config/locales/crowdin/it.yml +++ b/config/locales/crowdin/it.yml @@ -1990,6 +1990,7 @@ it: label_ical_access_key_generation_hint: "Generato automaticamente all'iscrizione a un calendario." label_ical_access_key_latest: "più recente" label_ical_access_key_revoke: "Revoca" + label_add_column: "Add column" label_applied_status: "Stato applicato" label_archive_project: "Archivia progetto" label_ascending: "Crescente" @@ -2158,8 +2159,9 @@ it: label_feeds_access_key_created_on: "Chiave di accesso RSS creato %{value} fa" label_feeds_access_key_type: "RSS" label_file_plural: "File" - label_filter_add: "Aggiungi filtro" label_filter: "Filtri" + label_filter_add: "Aggiungi filtro" + label_filter_by: "Filter by" label_filter_plural: "Filtri" label_filters_toggle: "Mostra/nascondi filtri" label_float: "Virgola mobile" @@ -2282,6 +2284,8 @@ it: label_months_from: "mesi da" label_more: "Più" label_more_than_ago: "più di qualche giorno fa" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Sposta la macro-attività" label_my_account: "Impostazioni account" label_my_activity: "La mia attività" @@ -2404,6 +2408,7 @@ it: label_relation_new: "Nuova relazione" label_release_notes: "Note di rilascio" label_remaining_work: "Lavoro residuo" + label_remove_column: "Remove column" label_remove_columns: "Rimuovi le colonne selezionate" label_renamed: "rinominato" label_reply_plural: "Risposte" @@ -2444,8 +2449,10 @@ it: label_show_completed_versions: "Visualizza le versioni completate" label_columns: "Colonne" label_sort: "Ordina" + label_sort_ascending: "Sort ascending" label_sort_by: "Ordina per %{value}" label_sorted_by: "ordinati per %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Sposta su" label_sort_highest: "Sposta in cima" label_sort_lower: "Sposta giu" diff --git a/config/locales/crowdin/ja.yml b/config/locales/crowdin/ja.yml index 5ed41d59b7c9..92f86ae2b62c 100644 --- a/config/locales/crowdin/ja.yml +++ b/config/locales/crowdin/ja.yml @@ -1954,6 +1954,7 @@ ja: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "取り消し" + label_add_column: "Add column" label_applied_status: "適用されるステータス" label_archive_project: "プロジェクトをアーカイブ" label_ascending: "昇順" @@ -2122,8 +2123,9 @@ ja: label_feeds_access_key_created_on: "RSSアクセスキーが%{value}前に作成されました" label_feeds_access_key_type: "RSS" label_file_plural: "ファイルを添付する" - label_filter_add: "フィルタを追加" label_filter: "フィルタ" + label_filter_add: "フィルタを追加" + label_filter_by: "Filter by" label_filter_plural: "フィルタ" label_filters_toggle: "フィルターの表示/非表示" label_float: "小数" @@ -2246,6 +2248,8 @@ ja: label_months_from: "ヶ月分" label_more: "続き" label_more_than_ago: "今日より○日前以前" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "ワークパッケージを移動" label_my_account: "Account settings" label_my_activity: "My activity" @@ -2368,6 +2372,7 @@ ja: label_relation_new: "新しい関係" label_release_notes: "リリースノート" label_remaining_work: "残りの作業" + label_remove_column: "Remove column" label_remove_columns: "選択した列を削除" label_renamed: "名称変更" label_reply_plural: "返信" @@ -2408,8 +2413,10 @@ ja: label_show_completed_versions: "終了したバージョンを表示" label_columns: "列" label_sort: "並べ替え" + label_sort_ascending: "Sort ascending" label_sort_by: "%{value}で並べ替え" label_sorted_by: "%{value}で並べ替えている" + label_sort_descending: "Sort descending" label_sort_higher: "上へ" label_sort_highest: "一番上へ移動" label_sort_lower: "下へ移動" diff --git a/config/locales/crowdin/ka.yml b/config/locales/crowdin/ka.yml index fe2446faa2c8..a488c730698b 100644 --- a/config/locales/crowdin/ka.yml +++ b/config/locales/crowdin/ka.yml @@ -1993,6 +1993,7 @@ ka: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "უახლესი" label_ical_access_key_revoke: "გაუქმება" + label_add_column: "Add column" label_applied_status: "გადატარებულია სტატუსი" label_archive_project: "Archive project" label_ascending: "ზრდადობით" @@ -2161,8 +2162,9 @@ ka: label_feeds_access_key_created_on: "RSS access key created %{value} ago" label_feeds_access_key_type: "RSS" label_file_plural: "ფაილები" - label_filter_add: "ფილტრის დამატება" label_filter: "ფილტრები" + label_filter_add: "ფილტრის დამატება" + label_filter_by: "Filter by" label_filter_plural: "ფილტრები" label_filters_toggle: "Show/hide filters" label_float: "მცურავი" @@ -2285,6 +2287,8 @@ ka: label_months_from: "თვეების დასაწყისი" label_more: "კიდევ" label_more_than_ago: "more than days ago" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Move work package" label_my_account: "Account settings" label_my_activity: "ჩემი აქტივობა" @@ -2407,6 +2411,7 @@ ka: label_relation_new: "ახალი ურთიერთობა" label_release_notes: "გამოშვების შენიშვნები" label_remaining_work: "დარჩენილი სამუშაო" + label_remove_column: "Remove column" label_remove_columns: "Remove selected columns" label_renamed: "გადარქმეულია" label_reply_plural: "პასუხები" @@ -2447,8 +2452,10 @@ ka: label_show_completed_versions: "Show completed versions" label_columns: "სვეტები" label_sort: "დალაგება" + label_sort_ascending: "Sort ascending" label_sort_by: "Sort by %{value}" label_sorted_by: "sorted by %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "აწევა" label_sort_highest: "თავში ატანა" label_sort_lower: "ქვემოთ ჩატანა" diff --git a/config/locales/crowdin/kk.yml b/config/locales/crowdin/kk.yml index e7c23c537093..f0db2cea09f9 100644 --- a/config/locales/crowdin/kk.yml +++ b/config/locales/crowdin/kk.yml @@ -1993,6 +1993,7 @@ kk: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "Revoke" + label_add_column: "Add column" label_applied_status: "Applied status" label_archive_project: "Archive project" label_ascending: "Ascending" @@ -2161,8 +2162,9 @@ kk: label_feeds_access_key_created_on: "RSS access key created %{value} ago" label_feeds_access_key_type: "RSS" label_file_plural: "Files" - label_filter_add: "Add filter" label_filter: "Filters" + label_filter_add: "Add filter" + label_filter_by: "Filter by" label_filter_plural: "Filters" label_filters_toggle: "Show/hide filters" label_float: "Float" @@ -2285,6 +2287,8 @@ kk: label_months_from: "months from" label_more: "More" label_more_than_ago: "more than days ago" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Move work package" label_my_account: "Account settings" label_my_activity: "My activity" @@ -2407,6 +2411,7 @@ kk: label_relation_new: "New relation" label_release_notes: "Release notes" label_remaining_work: "Remaining work" + label_remove_column: "Remove column" label_remove_columns: "Remove selected columns" label_renamed: "renamed" label_reply_plural: "Replies" @@ -2447,8 +2452,10 @@ kk: label_show_completed_versions: "Show completed versions" label_columns: "Columns" label_sort: "Sort" + label_sort_ascending: "Sort ascending" label_sort_by: "Sort by %{value}" label_sorted_by: "sorted by %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Move up" label_sort_highest: "Move to top" label_sort_lower: "Move down" diff --git a/config/locales/crowdin/ko.yml b/config/locales/crowdin/ko.yml index 8c22a76c4a93..ce27b9512f59 100644 --- a/config/locales/crowdin/ko.yml +++ b/config/locales/crowdin/ko.yml @@ -1957,6 +1957,7 @@ ko: label_ical_access_key_generation_hint: "캘린더 구독 시 자동으로 생성됩니다." label_ical_access_key_latest: "최신" label_ical_access_key_revoke: "취소" + label_add_column: "Add column" label_applied_status: "적용된 상태" label_archive_project: "프로젝트 아카이브하기" label_ascending: "오름차순" @@ -2125,8 +2126,9 @@ ko: label_feeds_access_key_created_on: "RSS 액세스 키가 %{value} 전에 생성되었습니다." label_feeds_access_key_type: "RSS" label_file_plural: "파일" - label_filter_add: "필터 추가" label_filter: "필터" + label_filter_add: "필터 추가" + label_filter_by: "Filter by" label_filter_plural: "필터" label_filters_toggle: "필터 표시/숨기기" label_float: "부동" @@ -2249,6 +2251,8 @@ ko: label_months_from: "개월 동안 | 다음부터" label_more: "기타" label_more_than_ago: "일 이상 전" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "작업 패키지 이동" label_my_account: "계정 설정" label_my_activity: "내 활동" @@ -2371,6 +2375,7 @@ ko: label_relation_new: "새 관계" label_release_notes: "릴리스 노트" label_remaining_work: "남은 작업" + label_remove_column: "Remove column" label_remove_columns: "선택된 열 제거" label_renamed: "이름 변경됨" label_reply_plural: "회신" @@ -2411,8 +2416,10 @@ ko: label_show_completed_versions: "완료된 버전 표시" label_columns: "열" label_sort: "분류" + label_sort_ascending: "Sort ascending" label_sort_by: "%{value}(으)로 정렬" label_sorted_by: "%{value}(으)로 정렬됨" + label_sort_descending: "Sort descending" label_sort_higher: "위로 이동" label_sort_highest: "맨 위로 이동" label_sort_lower: "아래로 이동" diff --git a/config/locales/crowdin/lt.yml b/config/locales/crowdin/lt.yml index 9bcbd7184b76..c4a3a12e6532 100644 --- a/config/locales/crowdin/lt.yml +++ b/config/locales/crowdin/lt.yml @@ -2060,6 +2060,7 @@ lt: label_ical_access_key_generation_hint: "Automatiškai sukurta prenumeruojant kalendorių." label_ical_access_key_latest: "vėliausias" label_ical_access_key_revoke: "Atšaukti" + label_add_column: "Add column" label_applied_status: "Taikomoji būsena" label_archive_project: "Archyvuoti projektą" label_ascending: "Didėjančia tvarka" @@ -2228,8 +2229,9 @@ lt: label_feeds_access_key_created_on: "RSS prieigos raktas sukurtas prieš %{value}" label_feeds_access_key_type: "RSS" label_file_plural: "Failai" - label_filter_add: "Pridėti filtrą" label_filter: "Filtrai" + label_filter_add: "Pridėti filtrą" + label_filter_by: "Filter by" label_filter_plural: "Filtrai" label_filters_toggle: "Rodyti/slėpti filtrus" label_float: "Skaičius su kableliu" @@ -2352,6 +2354,8 @@ lt: label_months_from: "mėnesiai nuo" label_more: "Daugiau" label_more_than_ago: "daugiau nei prieš dieną" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Perkelti darbų paketą" label_my_account: "Account settings" label_my_activity: "Mano veikla" @@ -2474,6 +2478,7 @@ lt: label_relation_new: "Naujas ryšys" label_release_notes: "Išleidimo informacija" label_remaining_work: "Liko darbo" + label_remove_column: "Remove column" label_remove_columns: "Pašalinti pažymėtus stulpelius" label_renamed: "pervadintas" label_reply_plural: "Atsakymai" @@ -2514,8 +2519,10 @@ lt: label_show_completed_versions: "Rodyti užbaigtas versijas" label_columns: "Stulpeliai" label_sort: "Rikiuoti" + label_sort_ascending: "Sort ascending" label_sort_by: "Rūšiuoti pagal %{value}" label_sorted_by: "suršiuota pagal %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Perkelti aukštyn" label_sort_highest: "Perkelti į viršūnę" label_sort_lower: "Perkelti žemyn" diff --git a/config/locales/crowdin/lv.yml b/config/locales/crowdin/lv.yml index 4cc0f186b4da..4c2a6ae8edd0 100644 --- a/config/locales/crowdin/lv.yml +++ b/config/locales/crowdin/lv.yml @@ -2028,6 +2028,7 @@ lv: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "Revoke" + label_add_column: "Add column" label_applied_status: "Applied status" label_archive_project: "Archive project" label_ascending: "Ascending" @@ -2196,8 +2197,9 @@ lv: label_feeds_access_key_created_on: "API piekļuves atslēga izveidota pirms %{value}" label_feeds_access_key_type: "RSS" label_file_plural: "Faili" - label_filter_add: "Pievienot filtru" label_filter: "Filtri" + label_filter_add: "Pievienot filtru" + label_filter_by: "Filter by" label_filter_plural: "Filtri" label_filters_toggle: "Show/hide filters" label_float: "Float" @@ -2320,6 +2322,8 @@ lv: label_months_from: "months from" label_more: "More" label_more_than_ago: "more than days ago" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Move work package" label_my_account: "Account settings" label_my_activity: "My activity" @@ -2442,6 +2446,7 @@ lv: label_relation_new: "New relation" label_release_notes: "Informācija par laidienu" label_remaining_work: "Atlikušie darbi" + label_remove_column: "Remove column" label_remove_columns: "Remove selected columns" label_renamed: "renamed" label_reply_plural: "Atbildes" @@ -2482,8 +2487,10 @@ lv: label_show_completed_versions: "Rādīt pabeigtas versijas" label_columns: "Kolonnas" label_sort: "Sort" + label_sort_ascending: "Sort ascending" label_sort_by: "Sort by %{value}" label_sorted_by: "sorted by %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Move up" label_sort_highest: "Move to top" label_sort_lower: "Move down" diff --git a/config/locales/crowdin/mn.yml b/config/locales/crowdin/mn.yml index e4ced19229b5..d730a34f2b59 100644 --- a/config/locales/crowdin/mn.yml +++ b/config/locales/crowdin/mn.yml @@ -1993,6 +1993,7 @@ mn: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "Revoke" + label_add_column: "Add column" label_applied_status: "Applied status" label_archive_project: "Archive project" label_ascending: "Ascending" @@ -2161,8 +2162,9 @@ mn: label_feeds_access_key_created_on: "RSS access key created %{value} ago" label_feeds_access_key_type: "RSS" label_file_plural: "Files" - label_filter_add: "Add filter" label_filter: "Filters" + label_filter_add: "Add filter" + label_filter_by: "Filter by" label_filter_plural: "Filters" label_filters_toggle: "Show/hide filters" label_float: "Float" @@ -2285,6 +2287,8 @@ mn: label_months_from: "months from" label_more: "More" label_more_than_ago: "more than days ago" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Move work package" label_my_account: "Account settings" label_my_activity: "My activity" @@ -2407,6 +2411,7 @@ mn: label_relation_new: "New relation" label_release_notes: "Release notes" label_remaining_work: "Remaining work" + label_remove_column: "Remove column" label_remove_columns: "Remove selected columns" label_renamed: "renamed" label_reply_plural: "Replies" @@ -2447,8 +2452,10 @@ mn: label_show_completed_versions: "Show completed versions" label_columns: "Columns" label_sort: "Sort" + label_sort_ascending: "Sort ascending" label_sort_by: "Sort by %{value}" label_sorted_by: "sorted by %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Move up" label_sort_highest: "Move to top" label_sort_lower: "Move down" diff --git a/config/locales/crowdin/ms.yml b/config/locales/crowdin/ms.yml index 21c6ec7427d1..5681bb8bdc6c 100644 --- a/config/locales/crowdin/ms.yml +++ b/config/locales/crowdin/ms.yml @@ -1956,6 +1956,7 @@ ms: label_ical_access_key_generation_hint: "Dijanakan secara automatik apabila melanggan kalendar." label_ical_access_key_latest: "terkini" label_ical_access_key_revoke: "Tarik balik" + label_add_column: "Add column" label_applied_status: "Status yang dilaksanakan" label_archive_project: "Arkib projek" label_ascending: "Menaik" @@ -2124,8 +2125,9 @@ ms: label_feeds_access_key_created_on: "Kunci akses RSS dicipta %{value} yang lalu" label_feeds_access_key_type: "RSS" label_file_plural: "Fail-fail" - label_filter_add: "Tambah penyaring" label_filter: "Penyaring" + label_filter_add: "Tambah penyaring" + label_filter_by: "Filter by" label_filter_plural: "Penyaring" label_filters_toggle: "Paparkan/sembunyikan penyaring\n" label_float: "Terapung" @@ -2248,6 +2250,8 @@ ms: label_months_from: "bulan dari" label_more: "Lagi" label_more_than_ago: "lebih dari beberapa hari yang lalu" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Alih pakej kerja" label_my_account: "Account settings" label_my_activity: "Aktiviti saya" @@ -2370,6 +2374,7 @@ ms: label_relation_new: "Hubungan baharu" label_release_notes: "Nota keluaran" label_remaining_work: "Kerja berbaki" + label_remove_column: "Remove column" label_remove_columns: "Keluarkan kolum yang dipilih" label_renamed: "namakan semula" label_reply_plural: "Balasan" @@ -2410,8 +2415,10 @@ ms: label_show_completed_versions: "Paparkan versi lengkap" label_columns: "Kolum" label_sort: "Susun" + label_sort_ascending: "Sort ascending" label_sort_by: "Susun mengikut %{value}" label_sorted_by: "disusun mengikut %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Alihkan ke atas" label_sort_highest: "Alih ke paling atas" label_sort_lower: "Alih ke bawah" diff --git a/config/locales/crowdin/ne.yml b/config/locales/crowdin/ne.yml index e1ce42f8973f..89ac04a93067 100644 --- a/config/locales/crowdin/ne.yml +++ b/config/locales/crowdin/ne.yml @@ -1993,6 +1993,7 @@ ne: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "रद्द गर्नुहोस्" + label_add_column: "Add column" label_applied_status: "Applied status" label_archive_project: "Archive project" label_ascending: "Ascending" @@ -2161,8 +2162,9 @@ ne: label_feeds_access_key_created_on: "RSS access key created %{value} ago" label_feeds_access_key_type: "RSS" label_file_plural: "Files" - label_filter_add: "Add filter" label_filter: "Filters" + label_filter_add: "Add filter" + label_filter_by: "Filter by" label_filter_plural: "Filters" label_filters_toggle: "Show/hide filters" label_float: "Float" @@ -2285,6 +2287,8 @@ ne: label_months_from: "months from" label_more: "More" label_more_than_ago: "more than days ago" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Move work package" label_my_account: "Account settings" label_my_activity: "My activity" @@ -2407,6 +2411,7 @@ ne: label_relation_new: "New relation" label_release_notes: "Release notes" label_remaining_work: "Remaining work" + label_remove_column: "Remove column" label_remove_columns: "Remove selected columns" label_renamed: "renamed" label_reply_plural: "Replies" @@ -2447,8 +2452,10 @@ ne: label_show_completed_versions: "Show completed versions" label_columns: "Columns" label_sort: "Sort" + label_sort_ascending: "Sort ascending" label_sort_by: "Sort by %{value}" label_sorted_by: "sorted by %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Move up" label_sort_highest: "Move to top" label_sort_lower: "Move down" diff --git a/config/locales/crowdin/nl.yml b/config/locales/crowdin/nl.yml index 48dd2f87ae9e..406ac8a60d70 100644 --- a/config/locales/crowdin/nl.yml +++ b/config/locales/crowdin/nl.yml @@ -1990,6 +1990,7 @@ nl: label_ical_access_key_generation_hint: "Automatisch gegenereerd bij het abonneren op een kalender." label_ical_access_key_latest: "laatste" label_ical_access_key_revoke: "Intrekken" + label_add_column: "Add column" label_applied_status: "Toegepaste status" label_archive_project: "Project archiveren" label_ascending: "Oplopend" @@ -2158,8 +2159,9 @@ nl: label_feeds_access_key_created_on: "RSS toegangssleutel %{value} geleden gemaakt" label_feeds_access_key_type: "RSS" label_file_plural: "Bestanden" - label_filter_add: "Filter toevoegen" label_filter: "Filters" + label_filter_add: "Filter toevoegen" + label_filter_by: "Filter by" label_filter_plural: "Filters" label_filters_toggle: "Filter tonen/verbergen" label_float: "Zwevend" @@ -2282,6 +2284,8 @@ nl: label_months_from: "maanden vanaf" label_more: "Meer" label_more_than_ago: "meer dan dagen geleden" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Verplaatsen werkpakket" label_my_account: "Account settings" label_my_activity: "Mijn activiteit" @@ -2404,6 +2408,7 @@ nl: label_relation_new: "Nieuwe relatie" label_release_notes: "Release notes" label_remaining_work: "Resterend werk" + label_remove_column: "Remove column" label_remove_columns: "Verwijder geselecteerde kolommen" label_renamed: "hernoemd" label_reply_plural: "Reacties" @@ -2444,8 +2449,10 @@ nl: label_show_completed_versions: "Toon voltooide versies" label_columns: "Kolommen" label_sort: "Sorteren" + label_sort_ascending: "Sort ascending" label_sort_by: "Sorteren op %{value}" label_sorted_by: "gesorteerd op %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Omhoog" label_sort_highest: "Verplaats naar boven" label_sort_lower: "Omlaag" diff --git a/config/locales/crowdin/no.yml b/config/locales/crowdin/no.yml index 13c65a581469..126ca30c7700 100644 --- a/config/locales/crowdin/no.yml +++ b/config/locales/crowdin/no.yml @@ -1992,6 +1992,7 @@ label_ical_access_key_generation_hint: "Automatisk generert ved abonnering på en kalender." label_ical_access_key_latest: "siste" label_ical_access_key_revoke: "Tilbakekall" + label_add_column: "Add column" label_applied_status: "Tildelt status" label_archive_project: "Arkiver prosjekt" label_ascending: "Stigende" @@ -2160,8 +2161,9 @@ label_feeds_access_key_created_on: "RSS tilgangsnøkkel opprettet for %{value} siden" label_feeds_access_key_type: "RSS" label_file_plural: "Filer" - label_filter_add: "Legg til filter" label_filter: "Filtre" + label_filter_add: "Legg til filter" + label_filter_by: "Filter by" label_filter_plural: "Filtre" label_filters_toggle: "Vis/skjul filtre" label_float: "Flyt" @@ -2284,6 +2286,8 @@ label_months_from: "måneder fra" label_more: "Mer" label_more_than_ago: "mer enn dager siden" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Flytt arbeidspakke" label_my_account: "Kontoinnstillinger" label_my_activity: "Min aktivitet" @@ -2406,6 +2410,7 @@ label_relation_new: "Ny relasjon" label_release_notes: "Utgivelsesnotater" label_remaining_work: "Gjenstående arbeid" + label_remove_column: "Remove column" label_remove_columns: "Fjern valgte kolonner" label_renamed: "omdøpt" label_reply_plural: "Svar" @@ -2446,8 +2451,10 @@ label_show_completed_versions: "Vis fullførte versjoner" label_columns: "Kolonner" label_sort: "Sortèr" + label_sort_ascending: "Sort ascending" label_sort_by: "Sorter på %{value}" label_sorted_by: "sortert på %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Flytt opp" label_sort_highest: "Flytt til toppen" label_sort_lower: "Flytt ned" diff --git a/config/locales/crowdin/pl.yml b/config/locales/crowdin/pl.yml index e75f666ba083..eb9eeb0b6d1f 100644 --- a/config/locales/crowdin/pl.yml +++ b/config/locales/crowdin/pl.yml @@ -2060,6 +2060,7 @@ pl: label_ical_access_key_generation_hint: "Automatycznie wygenerowano podczas subskrypcji kalendarza." label_ical_access_key_latest: "najnowszy" label_ical_access_key_revoke: "Unieważnij" + label_add_column: "Add column" label_applied_status: "Nadaj status" label_archive_project: "Archiwum projektów" label_ascending: "Rosnąco" @@ -2228,8 +2229,9 @@ pl: label_feeds_access_key_created_on: "Klucz dostępu RSS utworzony %{value} temu" label_feeds_access_key_type: "RSS" label_file_plural: "Pliki" - label_filter_add: "Dodaj filtr" label_filter: "Filtry" + label_filter_add: "Dodaj filtr" + label_filter_by: "Filter by" label_filter_plural: "Filtry" label_filters_toggle: "Pokaż/Ukryj filtry" label_float: "Liczba rzeczywista" @@ -2352,6 +2354,8 @@ pl: label_months_from: "miesięcy od" label_more: "Więcej" label_more_than_ago: "Więcej niż dni temu" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Przenieś pakiet roboczy" label_my_account: "Ustawienia konta" label_my_activity: "Moja aktywność" @@ -2474,6 +2478,7 @@ pl: label_relation_new: "Nowe powiązanie" label_release_notes: "Dziennik zamian" label_remaining_work: "Pozostała praca" + label_remove_column: "Remove column" label_remove_columns: "Usuń zaznaczone kolumny" label_renamed: "przemianowany" label_reply_plural: "Odpowiedzi" @@ -2514,8 +2519,10 @@ pl: label_show_completed_versions: "Pokaż zakończone wersje" label_columns: "Kolumny" label_sort: "Sortowanie" + label_sort_ascending: "Sort ascending" label_sort_by: "Sortuj wg %{value}" label_sorted_by: "kolejność wg %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Przenieś wyżej" label_sort_highest: "Przenieś na samą górę" label_sort_lower: "Przenieś niżej" diff --git a/config/locales/crowdin/pt-BR.yml b/config/locales/crowdin/pt-BR.yml index fed4e04b5e9f..0caecf7edfd6 100644 --- a/config/locales/crowdin/pt-BR.yml +++ b/config/locales/crowdin/pt-BR.yml @@ -1991,6 +1991,7 @@ pt-BR: label_ical_access_key_generation_hint: "Gerado automaticamente ao assinar um calendário." label_ical_access_key_latest: "último" label_ical_access_key_revoke: "Revogar" + label_add_column: "Add column" label_applied_status: "Situação aplicada" label_archive_project: "Arquivar projeto" label_ascending: "Ascendente" @@ -2159,8 +2160,9 @@ pt-BR: label_feeds_access_key_created_on: "Chave de acesso RSS criada %{value} atrás" label_feeds_access_key_type: "RSS" label_file_plural: "Arquivos" - label_filter_add: "Adicionar filtro" label_filter: "Filtros" + label_filter_add: "Adicionar filtro" + label_filter_by: "Filter by" label_filter_plural: "Filtros" label_filters_toggle: "Mostrar/ocultar filtros" label_float: "Ponto flutuante" @@ -2283,6 +2285,8 @@ pt-BR: label_months_from: "meses de" label_more: "Mais" label_more_than_ago: "mais do que dias atrás" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Mover pacote de trabalho" label_my_account: "Configurações de conta" label_my_activity: "Minha atividade" @@ -2405,6 +2409,7 @@ pt-BR: label_relation_new: "Nova relação" label_release_notes: "Notas da versão" label_remaining_work: "Trabalho restante" + label_remove_column: "Remove column" label_remove_columns: "Remover colunas selecionadas" label_renamed: "renomeado" label_reply_plural: "Respostas" @@ -2445,8 +2450,10 @@ pt-BR: label_show_completed_versions: "Mostrar versões concluídas" label_columns: "Colunas" label_sort: "Ordenar" + label_sort_ascending: "Sort ascending" label_sort_by: "Ordenar por %{value}" label_sorted_by: "ordenados por %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Mover para cima" label_sort_highest: "Mover para o topo" label_sort_lower: "Mover para baixo" diff --git a/config/locales/crowdin/pt-PT.yml b/config/locales/crowdin/pt-PT.yml index f863922390b2..0f33db5b3c26 100644 --- a/config/locales/crowdin/pt-PT.yml +++ b/config/locales/crowdin/pt-PT.yml @@ -1991,6 +1991,7 @@ pt-PT: label_ical_access_key_generation_hint: "Gerado automaticamente ao subscrever um calendário." label_ical_access_key_latest: "recente" label_ical_access_key_revoke: "Revogar" + label_add_column: "Add column" label_applied_status: "Status aplicado" label_archive_project: "Arquivar projeto" label_ascending: "Ascendente" @@ -2159,8 +2160,9 @@ pt-PT: label_feeds_access_key_created_on: "Chave de acesso RSS criada %{value} atrás" label_feeds_access_key_type: "RSS" label_file_plural: "Ficheiros" - label_filter_add: "Adicionar filtro" label_filter: "Filtros" + label_filter_add: "Adicionar filtro" + label_filter_by: "Filter by" label_filter_plural: "Filtros" label_filters_toggle: "Mostrar/ocultar filtros" label_float: "Float" @@ -2283,6 +2285,8 @@ pt-PT: label_months_from: "meses desde" label_more: "Mais" label_more_than_ago: "mais do que dias atrás" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Mover tarefa" label_my_account: "Definições da conta" label_my_activity: "A minha atividade" @@ -2405,6 +2409,7 @@ pt-PT: label_relation_new: "Nova relação" label_release_notes: "Notas de lançamento" label_remaining_work: "Trabalho restante" + label_remove_column: "Remove column" label_remove_columns: "Remover colunas selecionadas" label_renamed: "renomeado" label_reply_plural: "Respostas" @@ -2445,8 +2450,10 @@ pt-PT: label_show_completed_versions: "Mostrar versões concluídas" label_columns: "Colunas" label_sort: "Ordenar" + label_sort_ascending: "Sort ascending" label_sort_by: "Ordenar por %{value}" label_sorted_by: "ordenado por %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Subir" label_sort_highest: "Mover para o início" label_sort_lower: "Descer" diff --git a/config/locales/crowdin/ro.yml b/config/locales/crowdin/ro.yml index c02ae2a66dae..ac2d7ad6dd0f 100644 --- a/config/locales/crowdin/ro.yml +++ b/config/locales/crowdin/ro.yml @@ -2028,6 +2028,7 @@ ro: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "Revocă" + label_add_column: "Add column" label_applied_status: "Stare aplicată" label_archive_project: "Proiect de arhivă" label_ascending: "Crescător" @@ -2196,8 +2197,9 @@ ro: label_feeds_access_key_created_on: "Cheia de acces RSS a fost creată acum %{value}" label_feeds_access_key_type: "RSS" label_file_plural: "Fișiere" - label_filter_add: "Adăugare filtru" label_filter: "Filtre" + label_filter_add: "Adăugare filtru" + label_filter_by: "Filter by" label_filter_plural: "Filtre" label_filters_toggle: "Arată/ascunde filtre" label_float: "Număr real" @@ -2320,6 +2322,8 @@ ro: label_months_from: "luni de la" label_more: "Mai mult" label_more_than_ago: "acum mai mult de zile" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Mutare pachet de lucru" label_my_account: "Account settings" label_my_activity: "Activitatea mea" @@ -2442,6 +2446,7 @@ ro: label_relation_new: "Relație nouă" label_release_notes: "Note privind lansarea noii versiuni" label_remaining_work: "Muncă rămasă" + label_remove_column: "Remove column" label_remove_columns: "Eliminare coloane selectate" label_renamed: "redenumit" label_reply_plural: "Răspunsuri" @@ -2482,8 +2487,10 @@ ro: label_show_completed_versions: "Afișare versiuni complete" label_columns: "Coloane" label_sort: "Sortare" + label_sort_ascending: "Sort ascending" label_sort_by: "Sortare după %{value}" label_sorted_by: "sortate după %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Mută în sus" label_sort_highest: "Mută sus" label_sort_lower: "Mută în jos" diff --git a/config/locales/crowdin/ru.yml b/config/locales/crowdin/ru.yml index 0903a132e2e4..4ac8a7ea4b7d 100644 --- a/config/locales/crowdin/ru.yml +++ b/config/locales/crowdin/ru.yml @@ -2062,6 +2062,7 @@ ru: label_ical_access_key_generation_hint: "Автоматически генерируется при подписке на календарь." label_ical_access_key_latest: "последний" label_ical_access_key_revoke: "Отозвать" + label_add_column: "Add column" label_applied_status: "Прикладной статус" label_archive_project: "Архивировать проект" label_ascending: "По возрастанию" @@ -2230,8 +2231,9 @@ ru: label_feeds_access_key_created_on: "Ключ доступа RSS, созданный %{value} назад" label_feeds_access_key_type: "RSS" label_file_plural: "Файлы" - label_filter_add: "Добавить фильтр" label_filter: "Фильтры" + label_filter_add: "Добавить фильтр" + label_filter_by: "Filter by" label_filter_plural: "Фильтры" label_filters_toggle: "Показать/скрыть фильтры" label_float: "Плавающий" @@ -2354,6 +2356,8 @@ ru: label_months_from: "месяцев с" label_more: "Более" label_more_than_ago: "больше, чем дней назад" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Переместить пакет работ" label_my_account: "Настройки учетной записи" label_my_activity: "Моя активность" @@ -2476,6 +2480,7 @@ ru: label_relation_new: "Новая связь" label_release_notes: "Список изменений" label_remaining_work: "Оставшиеся часы" + label_remove_column: "Remove column" label_remove_columns: "Удалить выбранные столбцы" label_renamed: "переименовано" label_reply_plural: "Ответы" @@ -2516,8 +2521,10 @@ ru: label_show_completed_versions: "Показать завершённые этапы" label_columns: "Столбцы" label_sort: "Сортировать" + label_sort_ascending: "Sort ascending" label_sort_by: "Сортировать по %{value}" label_sorted_by: "отсортировано по %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Переместить вверх" label_sort_highest: "В начало" label_sort_lower: "Переместить вниз" diff --git a/config/locales/crowdin/rw.yml b/config/locales/crowdin/rw.yml index d3cec9675f4b..038d91686677 100644 --- a/config/locales/crowdin/rw.yml +++ b/config/locales/crowdin/rw.yml @@ -1993,6 +1993,7 @@ rw: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "Revoke" + label_add_column: "Add column" label_applied_status: "Applied status" label_archive_project: "Archive project" label_ascending: "Ascending" @@ -2161,8 +2162,9 @@ rw: label_feeds_access_key_created_on: "RSS access key created %{value} ago" label_feeds_access_key_type: "RSS" label_file_plural: "Files" - label_filter_add: "Add filter" label_filter: "Filters" + label_filter_add: "Add filter" + label_filter_by: "Filter by" label_filter_plural: "Filters" label_filters_toggle: "Show/hide filters" label_float: "Float" @@ -2285,6 +2287,8 @@ rw: label_months_from: "months from" label_more: "More" label_more_than_ago: "more than days ago" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Move work package" label_my_account: "Account settings" label_my_activity: "My activity" @@ -2407,6 +2411,7 @@ rw: label_relation_new: "New relation" label_release_notes: "Release notes" label_remaining_work: "Remaining work" + label_remove_column: "Remove column" label_remove_columns: "Remove selected columns" label_renamed: "renamed" label_reply_plural: "Replies" @@ -2447,8 +2452,10 @@ rw: label_show_completed_versions: "Show completed versions" label_columns: "Columns" label_sort: "Sort" + label_sort_ascending: "Sort ascending" label_sort_by: "Sort by %{value}" label_sorted_by: "sorted by %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Move up" label_sort_highest: "Move to top" label_sort_lower: "Move down" diff --git a/config/locales/crowdin/si.yml b/config/locales/crowdin/si.yml index 4cf5f76f5100..7e8db7bb2f2f 100644 --- a/config/locales/crowdin/si.yml +++ b/config/locales/crowdin/si.yml @@ -1993,6 +1993,7 @@ si: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "අවලංගු" + label_add_column: "Add column" label_applied_status: "ව්යවහාරික තත්ත්වය" label_archive_project: "සංරක්ෂිත ව්යාපෘතිය" label_ascending: "නැගීම" @@ -2161,8 +2162,9 @@ si: label_feeds_access_key_created_on: "%{value} පෙර නිර්මාණය කරන ලද RSS ප්රවේශ යතුර" label_feeds_access_key_type: "RSS" label_file_plural: "ගොනු" - label_filter_add: "පෙරහන් එකතු කරන්න" label_filter: "පෙරහන්" + label_filter_add: "පෙරහන් එකතු කරන්න" + label_filter_by: "Filter by" label_filter_plural: "පෙරහන්" label_filters_toggle: "පෙරහන් පෙන්වන්න/සඟවන්න" label_float: "පාවෙන" @@ -2285,6 +2287,8 @@ si: label_months_from: "මාස සිට" label_more: "තවත්" label_more_than_ago: "දින කිහිපයකට පෙර" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "වැඩ පැකේජය ගෙනයාම" label_my_account: "Account settings" label_my_activity: "My activity" @@ -2407,6 +2411,7 @@ si: label_relation_new: "නව සම්බන්ධතාවය" label_release_notes: "සටහන් නිකුත් කිරීම" label_remaining_work: "Remaining work" + label_remove_column: "Remove column" label_remove_columns: "තෝරාගත් තීරු ඉවත් කරන්න" label_renamed: "නම් කරන ලද" label_reply_plural: "පිළිතුරු" @@ -2447,8 +2452,10 @@ si: label_show_completed_versions: "සම්පූර්ණ කළ අනුවාද පෙන්වන්න" label_columns: "තීරු" label_sort: "වර්ග" + label_sort_ascending: "Sort ascending" label_sort_by: "%{value}විසින් වර්ග කරන්න" label_sorted_by: "%{value}විසින් වර්ග කර ඇත" + label_sort_descending: "Sort descending" label_sort_higher: "ඉහළට ගෙනයන්න" label_sort_highest: "ඉහළට ගෙනයන්න" label_sort_lower: "පහළට ගෙනයන්න" diff --git a/config/locales/crowdin/sk.yml b/config/locales/crowdin/sk.yml index 5f5ab1294fc7..57ab72ae4661 100644 --- a/config/locales/crowdin/sk.yml +++ b/config/locales/crowdin/sk.yml @@ -2063,6 +2063,7 @@ sk: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "Odvolať" + label_add_column: "Add column" label_applied_status: "Aplikovaný stav" label_archive_project: "Archivovať projekt" label_ascending: "Vzostupne" @@ -2231,8 +2232,9 @@ sk: label_feeds_access_key_created_on: "Prístupový kľúč pre RSS vytvorený %{value} vzad" label_feeds_access_key_type: "RSS kanál" label_file_plural: "Súbory" - label_filter_add: "Pridať filter" label_filter: "Filtre" + label_filter_add: "Pridať filter" + label_filter_by: "Filter by" label_filter_plural: "Filtre" label_filters_toggle: "Zobraziť / skryť filtre" label_float: "Plávajúce (float)" @@ -2355,6 +2357,8 @@ sk: label_months_from: "mesiacov od" label_more: "Viac" label_more_than_ago: "pred viac než dňom" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Premiestniť pracovný balíček" label_my_account: "Account settings" label_my_activity: "My activity" @@ -2477,6 +2481,7 @@ sk: label_relation_new: "Nový vzťah" label_release_notes: "Zoznam zmien" label_remaining_work: "Remaining work" + label_remove_column: "Remove column" label_remove_columns: "Odstrániť vybraté stĺpce" label_renamed: "premenované" label_reply_plural: "Odpovede" @@ -2517,8 +2522,10 @@ sk: label_show_completed_versions: "Zobraziť dokončené verzie" label_columns: "Stĺpce" label_sort: "Zoradiť" + label_sort_ascending: "Sort ascending" label_sort_by: "Zoradiť podľa %{value}" label_sorted_by: "zoradené podľa %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Presunúť vyššie" label_sort_highest: "Presunúť navrch" label_sort_lower: "Presunúť nižšie" diff --git a/config/locales/crowdin/sl.yml b/config/locales/crowdin/sl.yml index c4852130d3f8..8cf4819bf8c0 100644 --- a/config/locales/crowdin/sl.yml +++ b/config/locales/crowdin/sl.yml @@ -2060,6 +2060,7 @@ sl: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "Prekliči" + label_add_column: "Add column" label_applied_status: "Uveljavljeno stanje" label_archive_project: "Arhivirani projekti" label_ascending: "Naraščajoče" @@ -2228,8 +2229,9 @@ sl: label_feeds_access_key_created_on: "RSS dostopni ključ narejen pred %{value}" label_feeds_access_key_type: "RSS" label_file_plural: "Datoteke" - label_filter_add: "Dodaj filter" label_filter: "Filtri" + label_filter_add: "Dodaj filter" + label_filter_by: "Filter by" label_filter_plural: "Filtri" label_filters_toggle: "Prikaži/skrij filter" label_float: "Lebdeti" @@ -2352,6 +2354,8 @@ sl: label_months_from: "mesecev od" label_more: "več" label_more_than_ago: "pred več kot nekaj dnevi" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Prestavi delovni paket" label_my_account: "Account settings" label_my_activity: "My activity" @@ -2474,6 +2478,7 @@ sl: label_relation_new: "Nova povezava" label_release_notes: "Opombe ob izdaji" label_remaining_work: "Remaining work" + label_remove_column: "Remove column" label_remove_columns: "Odstranite izbrane stolpce" label_renamed: "Preimenuj" label_reply_plural: "Odgovori" @@ -2514,8 +2519,10 @@ sl: label_show_completed_versions: "Prikaži zaključene različice" label_columns: "Stolpci" label_sort: "Razvrsti" + label_sort_ascending: "Sort ascending" label_sort_by: "Razporedi po %{value}" label_sorted_by: "Razporejeno po %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Premakni gor" label_sort_highest: "Premakni na vrh" label_sort_lower: "Premakni dol" diff --git a/config/locales/crowdin/sr.yml b/config/locales/crowdin/sr.yml index 62edcfdebd34..18f738533d8b 100644 --- a/config/locales/crowdin/sr.yml +++ b/config/locales/crowdin/sr.yml @@ -2028,6 +2028,7 @@ sr: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "Revoke" + label_add_column: "Add column" label_applied_status: "Applied status" label_archive_project: "Archive project" label_ascending: "Ascending" @@ -2196,8 +2197,9 @@ sr: label_feeds_access_key_created_on: "RSS access key created %{value} ago" label_feeds_access_key_type: "RSS" label_file_plural: "Files" - label_filter_add: "Add filter" label_filter: "Filters" + label_filter_add: "Add filter" + label_filter_by: "Filter by" label_filter_plural: "Filters" label_filters_toggle: "Show/hide filters" label_float: "Float" @@ -2320,6 +2322,8 @@ sr: label_months_from: "months from" label_more: "More" label_more_than_ago: "more than days ago" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Move work package" label_my_account: "Account settings" label_my_activity: "My activity" @@ -2442,6 +2446,7 @@ sr: label_relation_new: "New relation" label_release_notes: "Release notes" label_remaining_work: "Remaining work" + label_remove_column: "Remove column" label_remove_columns: "Remove selected columns" label_renamed: "renamed" label_reply_plural: "Replies" @@ -2482,8 +2487,10 @@ sr: label_show_completed_versions: "Show completed versions" label_columns: "Columns" label_sort: "Sort" + label_sort_ascending: "Sort ascending" label_sort_by: "Sort by %{value}" label_sorted_by: "sorted by %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Move up" label_sort_highest: "Move to top" label_sort_lower: "Move down" diff --git a/config/locales/crowdin/sv.yml b/config/locales/crowdin/sv.yml index 672f27bcc09b..7b6594bb21bc 100644 --- a/config/locales/crowdin/sv.yml +++ b/config/locales/crowdin/sv.yml @@ -1992,6 +1992,7 @@ sv: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "Återkalla" + label_add_column: "Add column" label_applied_status: "Tillämpad status" label_archive_project: "Arkivera projekt" label_ascending: "Stigande" @@ -2160,8 +2161,9 @@ sv: label_feeds_access_key_created_on: "RSS access key skapad %{value} sen" label_feeds_access_key_type: "RSS" label_file_plural: "Filer" - label_filter_add: "Lägg till filter" label_filter: "Filter" + label_filter_add: "Lägg till filter" + label_filter_by: "Filter by" label_filter_plural: "Filter" label_filters_toggle: "Visa/dölj filter" label_float: "Flyttal" @@ -2284,6 +2286,8 @@ sv: label_months_from: "månader från" label_more: "Mer" label_more_than_ago: "mer än dagar sedan" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Flytta arbetspaket" label_my_account: "Kontoinställningar" label_my_activity: "Min aktivitet" @@ -2406,6 +2410,7 @@ sv: label_relation_new: "Ny relation" label_release_notes: "Versionsnoteringar" label_remaining_work: "Remaining work" + label_remove_column: "Remove column" label_remove_columns: "Ta bort markerade kolumner" label_renamed: "ändrat namn" label_reply_plural: "Svar" @@ -2446,8 +2451,10 @@ sv: label_show_completed_versions: "Visa slutförda versioner" label_columns: "Kolumner" label_sort: "Sortera" + label_sort_ascending: "Sort ascending" label_sort_by: "Sortera efter %{value}" label_sorted_by: "sorterade efter %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Flytta upp" label_sort_highest: "Flytta till toppen" label_sort_lower: "Flytta ned" diff --git a/config/locales/crowdin/th.yml b/config/locales/crowdin/th.yml index c439ccff3efa..ddc237552530 100644 --- a/config/locales/crowdin/th.yml +++ b/config/locales/crowdin/th.yml @@ -1958,6 +1958,7 @@ th: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "Revoke" + label_add_column: "Add column" label_applied_status: "ใช้สถานะ" label_archive_project: "Archive project" label_ascending: "จากน้อยไปมาก" @@ -2126,8 +2127,9 @@ th: label_feeds_access_key_created_on: "คีย์การเข้าถึง API ซึ่งสร้างเมื่อ %{value} ที่ผ่านมา" label_feeds_access_key_type: "RSS" label_file_plural: "ไฟล์" - label_filter_add: "เพิ่มตัวกรอง" label_filter: "ตัวกรอง" + label_filter_add: "เพิ่มตัวกรอง" + label_filter_by: "Filter by" label_filter_plural: "ตัวกรอง" label_filters_toggle: "Show/hide filters" label_float: "ลอย" @@ -2250,6 +2252,8 @@ th: label_months_from: "เดือนจาก" label_more: "เพิ่มเติม" label_more_than_ago: "มากกว่าวันที่ผ่านมา" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "ย้ายชุดภารกิจ" label_my_account: "Account settings" label_my_activity: "My activity" @@ -2372,6 +2376,7 @@ th: label_relation_new: "ความสัมพันธ์ใหม่" label_release_notes: "Release notes" label_remaining_work: "Remaining work" + label_remove_column: "Remove column" label_remove_columns: "ลบคอลัมน์ที่เลือก" label_renamed: "เปลี่ยนชื่อ" label_reply_plural: "ตอบกลับ" @@ -2412,8 +2417,10 @@ th: label_show_completed_versions: "แสดงเวอร์ชันที่เสร็จสมบูรณ์" label_columns: "คอลัมน์" label_sort: "เรียงลำดับ" + label_sort_ascending: "Sort ascending" label_sort_by: "เรียงตาม %{value}" label_sorted_by: "เรียงตาม %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "เลื่อนขึ้น" label_sort_highest: "เลื่อนไว้บนสุด" label_sort_lower: "เลื่อนลง" diff --git a/config/locales/crowdin/tr.yml b/config/locales/crowdin/tr.yml index eecb7e03868f..cb7a4e18c0c1 100644 --- a/config/locales/crowdin/tr.yml +++ b/config/locales/crowdin/tr.yml @@ -1992,6 +1992,7 @@ tr: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "en son" label_ical_access_key_revoke: "İptal etmek" + label_add_column: "Add column" label_applied_status: "Uygulanan statü" label_archive_project: "Projeyi arşivle" label_ascending: "Azalan" @@ -2160,8 +2161,9 @@ tr: label_feeds_access_key_created_on: "RSS erişim anahtarı %{value} önce oluşturuldu" label_feeds_access_key_type: "RSS" label_file_plural: "Dosyalar" - label_filter_add: "Filtre ekle" label_filter: "Filtreler" + label_filter_add: "Filtre ekle" + label_filter_by: "Filter by" label_filter_plural: "Filtreler" label_filters_toggle: "Filtreleri göster / gizle" label_float: "Ondalık" @@ -2284,6 +2286,8 @@ tr: label_months_from: "aydan itibaren" label_more: "Fazlası" label_more_than_ago: "günler önce" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "İş paketini taşı" label_my_account: "Account settings" label_my_activity: "My activity" @@ -2406,6 +2410,7 @@ tr: label_relation_new: "Yeni ilişki" label_release_notes: "Sürüm notları" label_remaining_work: "Kalan çalışma" + label_remove_column: "Remove column" label_remove_columns: "Seçili sütunları sil" label_renamed: "yeniden adlandırıldı" label_reply_plural: "Yanıtlar" @@ -2446,8 +2451,10 @@ tr: label_show_completed_versions: "Tamamlanmış sürümleri göster" label_columns: "Sütunlar" label_sort: "Sırala" + label_sort_ascending: "Sort ascending" label_sort_by: "%{value} ile sırala" label_sorted_by: "%{value} göre sıralandı" + label_sort_descending: "Sort descending" label_sort_higher: "Yukarı taşı" label_sort_highest: "En üste taşı" label_sort_lower: "Aşağı taşı" diff --git a/config/locales/crowdin/uk.yml b/config/locales/crowdin/uk.yml index 5c657f313162..09ddda5eb374 100644 --- a/config/locales/crowdin/uk.yml +++ b/config/locales/crowdin/uk.yml @@ -2057,6 +2057,7 @@ uk: label_ical_access_key_generation_hint: "Автоматично створено під час оформлення підписки на календар." label_ical_access_key_latest: "останні" label_ical_access_key_revoke: "Скасувати" + label_add_column: "Add column" label_applied_status: "Застосовний статус" label_archive_project: "Архівний проект" label_ascending: "За зростанням" @@ -2225,8 +2226,9 @@ uk: label_feeds_access_key_created_on: "Створено ключ доступу RSS %{value} тому" label_feeds_access_key_type: "RSS" label_file_plural: "Файли" - label_filter_add: "Додати фільтр" label_filter: "Фільтри" + label_filter_add: "Додати фільтр" + label_filter_by: "Filter by" label_filter_plural: "Фільтри" label_filters_toggle: "Показати / приховати фільтри" label_float: "З плаваючою крапкою" @@ -2349,6 +2351,8 @@ uk: label_months_from: "місяців(ця) з" label_more: "Більше" label_more_than_ago: "більш ніж днів(я) назад" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Перемістити робочий пакет" label_my_account: "Налаштування облікового запису" label_my_activity: "Мої дії" @@ -2471,6 +2475,7 @@ uk: label_relation_new: "Новий зв'язок" label_release_notes: "Примітки до випуску" label_remaining_work: "Залишок роботи" + label_remove_column: "Remove column" label_remove_columns: "Видалити вибрані стовпці" label_renamed: "перейменовано" label_reply_plural: "Відповіді" @@ -2511,8 +2516,10 @@ uk: label_show_completed_versions: "Показати завершені версії" label_columns: "Стовпці" label_sort: "Сортувати" + label_sort_ascending: "Sort ascending" label_sort_by: "Сортувати за %{value}" label_sorted_by: "відсортовані за %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Рухатися вгору" label_sort_highest: "Помістити на верх" label_sort_lower: "Пересунути нижче" diff --git a/config/locales/crowdin/uz.yml b/config/locales/crowdin/uz.yml index 8f5e5cae79c5..da482d086ec3 100644 --- a/config/locales/crowdin/uz.yml +++ b/config/locales/crowdin/uz.yml @@ -1993,6 +1993,7 @@ uz: label_ical_access_key_generation_hint: "Automatically generated when subscribing to a calendar." label_ical_access_key_latest: "latest" label_ical_access_key_revoke: "Revoke" + label_add_column: "Add column" label_applied_status: "Applied status" label_archive_project: "Archive project" label_ascending: "Ascending" @@ -2161,8 +2162,9 @@ uz: label_feeds_access_key_created_on: "RSS access key created %{value} ago" label_feeds_access_key_type: "RSS" label_file_plural: "Files" - label_filter_add: "Add filter" label_filter: "Filters" + label_filter_add: "Add filter" + label_filter_by: "Filter by" label_filter_plural: "Filters" label_filters_toggle: "Show/hide filters" label_float: "Float" @@ -2285,6 +2287,8 @@ uz: label_months_from: "months from" label_more: "More" label_more_than_ago: "more than days ago" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Move work package" label_my_account: "Account settings" label_my_activity: "My activity" @@ -2407,6 +2411,7 @@ uz: label_relation_new: "New relation" label_release_notes: "Release notes" label_remaining_work: "Remaining work" + label_remove_column: "Remove column" label_remove_columns: "Remove selected columns" label_renamed: "renamed" label_reply_plural: "Replies" @@ -2447,8 +2452,10 @@ uz: label_show_completed_versions: "Show completed versions" label_columns: "Columns" label_sort: "Sort" + label_sort_ascending: "Sort ascending" label_sort_by: "Sort by %{value}" label_sorted_by: "sorted by %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Move up" label_sort_highest: "Move to top" label_sort_lower: "Move down" diff --git a/config/locales/crowdin/vi.yml b/config/locales/crowdin/vi.yml index 9185f587e8d5..3976670e7aae 100644 --- a/config/locales/crowdin/vi.yml +++ b/config/locales/crowdin/vi.yml @@ -1960,6 +1960,7 @@ vi: label_ical_access_key_generation_hint: "Tự động tạo khi đăng ký lịch." label_ical_access_key_latest: "mới nhất" label_ical_access_key_revoke: "Thu hồi" + label_add_column: "Add column" label_applied_status: "Tình trạng áp dụng" label_archive_project: "Lưu trữ dự án" label_ascending: "Tăng dần" @@ -2128,8 +2129,9 @@ vi: label_feeds_access_key_created_on: "Khoá truy cập API đựơc tạo cách đây %{value}" label_feeds_access_key_type: "RSS" label_file_plural: "Tệp" - label_filter_add: "Thêm bộ lọc" label_filter: "Bộ lọc" + label_filter_add: "Thêm bộ lọc" + label_filter_by: "Filter by" label_filter_plural: "Bộ lọc" label_filters_toggle: "Hiển thị/ẩn bộ lọc" label_float: "Số thực" @@ -2252,6 +2254,8 @@ vi: label_months_from: "tháng từ" label_more: "Xem thêm" label_more_than_ago: "nhiều hơn mấy ngày trước" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "Di chuyển work package" label_my_account: "Account settings" label_my_activity: "Hoạt động của tôi" @@ -2374,6 +2378,7 @@ vi: label_relation_new: "Quan hệ mới" label_release_notes: "Ghi chú phát hành" label_remaining_work: "Công việc còn lại" + label_remove_column: "Remove column" label_remove_columns: "Loại bỏ cột được chọn" label_renamed: "đổi tên" label_reply_plural: "Trả lời" @@ -2414,8 +2419,10 @@ vi: label_show_completed_versions: "Xem phiên bản đã hoàn thành" label_columns: "Cột" label_sort: "Sắp xếp" + label_sort_ascending: "Sort ascending" label_sort_by: "Sắp xếp theo %{value}" label_sorted_by: "sắp xếp theo %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "Chuyển lên" label_sort_highest: "Lên trên cùng" label_sort_lower: "Dịch xuống" diff --git a/config/locales/crowdin/zh-CN.yml b/config/locales/crowdin/zh-CN.yml index cb45827e8f72..1f7479d080f7 100644 --- a/config/locales/crowdin/zh-CN.yml +++ b/config/locales/crowdin/zh-CN.yml @@ -26,20 +26,20 @@ zh-CN: no_results_title_text: 在这个时间范围内没有任何项目的活动。 work_packages: activity_tab: - no_results_title_text: No activity to display - no_results_description_text: "Choose \"Show everything\" to show all activity and comments" - label_activity_show_all: "Show everything" - label_activity_show_only_comments: "Show comments only" - label_activity_show_only_changes: "Show changes only" - label_sort_asc: "Newest at the bottom" - label_sort_desc: "Newest on top" - label_type_to_comment: "Type here to comment" - label_submit_comment: "Submit comment" - changed_on: "changed on" - created_on: "created this on" - changed: "changed" - created: "created" - commented: "commented" + no_results_title_text: 没有显示的活动 + no_results_description_text: "选择 \"显示所有 \"可以显示所有活动和评论" + label_activity_show_all: "显示所有" + label_activity_show_only_comments: "仅显示评论" + label_activity_show_only_changes: "仅显示更改" + label_sort_asc: "时间升序" + label_sort_desc: "时间降序" + label_type_to_comment: "在此输入评论" + label_submit_comment: "提交评论" + changed_on: "更改于" + created_on: "创建于" + changed: "已更改" + created: "已创建" + commented: "已评论" admin: plugins: no_results_title_text: 目前没有安装插件。 @@ -221,7 +221,7 @@ zh-CN: blankslate: title: "您的条目列表为空" description: "首先,将条目添加到类型层次结构的自定义字段中。每个条目都可以用于在其下方创建一个层次结构。要导航并在层次结构中创建子条目,请点击已创建的条目。" - actions: "Item actions" + actions: "项目操作" placeholder: label: "项目标签" short: "短名" @@ -1953,6 +1953,7 @@ zh-CN: label_ical_access_key_generation_hint: "订阅日历时自动生成的。" label_ical_access_key_latest: "最近" label_ical_access_key_revoke: "撤消" + label_add_column: "Add column" label_applied_status: "应用的状态" label_archive_project: "归档项目" label_ascending: "升序" @@ -2121,8 +2122,9 @@ zh-CN: label_feeds_access_key_created_on: "RSS 访问密钥创建于 %{value} 前" label_feeds_access_key_type: "RSS 订阅" label_file_plural: "文件" - label_filter_add: "添加筛选器" label_filter: "筛选器" + label_filter_add: "添加筛选器" + label_filter_by: "Filter by" label_filter_plural: "筛选器" label_filters_toggle: "显示/隐藏筛选器" label_float: "浮点数" @@ -2245,6 +2247,8 @@ zh-CN: label_months_from: "月份从" label_more: "更多" label_more_than_ago: "更多天前" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "移动工作包" label_my_account: "帐户设置" label_my_activity: "我的活动" @@ -2367,6 +2371,7 @@ zh-CN: label_relation_new: "新的关系" label_release_notes: "更新日志" label_remaining_work: "剩余工时" + label_remove_column: "Remove column" label_remove_columns: "删除选定的列" label_renamed: "重命名" label_reply_plural: "答复" @@ -2407,8 +2412,10 @@ zh-CN: label_show_completed_versions: "显示已完成的版本" label_columns: "列" label_sort: "排序" + label_sort_ascending: "Sort ascending" label_sort_by: "排序按 %{value}" label_sorted_by: "排序按 %{value}" + label_sort_descending: "Sort descending" label_sort_higher: "向上移动" label_sort_highest: "移至顶部" label_sort_lower: "向下移动" diff --git a/config/locales/crowdin/zh-TW.yml b/config/locales/crowdin/zh-TW.yml index d27658a5f0bb..9f04eb85e8fc 100644 --- a/config/locales/crowdin/zh-TW.yml +++ b/config/locales/crowdin/zh-TW.yml @@ -26,20 +26,20 @@ zh-TW: no_results_title_text: 在這一時限內, 該專案沒有任何活動。 work_packages: activity_tab: - no_results_title_text: No activity to display - no_results_description_text: "Choose \"Show everything\" to show all activity and comments" - label_activity_show_all: "Show everything" - label_activity_show_only_comments: "Show comments only" - label_activity_show_only_changes: "Show changes only" - label_sort_asc: "Newest at the bottom" - label_sort_desc: "Newest on top" - label_type_to_comment: "Type here to comment" - label_submit_comment: "Submit comment" - changed_on: "changed on" - created_on: "created this on" - changed: "changed" - created: "created" - commented: "commented" + no_results_title_text: 無活動顯示 + no_results_description_text: "選擇「顯示所有內容」以顯示所有活動和留言" + label_activity_show_all: "全部顯示" + label_activity_show_only_comments: "僅顯示留言" + label_activity_show_only_changes: "僅顯示差異" + label_sort_asc: "最新的在最下面" + label_sort_desc: "最新的在最上面" + label_type_to_comment: "在此留言" + label_submit_comment: "留言" + changed_on: "異動於" + created_on: "新增於" + changed: "變更" + created: "建立" + commented: "已留言" admin: plugins: no_results_title_text: 目前沒有安裝任何外掛程式 @@ -223,7 +223,7 @@ zh-TW: blankslate: title: "您的欄位清單是空的" description: "首先將項目新增至層次結構的自訂欄位。每個項目都可用於在其下方建立一個層次結構。若要在層次結構內導覽和建立子項目,請按一下已建立的項目。" - actions: "Item actions" + actions: "自訂動作" placeholder: label: "項目標籤" short: "簡稱" @@ -1955,6 +1955,7 @@ zh-TW: label_ical_access_key_generation_hint: "訂閱日曆時自動生成的。" label_ical_access_key_latest: "最新" label_ical_access_key_revoke: "撤銷" + label_add_column: "Add column" label_applied_status: "套用的狀態" label_archive_project: "封存專案" label_ascending: "昇冪" @@ -2123,8 +2124,9 @@ zh-TW: label_feeds_access_key_created_on: "RSS 金鑰在 %{value} 前已建立" label_feeds_access_key_type: "RSS訂閱" label_file_plural: "檔案" - label_filter_add: "新增條件" label_filter: "篩選條件" + label_filter_add: "新增條件" + label_filter_by: "Filter by" label_filter_plural: "篩選器" label_filters_toggle: "顯示/隱藏篩選條件" label_float: "浮點數" @@ -2247,6 +2249,8 @@ zh-TW: label_months_from: "從幾個月" label_more: "更多" label_more_than_ago: "超過幾天" + label_move_column_left: "Move column left" + label_move_column_right: "Move column right" label_move_work_package: "移動工作項目" label_my_account: "帳號設定" label_my_activity: "我的活動紀錄" @@ -2369,6 +2373,7 @@ zh-TW: label_relation_new: "新增關聯" label_release_notes: "發行說明" label_remaining_work: "剩餘工作" + label_remove_column: "Remove column" label_remove_columns: "移除所選欄" label_renamed: "重新命名" label_reply_plural: "回覆" @@ -2409,8 +2414,10 @@ zh-TW: label_show_completed_versions: "顯示已完成的版本" label_columns: "欄位" label_sort: "排序" + label_sort_ascending: "Sort ascending" label_sort_by: "按 %{value} 排序" label_sorted_by: "以 %{value} 排序" + label_sort_descending: "Sort descending" label_sort_higher: "往上移動" label_sort_highest: "移到頂端" label_sort_lower: "往下移動" From a889df6c3593641dc1ed1b918d2e43b039658585 Mon Sep 17 00:00:00 2001 From: OpenProject Actions CI Date: Sun, 20 Oct 2024 03:13:59 +0000 Subject: [PATCH 047/115] update locales from crowdin [ci skip] --- config/locales/crowdin/ru.yml | 46 ++-- config/locales/crowdin/zh-CN.yml | 14 +- .../bim/config/locales/crowdin/ru.seeders.yml | 200 +++++++++--------- .../config/locales/crowdin/ru.yml | 6 +- .../reporting/config/locales/crowdin/ru.yml | 2 +- 5 files changed, 134 insertions(+), 134 deletions(-) diff --git a/config/locales/crowdin/ru.yml b/config/locales/crowdin/ru.yml index 4ac8a7ea4b7d..c070ac95b9a9 100644 --- a/config/locales/crowdin/ru.yml +++ b/config/locales/crowdin/ru.yml @@ -26,20 +26,20 @@ ru: no_results_title_text: В течение этого периода времени не было никакой деятельности по проекту. work_packages: activity_tab: - no_results_title_text: No activity to display - no_results_description_text: "Choose \"Show everything\" to show all activity and comments" - label_activity_show_all: "Show everything" - label_activity_show_only_comments: "Show comments only" - label_activity_show_only_changes: "Show changes only" - label_sort_asc: "Newest at the bottom" - label_sort_desc: "Newest on top" - label_type_to_comment: "Type here to comment" - label_submit_comment: "Submit comment" - changed_on: "changed on" + no_results_title_text: Нет деятельности для отображения + no_results_description_text: "Выберите \"Показать всё\" для отображения всей деятельности и комментариев" + label_activity_show_all: "Показать всё" + label_activity_show_only_comments: "Показать только комментарии" + label_activity_show_only_changes: "Показать только изменения" + label_sort_asc: "Новые внизу" + label_sort_desc: "Новые вверху" + label_type_to_comment: "Введите комментарий" + label_submit_comment: "Отправить комментарий" + changed_on: "изменено" created_on: "created this on" - changed: "changed" - created: "created" - commented: "commented" + changed: "изменено" + created: "создано" + commented: "прокомментировано" admin: plugins: no_results_title_text: В настоящее время плагины не установлены. @@ -238,9 +238,9 @@ ru: reorder_alphabetical: "Переупорядочить значения по алфавиту" reorder_confirmation: "Внимание: Текущий порядок доступных значений будет утерян. Продолжить?" instructions: - is_required: "" + is_required: "Пометить пользовательское поле как обязательное. Это сделает заполнение поля обязательным при создании новых или обновлении существующих ресурсов." is_required_for_project: "Установите флажок, чтобы включить этот атрибут и сделать его обязательным для всех проектов. Он не может быть отключен для отдельных проектов." - is_for_all: "" + is_for_all: "Отметьте пользовательское поле как доступное во всех существующих и новых проектах." multi_select: "Позволяет пользователю присвоить несколько значений этому пользовательскому полю." searchable: "Включать значения полей при использовании функции глобального поиска." searchable_for_project: "Установите флажок, чтобы сделать этот атрибут доступным в качестве фильтра в списках проектов." @@ -1640,7 +1640,7 @@ ru: x_years: one: "1 год" few: "%{count} года" - many: "%{count} years" + many: "%{count} лет" other: "%{count} лет" x_seconds: one: "1 секунда" @@ -2062,7 +2062,7 @@ ru: label_ical_access_key_generation_hint: "Автоматически генерируется при подписке на календарь." label_ical_access_key_latest: "последний" label_ical_access_key_revoke: "Отозвать" - label_add_column: "Add column" + label_add_column: "Добавить столбец" label_applied_status: "Прикладной статус" label_archive_project: "Архивировать проект" label_ascending: "По возрастанию" @@ -2233,7 +2233,7 @@ ru: label_file_plural: "Файлы" label_filter: "Фильтры" label_filter_add: "Добавить фильтр" - label_filter_by: "Filter by" + label_filter_by: "Фильтровать по" label_filter_plural: "Фильтры" label_filters_toggle: "Показать/скрыть фильтры" label_float: "Плавающий" @@ -2356,8 +2356,8 @@ ru: label_months_from: "месяцев с" label_more: "Более" label_more_than_ago: "больше, чем дней назад" - label_move_column_left: "Move column left" - label_move_column_right: "Move column right" + label_move_column_left: "Переместить столбец влево" + label_move_column_right: "Переместить столбец вправо" label_move_work_package: "Переместить пакет работ" label_my_account: "Настройки учетной записи" label_my_activity: "Моя активность" @@ -2480,7 +2480,7 @@ ru: label_relation_new: "Новая связь" label_release_notes: "Список изменений" label_remaining_work: "Оставшиеся часы" - label_remove_column: "Remove column" + label_remove_column: "Удалить столбец" label_remove_columns: "Удалить выбранные столбцы" label_renamed: "переименовано" label_reply_plural: "Ответы" @@ -2521,10 +2521,10 @@ ru: label_show_completed_versions: "Показать завершённые этапы" label_columns: "Столбцы" label_sort: "Сортировать" - label_sort_ascending: "Sort ascending" + label_sort_ascending: "Сортировать по возрастанию" label_sort_by: "Сортировать по %{value}" label_sorted_by: "отсортировано по %{value}" - label_sort_descending: "Sort descending" + label_sort_descending: "Сортировать по убыванию" label_sort_higher: "Переместить вверх" label_sort_highest: "В начало" label_sort_lower: "Переместить вниз" diff --git a/config/locales/crowdin/zh-CN.yml b/config/locales/crowdin/zh-CN.yml index 1f7479d080f7..cd12f35d0404 100644 --- a/config/locales/crowdin/zh-CN.yml +++ b/config/locales/crowdin/zh-CN.yml @@ -1953,7 +1953,7 @@ zh-CN: label_ical_access_key_generation_hint: "订阅日历时自动生成的。" label_ical_access_key_latest: "最近" label_ical_access_key_revoke: "撤消" - label_add_column: "Add column" + label_add_column: "添加列" label_applied_status: "应用的状态" label_archive_project: "归档项目" label_ascending: "升序" @@ -2124,7 +2124,7 @@ zh-CN: label_file_plural: "文件" label_filter: "筛选器" label_filter_add: "添加筛选器" - label_filter_by: "Filter by" + label_filter_by: "筛选条件:" label_filter_plural: "筛选器" label_filters_toggle: "显示/隐藏筛选器" label_float: "浮点数" @@ -2247,8 +2247,8 @@ zh-CN: label_months_from: "月份从" label_more: "更多" label_more_than_ago: "更多天前" - label_move_column_left: "Move column left" - label_move_column_right: "Move column right" + label_move_column_left: "左移列" + label_move_column_right: "右移列" label_move_work_package: "移动工作包" label_my_account: "帐户设置" label_my_activity: "我的活动" @@ -2371,7 +2371,7 @@ zh-CN: label_relation_new: "新的关系" label_release_notes: "更新日志" label_remaining_work: "剩余工时" - label_remove_column: "Remove column" + label_remove_column: "移除列" label_remove_columns: "删除选定的列" label_renamed: "重命名" label_reply_plural: "答复" @@ -2412,10 +2412,10 @@ zh-CN: label_show_completed_versions: "显示已完成的版本" label_columns: "列" label_sort: "排序" - label_sort_ascending: "Sort ascending" + label_sort_ascending: "升序排列" label_sort_by: "排序按 %{value}" label_sorted_by: "排序按 %{value}" - label_sort_descending: "Sort descending" + label_sort_descending: "降序排列" label_sort_higher: "向上移动" label_sort_highest: "移至顶部" label_sort_lower: "向下移动" diff --git a/modules/bim/config/locales/crowdin/ru.seeders.yml b/modules/bim/config/locales/crowdin/ru.seeders.yml index e8fd2874e766..d709222a3d9a 100644 --- a/modules/bim/config/locales/crowdin/ru.seeders.yml +++ b/modules/bim/config/locales/crowdin/ru.seeders.yml @@ -161,21 +161,21 @@ ru: options: name: Приступая к работе text: | - We are glad you joined! We suggest to try a few things to get started in OpenProject. + Мы рады, что Вы присоединились! Рекомендуем попробовать некоторые функции OpenProject для начала. - Here you will find the classical roles, some workflows and work packages for your construction project. + В данный момент здесь Вы найдете классические роли, немного рабочих потоков и пакетов для Вашего проекта строительства. - _Try the following steps:_ + _Попробуйте следующее:_ - 1. _Invite new members to your project:_ → Go to [Members]({{opSetting:base_url}}/projects/demo-planning-constructing-project/members) in the project navigation. - 2. _View the work in your projects:_ → Go to [Work packages]({{opSetting:base_url}}/projects/demo-planning-constructing-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) in the project navigation. - 3. _Create a new work package:_ → Go to [Work packages → Create]({{opSetting:base_url}}/projects/demo-planning-constructing-project/work_packages/new?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D&type=11). - 4. _Create and update a Gantt chart:_ → Go to [Gantt chart]({{opSetting:base_url}}/projects/demo-planning-constructing-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22assignee%22%2C%22responsible%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22weeks%22%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) in the project navigation. - 5. _Activate further modules:_ → Go to [Project settings → Modules]({{opSetting:base_url}}/projects/demo-planning-constructing-project/settings/modules). - 6. _Working agile? Create a new board:_ → Go to [Boards]({{opSetting:base_url}}/projects/demo-planning-constructing-project/boards) + 1. _Пригласите новых участников в проект:_ → Перейдите в раздел [Участники]({{opSetting:base_url}}/projects/demo-planning-constructing-project/members) в боковом меню проекта. + 2. _Посмотрите работы в ваших проектах:_ → Перейдите в раздел [Комплекс работ]({{opSetting:base_url}}/projects/demo-planning-constructing-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) в боковом меню проекта. + 3. _Создайте новый Комплекс работ:_ → Перейдите в раздел [Комлекс работ → Создать]({{opSetting:base_url}}/projects/demo-planning-constructing-project/work_packages/new?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D&type=11). + 4. _Создайте и обновите диаграмму Ганта:_ → Перейдите в раздел [Диаграммы Ганта]({{opSetting:base_url}}/projects/demo-planning-constructing-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22assignee%22%2C%22responsible%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22weeks%22%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) в боковом меню проекта. + 5. _Активируйте дополнительные модули:_ → Перейдите в [Настройки проекта → Модули]({{opSetting:base_url}}/projects/demo-planning-constructing-project/settings/modules). + 6. _Используете agile? Создайте новую доску:_ → Перейдите в [Доски]({{opSetting:base_url}}/projects/demo-planning-constructing-project/boards) - Here you will find our [User Guides](https://www.openproject.org/docs/user-guide/). - Please let us know if you have any questions or need support. Contact us: [support\[at\]openproject.com](mailto:support@openproject.com). + Здесь вы найдёте наши [Руководства пользователя](https://www.openproject.org/docs/user-guide/). + Пожалуйста, дайте нам знать, если у Вас возникнут вопросы или понадобится воддержка. Свяжитесь с нами: [support\[at\]openproject.com](mailto:support@openproject.com). item_4: options: name: Участники @@ -291,87 +291,87 @@ ru: item_0: subject: Начать конструирование description: |- - ## Goal + ## Цель - * Ground breaking ceremony - * Setting up the construction site - * ... + * Церемония закладки фундамента + * Обустройство строительной площадки + * ... - ## Description + ## Описание - * Preparing the site for the project - * Get the team together - * ... + * Подготовка площадки к проекту + * Собрать команду + * ... item_1: subject: Фундамент description: |- - ## Goal + ## Цель - * Laying of the foundation stone - * ... + * Укладка фундамента + * ... - ## Description + ## Описание - * Setting up the concrete mixer - * Setting up the supply chain for the concrete - * ... + * Установка бетономешалки + * Установка системы подачи бетона + * ... item_2: subject: Строительство здания description: |- - ## Goal + ## Цель - * Topping out ceremony - * Walls and ceilings are done - * ... + * Завершение церемонии + * Стены и потолки готовы + * ... - ## Description + ## Описание - * Creating all structural levels of the building - * Installing doors and windows - * Finishing the roof structure - * ... + * Создание всех структурных уровней здания + * Установка дверей и окон + * Завершение возведения крыши + * ... item_3: subject: Завершение фасада description: |- - ## Goal + ## Цель - * Facade is done - * Whole building is waterproof - * ... + * Фасад готов + * Все здание водонепроницаемо + * ... - ## Description + ## Описание - * Install all elements for the facade - * Finish the roof - * ... + * Установите все элементы для фасада + * Закончите возведение крыши + * ... item_4: subject: Установка систем обслуживания конструкций description: |- - ## Goal + ## Цель - * All building service systems are ready to be used + * Все системы здания готовы к использованию - ## Description + ## Описание - * Installing the heating system - * Installing the climate system - * Electrical installation - * ... + * Установка системы отопления + * Установка климатической системы + * Электромонтаж + * ... item_5: subject: Последние штрихи description: |- - ## Goal + ## Цель - * Handover of the keys - * The customer is happy with his building - * ... + * Передача ключей + * Заказчик доволен своим зданием + * ... - ## Description + ## Описание - * Finishing the installation of the building service systems - * Finishing the interior construction - * Finishing the facade - * ... + * Завершение монтажа систем здания + * Завершение внутренней отделки + * Завершение отделки фасада + * ... item_6: subject: Вечеринка по случаю новоселья description: |- @@ -508,14 +508,14 @@ ru: item_1: subject: Первоначальная внутренняя проверка и пересмотр модели description: |- - # Goal + # Цель - * Submitting a BIM model according to the defined standards + * Передача BIM-модели в соответствии с определенными стандартами - # Description + # Описание - * The model shall be checked, according to the defined standards (conventions, LOD, ...) and revised - * ... + * Модель должна быть проверена на соответствие стандартам (конвенциям, LOD, ...) и пересмотрена. + * ... item_2: subject: Отправка исходной BIM-модели description: Этот тип иерархически является родительским для типов "Столкновение" и "Запрос", таким образом, представляет собой общее примечание. @@ -526,27 +526,27 @@ ru: item_0: subject: Ссылки на внешние BIM-модели description: |- - # Goal + # Цель - * Having a foundation for developing the internal model/ offering answers - * Using the external model to develop the internal model + * Создание основы для разработки внутренней модели/предложение ответов + * Использование внешней модели для разработки внутренней модели - # Description + # Описание - * The external model will be referenced in the BIM platform, thus used for modelling the internal model - * ... + * На внешнюю модель будут ссылаться в BIM-платформе, что позволит использовать ее для моделирования внутренней модели + * ... item_1: subject: Моделирование BIM-модели description: |- - # Goal + # Цель - * Creating a BIM model for the project - * Creating a BIM model for the whole project team + * Создание BIM-модели для проекта + * Создание BIM-модели для всей команды проекта - # Description + # Описание - * The model will be created according to the BIM execution plan - * ... + * Модель будет создана в соответствии с планом выполнения BIM + * ... item_2: subject: Первый цикл, проверка и пересмотр внутренней модели description: |- @@ -604,30 +604,30 @@ ru: item_0: subject: Модель передачи команде разработчика description: |- - ## Goal + ## Цель - * Everyone knows the model and their tasks - * Everybody gets all the relevant information, model based - * ... + * Каждый знает модель и свои задачи + * Каждый получает всю необходимую информацию, основанную на модели + * ... - ## Description + ## Описание - * The Kickoff on the construction site includes an introduction to the model - * All the objects should have the information needed for the assigned tasks. If not, data enrichment of the model needs to be done - * ... + * Начало работы на строительной площадке включает в себя знакомство с моделью + * Все объекты должны обладать информацией, необходимой для выполнения поставленных задач. Если это не так, необходимо доработать модель с новыми данными + * ... item_1: subject: Создать сборку description: |- - ## Goal + ## Цель - * New issues realized on construction site will be handled model based - * Issues will be documented by using the BCF files and the BIM model + * Новые проблемы, возникающие на строительной площадке, будут обрабатываться на основе модели + * Проблемы будут документироваться с помощью файлов BCF и BIM-модели - ## Description + ## Описание - * New issues will be documented using BCF files as sticky notes for the model - * The BCF files will be used to assign, track and correct issues - * ... + * Новые проблемы будут документироваться с помощью файлов BCF в качестве заметок в модели + * Файлы BCF будут использоваться для назначения, отслеживания и исправления проблем + * ... item_2: subject: Завершить конструирование item_12: @@ -635,17 +635,17 @@ ru: item_13: subject: Передача полномочий по управлению объектами description: |- - ## Goal + ## Цель - * The BIM model will be used for the Facility Management - * The model provides all the relevant information for commissioning and operating the building - * ... + * BIM-модель будет использоваться для управления объектом + * Модель содержит всю необходимую информацию для ввода в эксплуатацию и эксплуатации здания + * ... - ## Description + ## Описание - * The model contains the relevant information for the facility manager - * The model can be used for the operating system of the building - * ... + * Модель содержит всю необходимую информацию для менеджера объекта + * Модель может быть использована для операционной системы здания + * ... item_14: subject: Управление активами description: Наслаждайтесь своим творением! :) @@ -657,7 +657,7 @@ ru: item_0: name: Больница - Архитектура (CC-BY-SA-3.0 Autodesk Inc.) item_1: - name: Hospital - Structural (cc-by-sa-3.0 Autodesk Inc.) + name: Больница - Механика (CC-BY-SA-3.0 Autodesk Inc.) item_2: name: Больница - Механика (CC-BY-SA-3.0 Autodesk Inc.) categories: diff --git a/modules/gitlab_integration/config/locales/crowdin/ru.yml b/modules/gitlab_integration/config/locales/crowdin/ru.yml index e917772d620c..a2a852a362e7 100644 --- a/modules/gitlab_integration/config/locales/crowdin/ru.yml +++ b/modules/gitlab_integration/config/locales/crowdin/ru.yml @@ -62,8 +62,8 @@ ru: issue_reopened_referenced_comment: > **Проблема открыта заново:** Проблема %{issue_number} [%{issue_title}](%{issue_url}) в репозитории [%{repository}](%{repository_url}) открыта заново пользователем [%{gitlab_user}](%{gitlab_user_url}). push_single_commit_comment: > - **Pushed in MR:** [%{gitlab_user}](%{gitlab_user_url}) pushed [%{commit_number}](%{commit_url}) to [%{repository}](%{repository_url}) at %{commit_timestamp}: %{commit_note} + **Отправлено в MR:** [%{gitlab_user}](%{gitlab_user_url}) отправил [%{commit_number}](%{commit_url}) в [%{repository}](%{repository_url}) в %{commit_timestamp}: %{commit_note} push_single_commit_comment_with_ref: > - **Pushed in %{reference}:** [%{gitlab_user}](%{gitlab_user_url}) pushed [%{commit_number}](%{commit_url}) to [%{repository}](%{repository_url}) at %{commit_timestamp}: %{commit_note} + **Отправлено в %{reference}:** [%{gitlab_user}](%{gitlab_user_url}) отправил [%{commit_number}](%{commit_url}) в [%{repository}](%{repository_url}) в %{commit_timestamp}: %{commit_note} push_multiple_commits_comment: > - **Pushed in MR:** [%{gitlab_user}](%{gitlab_user_url}) pushed multiple commits [%{commit_number}](%{commit_url}) to [%{repository}](%{repository_url}) at %{commit_timestamp}: %{commit_note} + **Отправлено в MR:** [%{gitlab_user}](%{gitlab_user_url}) отправил несколько коммитов [%{commit_number}](%{commit_url}) в [%{repository}](%{repository_url}) в %{commit_timestamp}: %{commit_note} diff --git a/modules/reporting/config/locales/crowdin/ru.yml b/modules/reporting/config/locales/crowdin/ru.yml index e8a71a4092cf..cf25ab9cc973 100644 --- a/modules/reporting/config/locales/crowdin/ru.yml +++ b/modules/reporting/config/locales/crowdin/ru.yml @@ -47,7 +47,7 @@ ru: label_is_project_with_subprojects: "есть (включая подпроекты)" label_work_package_attributes: "Атрибуты рабочего пакета" label_less: "<" - label_logged_by_reporting: "Вы вошли" + label_logged_by_reporting: "Зарегистрировано" label_money: "Денежная стоимость" label_month_reporting: "Месяц (Потрачено)" label_new_report: "Новый отчет о затратах" From a67c4583627ff396e7813f688cf2fa934cd4ee1c Mon Sep 17 00:00:00 2001 From: OpenProject Actions CI Date: Sun, 20 Oct 2024 03:16:11 +0000 Subject: [PATCH 048/115] update locales from crowdin [ci skip] --- config/locales/crowdin/ru.yml | 6 +- .../bim/config/locales/crowdin/ru.seeders.yml | 200 +++++++++--------- .../config/locales/crowdin/ru.yml | 6 +- .../reporting/config/locales/crowdin/ru.yml | 2 +- 4 files changed, 107 insertions(+), 107 deletions(-) diff --git a/config/locales/crowdin/ru.yml b/config/locales/crowdin/ru.yml index a7a531ff5099..1a8964cc67af 100644 --- a/config/locales/crowdin/ru.yml +++ b/config/locales/crowdin/ru.yml @@ -219,9 +219,9 @@ ru: reorder_alphabetical: "Переупорядочить значения по алфавиту" reorder_confirmation: "Внимание: Текущий порядок доступных значений будет утерян. Продолжить?" instructions: - is_required: "" + is_required: "Пометить пользовательское поле как обязательное. Это сделает заполнение поля обязательным при создании новых или обновлении существующих ресурсов." is_required_for_project: "Установите флажок, чтобы включить этот атрибут и сделать его обязательным для всех проектов. Он не может быть отключен для отдельных проектов." - is_for_all: "" + is_for_all: "Отметьте пользовательское поле как доступное во всех существующих и новых проектах." searchable: "Включать значения полей при использовании функции глобального поиска." searchable_for_project: "Установите флажок, чтобы сделать этот атрибут доступным в качестве фильтра в списках проектов." editable: "Разрешить редактирование поля самими пользователями." @@ -1604,7 +1604,7 @@ ru: x_years: one: "1 год" few: "%{count} года" - many: "%{count} years" + many: "%{count} лет" other: "%{count} лет" x_seconds: one: "1 секунда" diff --git a/modules/bim/config/locales/crowdin/ru.seeders.yml b/modules/bim/config/locales/crowdin/ru.seeders.yml index e8fd2874e766..d709222a3d9a 100644 --- a/modules/bim/config/locales/crowdin/ru.seeders.yml +++ b/modules/bim/config/locales/crowdin/ru.seeders.yml @@ -161,21 +161,21 @@ ru: options: name: Приступая к работе text: | - We are glad you joined! We suggest to try a few things to get started in OpenProject. + Мы рады, что Вы присоединились! Рекомендуем попробовать некоторые функции OpenProject для начала. - Here you will find the classical roles, some workflows and work packages for your construction project. + В данный момент здесь Вы найдете классические роли, немного рабочих потоков и пакетов для Вашего проекта строительства. - _Try the following steps:_ + _Попробуйте следующее:_ - 1. _Invite new members to your project:_ → Go to [Members]({{opSetting:base_url}}/projects/demo-planning-constructing-project/members) in the project navigation. - 2. _View the work in your projects:_ → Go to [Work packages]({{opSetting:base_url}}/projects/demo-planning-constructing-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) in the project navigation. - 3. _Create a new work package:_ → Go to [Work packages → Create]({{opSetting:base_url}}/projects/demo-planning-constructing-project/work_packages/new?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D&type=11). - 4. _Create and update a Gantt chart:_ → Go to [Gantt chart]({{opSetting:base_url}}/projects/demo-planning-constructing-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22assignee%22%2C%22responsible%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22weeks%22%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) in the project navigation. - 5. _Activate further modules:_ → Go to [Project settings → Modules]({{opSetting:base_url}}/projects/demo-planning-constructing-project/settings/modules). - 6. _Working agile? Create a new board:_ → Go to [Boards]({{opSetting:base_url}}/projects/demo-planning-constructing-project/boards) + 1. _Пригласите новых участников в проект:_ → Перейдите в раздел [Участники]({{opSetting:base_url}}/projects/demo-planning-constructing-project/members) в боковом меню проекта. + 2. _Посмотрите работы в ваших проектах:_ → Перейдите в раздел [Комплекс работ]({{opSetting:base_url}}/projects/demo-planning-constructing-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) в боковом меню проекта. + 3. _Создайте новый Комплекс работ:_ → Перейдите в раздел [Комлекс работ → Создать]({{opSetting:base_url}}/projects/demo-planning-constructing-project/work_packages/new?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D&type=11). + 4. _Создайте и обновите диаграмму Ганта:_ → Перейдите в раздел [Диаграммы Ганта]({{opSetting:base_url}}/projects/demo-planning-constructing-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22assignee%22%2C%22responsible%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22weeks%22%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) в боковом меню проекта. + 5. _Активируйте дополнительные модули:_ → Перейдите в [Настройки проекта → Модули]({{opSetting:base_url}}/projects/demo-planning-constructing-project/settings/modules). + 6. _Используете agile? Создайте новую доску:_ → Перейдите в [Доски]({{opSetting:base_url}}/projects/demo-planning-constructing-project/boards) - Here you will find our [User Guides](https://www.openproject.org/docs/user-guide/). - Please let us know if you have any questions or need support. Contact us: [support\[at\]openproject.com](mailto:support@openproject.com). + Здесь вы найдёте наши [Руководства пользователя](https://www.openproject.org/docs/user-guide/). + Пожалуйста, дайте нам знать, если у Вас возникнут вопросы или понадобится воддержка. Свяжитесь с нами: [support\[at\]openproject.com](mailto:support@openproject.com). item_4: options: name: Участники @@ -291,87 +291,87 @@ ru: item_0: subject: Начать конструирование description: |- - ## Goal + ## Цель - * Ground breaking ceremony - * Setting up the construction site - * ... + * Церемония закладки фундамента + * Обустройство строительной площадки + * ... - ## Description + ## Описание - * Preparing the site for the project - * Get the team together - * ... + * Подготовка площадки к проекту + * Собрать команду + * ... item_1: subject: Фундамент description: |- - ## Goal + ## Цель - * Laying of the foundation stone - * ... + * Укладка фундамента + * ... - ## Description + ## Описание - * Setting up the concrete mixer - * Setting up the supply chain for the concrete - * ... + * Установка бетономешалки + * Установка системы подачи бетона + * ... item_2: subject: Строительство здания description: |- - ## Goal + ## Цель - * Topping out ceremony - * Walls and ceilings are done - * ... + * Завершение церемонии + * Стены и потолки готовы + * ... - ## Description + ## Описание - * Creating all structural levels of the building - * Installing doors and windows - * Finishing the roof structure - * ... + * Создание всех структурных уровней здания + * Установка дверей и окон + * Завершение возведения крыши + * ... item_3: subject: Завершение фасада description: |- - ## Goal + ## Цель - * Facade is done - * Whole building is waterproof - * ... + * Фасад готов + * Все здание водонепроницаемо + * ... - ## Description + ## Описание - * Install all elements for the facade - * Finish the roof - * ... + * Установите все элементы для фасада + * Закончите возведение крыши + * ... item_4: subject: Установка систем обслуживания конструкций description: |- - ## Goal + ## Цель - * All building service systems are ready to be used + * Все системы здания готовы к использованию - ## Description + ## Описание - * Installing the heating system - * Installing the climate system - * Electrical installation - * ... + * Установка системы отопления + * Установка климатической системы + * Электромонтаж + * ... item_5: subject: Последние штрихи description: |- - ## Goal + ## Цель - * Handover of the keys - * The customer is happy with his building - * ... + * Передача ключей + * Заказчик доволен своим зданием + * ... - ## Description + ## Описание - * Finishing the installation of the building service systems - * Finishing the interior construction - * Finishing the facade - * ... + * Завершение монтажа систем здания + * Завершение внутренней отделки + * Завершение отделки фасада + * ... item_6: subject: Вечеринка по случаю новоселья description: |- @@ -508,14 +508,14 @@ ru: item_1: subject: Первоначальная внутренняя проверка и пересмотр модели description: |- - # Goal + # Цель - * Submitting a BIM model according to the defined standards + * Передача BIM-модели в соответствии с определенными стандартами - # Description + # Описание - * The model shall be checked, according to the defined standards (conventions, LOD, ...) and revised - * ... + * Модель должна быть проверена на соответствие стандартам (конвенциям, LOD, ...) и пересмотрена. + * ... item_2: subject: Отправка исходной BIM-модели description: Этот тип иерархически является родительским для типов "Столкновение" и "Запрос", таким образом, представляет собой общее примечание. @@ -526,27 +526,27 @@ ru: item_0: subject: Ссылки на внешние BIM-модели description: |- - # Goal + # Цель - * Having a foundation for developing the internal model/ offering answers - * Using the external model to develop the internal model + * Создание основы для разработки внутренней модели/предложение ответов + * Использование внешней модели для разработки внутренней модели - # Description + # Описание - * The external model will be referenced in the BIM platform, thus used for modelling the internal model - * ... + * На внешнюю модель будут ссылаться в BIM-платформе, что позволит использовать ее для моделирования внутренней модели + * ... item_1: subject: Моделирование BIM-модели description: |- - # Goal + # Цель - * Creating a BIM model for the project - * Creating a BIM model for the whole project team + * Создание BIM-модели для проекта + * Создание BIM-модели для всей команды проекта - # Description + # Описание - * The model will be created according to the BIM execution plan - * ... + * Модель будет создана в соответствии с планом выполнения BIM + * ... item_2: subject: Первый цикл, проверка и пересмотр внутренней модели description: |- @@ -604,30 +604,30 @@ ru: item_0: subject: Модель передачи команде разработчика description: |- - ## Goal + ## Цель - * Everyone knows the model and their tasks - * Everybody gets all the relevant information, model based - * ... + * Каждый знает модель и свои задачи + * Каждый получает всю необходимую информацию, основанную на модели + * ... - ## Description + ## Описание - * The Kickoff on the construction site includes an introduction to the model - * All the objects should have the information needed for the assigned tasks. If not, data enrichment of the model needs to be done - * ... + * Начало работы на строительной площадке включает в себя знакомство с моделью + * Все объекты должны обладать информацией, необходимой для выполнения поставленных задач. Если это не так, необходимо доработать модель с новыми данными + * ... item_1: subject: Создать сборку description: |- - ## Goal + ## Цель - * New issues realized on construction site will be handled model based - * Issues will be documented by using the BCF files and the BIM model + * Новые проблемы, возникающие на строительной площадке, будут обрабатываться на основе модели + * Проблемы будут документироваться с помощью файлов BCF и BIM-модели - ## Description + ## Описание - * New issues will be documented using BCF files as sticky notes for the model - * The BCF files will be used to assign, track and correct issues - * ... + * Новые проблемы будут документироваться с помощью файлов BCF в качестве заметок в модели + * Файлы BCF будут использоваться для назначения, отслеживания и исправления проблем + * ... item_2: subject: Завершить конструирование item_12: @@ -635,17 +635,17 @@ ru: item_13: subject: Передача полномочий по управлению объектами description: |- - ## Goal + ## Цель - * The BIM model will be used for the Facility Management - * The model provides all the relevant information for commissioning and operating the building - * ... + * BIM-модель будет использоваться для управления объектом + * Модель содержит всю необходимую информацию для ввода в эксплуатацию и эксплуатации здания + * ... - ## Description + ## Описание - * The model contains the relevant information for the facility manager - * The model can be used for the operating system of the building - * ... + * Модель содержит всю необходимую информацию для менеджера объекта + * Модель может быть использована для операционной системы здания + * ... item_14: subject: Управление активами description: Наслаждайтесь своим творением! :) @@ -657,7 +657,7 @@ ru: item_0: name: Больница - Архитектура (CC-BY-SA-3.0 Autodesk Inc.) item_1: - name: Hospital - Structural (cc-by-sa-3.0 Autodesk Inc.) + name: Больница - Механика (CC-BY-SA-3.0 Autodesk Inc.) item_2: name: Больница - Механика (CC-BY-SA-3.0 Autodesk Inc.) categories: diff --git a/modules/gitlab_integration/config/locales/crowdin/ru.yml b/modules/gitlab_integration/config/locales/crowdin/ru.yml index e917772d620c..a2a852a362e7 100644 --- a/modules/gitlab_integration/config/locales/crowdin/ru.yml +++ b/modules/gitlab_integration/config/locales/crowdin/ru.yml @@ -62,8 +62,8 @@ ru: issue_reopened_referenced_comment: > **Проблема открыта заново:** Проблема %{issue_number} [%{issue_title}](%{issue_url}) в репозитории [%{repository}](%{repository_url}) открыта заново пользователем [%{gitlab_user}](%{gitlab_user_url}). push_single_commit_comment: > - **Pushed in MR:** [%{gitlab_user}](%{gitlab_user_url}) pushed [%{commit_number}](%{commit_url}) to [%{repository}](%{repository_url}) at %{commit_timestamp}: %{commit_note} + **Отправлено в MR:** [%{gitlab_user}](%{gitlab_user_url}) отправил [%{commit_number}](%{commit_url}) в [%{repository}](%{repository_url}) в %{commit_timestamp}: %{commit_note} push_single_commit_comment_with_ref: > - **Pushed in %{reference}:** [%{gitlab_user}](%{gitlab_user_url}) pushed [%{commit_number}](%{commit_url}) to [%{repository}](%{repository_url}) at %{commit_timestamp}: %{commit_note} + **Отправлено в %{reference}:** [%{gitlab_user}](%{gitlab_user_url}) отправил [%{commit_number}](%{commit_url}) в [%{repository}](%{repository_url}) в %{commit_timestamp}: %{commit_note} push_multiple_commits_comment: > - **Pushed in MR:** [%{gitlab_user}](%{gitlab_user_url}) pushed multiple commits [%{commit_number}](%{commit_url}) to [%{repository}](%{repository_url}) at %{commit_timestamp}: %{commit_note} + **Отправлено в MR:** [%{gitlab_user}](%{gitlab_user_url}) отправил несколько коммитов [%{commit_number}](%{commit_url}) в [%{repository}](%{repository_url}) в %{commit_timestamp}: %{commit_note} diff --git a/modules/reporting/config/locales/crowdin/ru.yml b/modules/reporting/config/locales/crowdin/ru.yml index e8a71a4092cf..cf25ab9cc973 100644 --- a/modules/reporting/config/locales/crowdin/ru.yml +++ b/modules/reporting/config/locales/crowdin/ru.yml @@ -47,7 +47,7 @@ ru: label_is_project_with_subprojects: "есть (включая подпроекты)" label_work_package_attributes: "Атрибуты рабочего пакета" label_less: "<" - label_logged_by_reporting: "Вы вошли" + label_logged_by_reporting: "Зарегистрировано" label_money: "Денежная стоимость" label_month_reporting: "Месяц (Потрачено)" label_new_report: "Новый отчет о затратах" From a78cb2d1afbbf9286c1474f6449edcf70ddd0dec Mon Sep 17 00:00:00 2001 From: OpenProject Actions CI Date: Mon, 21 Oct 2024 03:12:40 +0000 Subject: [PATCH 049/115] update locales from crowdin [ci skip] --- config/locales/crowdin/zh-TW.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/config/locales/crowdin/zh-TW.yml b/config/locales/crowdin/zh-TW.yml index 9f04eb85e8fc..e90559e203b2 100644 --- a/config/locales/crowdin/zh-TW.yml +++ b/config/locales/crowdin/zh-TW.yml @@ -1955,7 +1955,7 @@ zh-TW: label_ical_access_key_generation_hint: "訂閱日曆時自動生成的。" label_ical_access_key_latest: "最新" label_ical_access_key_revoke: "撤銷" - label_add_column: "Add column" + label_add_column: "新增欄位" label_applied_status: "套用的狀態" label_archive_project: "封存專案" label_ascending: "昇冪" @@ -2126,7 +2126,7 @@ zh-TW: label_file_plural: "檔案" label_filter: "篩選條件" label_filter_add: "新增條件" - label_filter_by: "Filter by" + label_filter_by: "篩選條件:" label_filter_plural: "篩選器" label_filters_toggle: "顯示/隱藏篩選條件" label_float: "浮點數" @@ -2249,8 +2249,8 @@ zh-TW: label_months_from: "從幾個月" label_more: "更多" label_more_than_ago: "超過幾天" - label_move_column_left: "Move column left" - label_move_column_right: "Move column right" + label_move_column_left: "欄位左移" + label_move_column_right: "欄位右移" label_move_work_package: "移動工作項目" label_my_account: "帳號設定" label_my_activity: "我的活動紀錄" @@ -2373,7 +2373,7 @@ zh-TW: label_relation_new: "新增關聯" label_release_notes: "發行說明" label_remaining_work: "剩餘工作" - label_remove_column: "Remove column" + label_remove_column: "移除欄位" label_remove_columns: "移除所選欄" label_renamed: "重新命名" label_reply_plural: "回覆" @@ -2414,10 +2414,10 @@ zh-TW: label_show_completed_versions: "顯示已完成的版本" label_columns: "欄位" label_sort: "排序" - label_sort_ascending: "Sort ascending" + label_sort_ascending: "昇冪排序" label_sort_by: "按 %{value} 排序" label_sorted_by: "以 %{value} 排序" - label_sort_descending: "Sort descending" + label_sort_descending: "降冪排序" label_sort_higher: "往上移動" label_sort_highest: "移到頂端" label_sort_lower: "往下移動" From c1bf734db80ac908bec83a7f3fde7a9ef5347737 Mon Sep 17 00:00:00 2001 From: Eric-Guo Date: Mon, 21 Oct 2024 08:05:26 +0800 Subject: [PATCH 050/115] @primer/css after https://github.com/primer/css/pull/2724 need @primer/primitives and @primer/view-components (custom by op) --- frontend/package-lock.json | 30 +++++++++++++++--------------- frontend/package.json | 5 +++-- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 23db38273e3d..d432b97f9596 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -49,7 +49,9 @@ "@openproject/octicons-angular": "^19.18.0", "@openproject/primer-view-components": "^0.48.0", "@openproject/reactivestates": "^3.0.1", - "@primer/css": "^21.3.3", + "@primer/css": "^21.5.0", + "@primer/primitives": "^9.1.2", + "@primer/view-components": "npm:@openproject/primer-view-components@^0.48.0", "@types/hotwired__turbo": "^8.0.1", "@uirouter/angular": "^13.0.0", "@uirouter/core": "^6.1.0", @@ -4824,6 +4826,7 @@ "version": "0.48.0", "resolved": "https://registry.npmjs.org/@openproject/primer-view-components/-/primer-view-components-0.48.0.tgz", "integrity": "sha512-eR9vv6UTInqr3Oi3nlOLHlFa6hDhy4kdEbTXZEZS8pEa9E4IlcZNGjA+0foRPecBCizg2v+E+TgpS/wk3Vd0FA==", + "license": "MIT", "dependencies": { "@github/auto-check-element": "^5.2.0", "@github/auto-complete-element": "^3.6.2", @@ -4885,15 +4888,15 @@ "integrity": "sha512-HWwz+6MrfK5NTWcg9GdKFpMBW/yrAV937oXiw2eDtsd88P3SRwoCt6ZO6QmKp9RP3nDU9cbqmuGZ0xBh0eIFeg==" }, "node_modules/@primer/css": { - "version": "21.4.0", - "resolved": "https://registry.npmjs.org/@primer/css/-/css-21.4.0.tgz", - "integrity": "sha512-mBq0F6lvAuPioW30RP1CyvSkW76UpAzZp1HM+5N/cfpwuarYVsCWYkWQlDtJqIsGYNSa1E2GgL17HzzDt4Bofg==", - "dependencies": { - "@primer/primitives": "^9.0.3", - "@primer/view-components": "^0.34.0" - }, + "version": "21.5.0", + "resolved": "https://registry.npmjs.org/@primer/css/-/css-21.5.0.tgz", + "integrity": "sha512-zkvHxWpVcjURujbWaq4YL8R/9g9qkM6275KMXXgNs/T2o8GekUDH6Qx2cpIPVd/AHtwIwDsy4+yjWcNWsuRgkA==", + "license": "MIT", "engines": { "node": ">=16.0.0" + }, + "peerDependencies": { + "@primer/primitives": "^9.0.3" } }, "node_modules/@primer/primitives": { @@ -4910,6 +4913,7 @@ "version": "0.48.0", "resolved": "https://registry.npmjs.org/@openproject/primer-view-components/-/primer-view-components-0.48.0.tgz", "integrity": "sha512-eR9vv6UTInqr3Oi3nlOLHlFa6hDhy4kdEbTXZEZS8pEa9E4IlcZNGjA+0foRPecBCizg2v+E+TgpS/wk3Vd0FA==", + "license": "MIT", "dependencies": { "@github/auto-check-element": "^5.2.0", "@github/auto-complete-element": "^3.6.2", @@ -25398,13 +25402,9 @@ "integrity": "sha512-HWwz+6MrfK5NTWcg9GdKFpMBW/yrAV937oXiw2eDtsd88P3SRwoCt6ZO6QmKp9RP3nDU9cbqmuGZ0xBh0eIFeg==" }, "@primer/css": { - "version": "21.4.0", - "resolved": "https://registry.npmjs.org/@primer/css/-/css-21.4.0.tgz", - "integrity": "sha512-mBq0F6lvAuPioW30RP1CyvSkW76UpAzZp1HM+5N/cfpwuarYVsCWYkWQlDtJqIsGYNSa1E2GgL17HzzDt4Bofg==", - "requires": { - "@primer/primitives": "^9.0.3", - "@primer/view-components": "npm:@openproject/primer-view-components@^0.48.0" - } + "version": "21.5.0", + "resolved": "https://registry.npmjs.org/@primer/css/-/css-21.5.0.tgz", + "integrity": "sha512-zkvHxWpVcjURujbWaq4YL8R/9g9qkM6275KMXXgNs/T2o8GekUDH6Qx2cpIPVd/AHtwIwDsy4+yjWcNWsuRgkA==" }, "@primer/primitives": { "version": "9.1.2", diff --git a/frontend/package.json b/frontend/package.json index 9c6b0dc37e98..28186153066b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -101,7 +101,9 @@ "@openproject/octicons-angular": "^19.18.0", "@openproject/primer-view-components": "^0.48.0", "@openproject/reactivestates": "^3.0.1", - "@primer/css": "^21.3.3", + "@primer/css": "^21.5.0", + "@primer/primitives": "^9.1.2", + "@primer/view-components": "npm:@openproject/primer-view-components@^0.48.0", "@types/hotwired__turbo": "^8.0.1", "@uirouter/angular": "^13.0.0", "@uirouter/core": "^6.1.0", @@ -176,6 +178,5 @@ "generate-typings": "tsc -d -p src/tsconfig.app.json" }, "overrides": { - "@primer/view-components": "npm:@openproject/primer-view-components@^0.48.0" } } From 7be43c97fb91f3351dc39c5c002753a8a0387f8c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Oct 2024 05:17:56 +0000 Subject: [PATCH 051/115] Bump lefthook from 1.7.21 to 1.7.22 Bumps [lefthook](https://github.com/evilmartians/lefthook) from 1.7.21 to 1.7.22. - [Release notes](https://github.com/evilmartians/lefthook/releases) - [Changelog](https://github.com/evilmartians/lefthook/blob/master/CHANGELOG.md) - [Commits](https://github.com/evilmartians/lefthook/compare/v1.7.21...v1.7.22) --- updated-dependencies: - dependency-name: lefthook dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index eef095e51d02..6b6d5035059a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -703,7 +703,7 @@ GEM launchy (3.0.1) addressable (~> 2.8) childprocess (~> 5.0) - lefthook (1.7.21) + lefthook (1.7.22) letter_opener (1.10.0) launchy (>= 2.2, < 4) letter_opener_web (3.0.0) From 7f89e8fd835888cf000a8744eb1e50c642cd56a9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Oct 2024 05:33:08 +0000 Subject: [PATCH 052/115] Bump @ngx-formly/core from 6.3.9 to 6.3.10 in /frontend Bumps [@ngx-formly/core](https://github.com/ngx-formly/ngx-formly) from 6.3.9 to 6.3.10. - [Release notes](https://github.com/ngx-formly/ngx-formly/releases) - [Changelog](https://github.com/ngx-formly/ngx-formly/blob/main/CHANGELOG.md) - [Commits](https://github.com/ngx-formly/ngx-formly/compare/v6.3.9...v6.3.10) --- updated-dependencies: - dependency-name: "@ngx-formly/core" dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- frontend/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 23db38273e3d..14fdf0291f3a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -4084,9 +4084,9 @@ } }, "node_modules/@ngx-formly/core": { - "version": "6.3.9", - "resolved": "https://registry.npmjs.org/@ngx-formly/core/-/core-6.3.9.tgz", - "integrity": "sha512-Zur9LhjfngCaH/rjXmsyKXwdunBgXt56eC87QTjEZDhz0xUgrlfCPD0ojvkrGGrNnNhy0ScT+izTVRE2XPrTpg==", + "version": "6.3.10", + "resolved": "https://registry.npmjs.org/@ngx-formly/core/-/core-6.3.10.tgz", + "integrity": "sha512-YWS+0ts3aeyX7iQQop3exffmKsub5ZD18EnZPuoHSCCglLRS0tB8ra3kFTaYT0dyc8mXRQyES9TulbX9+ZdRFg==", "dependencies": { "tslib": "^2.0.0" }, @@ -24858,9 +24858,9 @@ "integrity": "sha512-yPKmdbTJzxROAl2NS8P8eHB2mU0BqV2I0ZiKmX6oTetY2Ea4i2WzlTK39pPpG7atmdF2NPWYLXdJWAup+JxSyw==" }, "@ngx-formly/core": { - "version": "6.3.9", - "resolved": "https://registry.npmjs.org/@ngx-formly/core/-/core-6.3.9.tgz", - "integrity": "sha512-Zur9LhjfngCaH/rjXmsyKXwdunBgXt56eC87QTjEZDhz0xUgrlfCPD0ojvkrGGrNnNhy0ScT+izTVRE2XPrTpg==", + "version": "6.3.10", + "resolved": "https://registry.npmjs.org/@ngx-formly/core/-/core-6.3.10.tgz", + "integrity": "sha512-YWS+0ts3aeyX7iQQop3exffmKsub5ZD18EnZPuoHSCCglLRS0tB8ra3kFTaYT0dyc8mXRQyES9TulbX9+ZdRFg==", "requires": { "tslib": "^2.0.0" } From b949d1843a0feba7ff451f7838c1a3f38abc5a87 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Oct 2024 05:34:15 +0000 Subject: [PATCH 053/115] Bump @types/lodash from 4.17.10 to 4.17.12 in /frontend Bumps [@types/lodash](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/lodash) from 4.17.10 to 4.17.12. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/lodash) --- updated-dependencies: - dependency-name: "@types/lodash" dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- frontend/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 23db38273e3d..8d880f3cc146 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -5588,9 +5588,9 @@ "dev": true }, "node_modules/@types/lodash": { - "version": "4.17.10", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.10.tgz", - "integrity": "sha512-YpS0zzoduEhuOWjAotS6A5AVCva7X4lVlYLF0FYHAY9sdraBfnatttHItlWeZdGhuEkf+OzMNg2ZYAx8t+52uQ==", + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.12.tgz", + "integrity": "sha512-sviUmCE8AYdaF/KIHLDJBQgeYzPBI0vf/17NaYehBJfYD1j6/L95Slh07NlyK2iNyBNaEkb3En2jRt+a8y3xZQ==", "dev": true }, "node_modules/@types/mime": { @@ -25940,9 +25940,9 @@ "dev": true }, "@types/lodash": { - "version": "4.17.10", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.10.tgz", - "integrity": "sha512-YpS0zzoduEhuOWjAotS6A5AVCva7X4lVlYLF0FYHAY9sdraBfnatttHItlWeZdGhuEkf+OzMNg2ZYAx8t+52uQ==", + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.12.tgz", + "integrity": "sha512-sviUmCE8AYdaF/KIHLDJBQgeYzPBI0vf/17NaYehBJfYD1j6/L95Slh07NlyK2iNyBNaEkb3En2jRt+a8y3xZQ==", "dev": true }, "@types/mime": { From 8bf994bf4c6938668e900411a8dcbd909a34c287 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Oct 2024 05:14:40 +0000 Subject: [PATCH 054/115] Bump ruby-prof from 1.7.0 to 1.7.1 Bumps [ruby-prof](https://github.com/ruby-prof/ruby-prof) from 1.7.0 to 1.7.1. - [Changelog](https://github.com/ruby-prof/ruby-prof/blob/master/CHANGES) - [Commits](https://github.com/ruby-prof/ruby-prof/compare/1.7.0...1.7.1) --- updated-dependencies: - dependency-name: ruby-prof dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index eef095e51d02..8d466126a20d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1044,7 +1044,7 @@ GEM i18n iso8601 ruby-ole (1.2.13.1) - ruby-prof (1.7.0) + ruby-prof (1.7.1) ruby-progressbar (1.13.0) ruby-rc4 (0.1.5) ruby-saml (1.17.0) From bb3cceb255f37ff131b285cca9731d381cd6727d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Oct 2024 05:15:13 +0000 Subject: [PATCH 055/115] Bump aws-sdk-s3 from 1.168.0 to 1.169.0 Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.168.0 to 1.169.0. - [Release notes](https://github.com/aws/aws-sdk-ruby/releases) - [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-s3/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-ruby/commits) --- updated-dependencies: - dependency-name: aws-sdk-s3 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 8d466126a20d..9bf3ac29bdd5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -342,17 +342,17 @@ GEM activerecord (>= 4.0.0, < 8.0) awrence (1.2.1) aws-eventstream (1.3.0) - aws-partitions (1.991.0) - aws-sdk-core (3.209.1) + aws-partitions (1.992.0) + aws-sdk-core (3.210.0) aws-eventstream (~> 1, >= 1.3.0) - aws-partitions (~> 1, >= 1.651.0) + aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.94.0) - aws-sdk-core (~> 3, >= 3.207.0) + aws-sdk-kms (1.95.0) + aws-sdk-core (~> 3, >= 3.210.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.168.0) - aws-sdk-core (~> 3, >= 3.207.0) + aws-sdk-s3 (1.169.0) + aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sdk-sns (1.88.0) From 922545d20c7d2a48b7088d81f8c1a399b6363edb Mon Sep 17 00:00:00 2001 From: Bruno Pagno Date: Mon, 21 Oct 2024 12:11:55 +0200 Subject: [PATCH 056/115] adjust label "Enabled in projects" It should say "Projects" now --- config/locales/en.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/locales/en.yml b/config/locales/en.yml index 9ac82e45a972..448952badf43 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2501,7 +2501,7 @@ en: label_project_latest: "Latest projects" label_project_default_type: "Allow empty type" label_project_hierarchy: "Project hierarchy" - label_project_mappings: "Enabled in projects" + label_project_mappings: "Projects" label_project_new: "New project" label_project_plural: "Projects" label_project_list_plural: "Project lists" From a70a3ae7cda196d7feb8b7e999524ad72f172987 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Mon, 21 Oct 2024 12:32:48 +0200 Subject: [PATCH 057/115] Check for fieldValue to be existent before relying on it --- .../principal/principal.component.ts | 2 +- .../invite_user_modal_spec.rb | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/features/invite-user-modal/principal/principal.component.ts b/frontend/src/app/features/invite-user-modal/principal/principal.component.ts index 2de2c62498d4..805b85e812c3 100644 --- a/frontend/src/app/features/invite-user-modal/principal/principal.component.ts +++ b/frontend/src/app/features/invite-user-modal/principal/principal.component.ts @@ -244,7 +244,7 @@ export class PrincipalComponent implements OnInit { const fieldSchema = fieldsSchema[fieldKey]; let fieldValue = this.customFields[fieldKey]; - if (fieldSchema.location === '_links') { + if (fieldSchema.location === '_links' && !!fieldValue) { fieldValue = Array.isArray(fieldValue) ? fieldValue.map((opt:any) => (opt._links ? opt._links.self : opt)) : (fieldValue._links ? fieldValue._links.self : fieldValue); diff --git a/spec/features/users/invite_user_modal/invite_user_modal_spec.rb b/spec/features/users/invite_user_modal/invite_user_modal_spec.rb index 635c3fd71695..33cd913c13e8 100644 --- a/spec/features/users/invite_user_modal/invite_user_modal_spec.rb +++ b/spec/features/users/invite_user_modal/invite_user_modal_spec.rb @@ -160,6 +160,27 @@ let(:mail_membership_recipients) { [principal] } end end + + context "with a required list user CF (regression #58429)" do + let(:current_user) { create(:admin) } + let(:list_cf) do + create(:user_custom_field, + :list, + name: "List", + is_required: true, + editable: false, + default_option: "A") + end + + before do + list_cf + end + + it_behaves_like "invites the principal to the project" do + let(:added_principal) { principal } + let(:mail_membership_recipients) { [principal] } + end + end end context "with a user to be invited" do From ebecad5d2b41e5ad2d85012a3c24e9f88403e1c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 21 Oct 2024 12:37:09 +0200 Subject: [PATCH 058/115] Ensure init nulldb with utf8 --- docker/prod/setup/postinstall-onprem.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/prod/setup/postinstall-onprem.sh b/docker/prod/setup/postinstall-onprem.sh index 277917033ba2..af673a8010ce 100755 --- a/docker/prod/setup/postinstall-onprem.sh +++ b/docker/prod/setup/postinstall-onprem.sh @@ -32,7 +32,7 @@ rm -rf /var/lib/postgresql/{$CURRENT_PGVERSION,$NEXT_PGVERSION} # create schema_cache.yml and db/structure.sql -su - postgres -c "$PGBIN/initdb -D /tmp/nulldb" +su - postgres -c "$PGBIN/initdb -D /tmp/nulldb -E UTF8" su - postgres -c "$PGBIN/pg_ctl -D /tmp/nulldb -l /dev/null -l /tmp/nulldb/log -w start" # give some more time for DB to start From e0c5dd3ef63b8ee10f28484ca54c35436f6768c3 Mon Sep 17 00:00:00 2001 From: Pavel Balashou Date: Fri, 11 Oct 2024 19:19:43 +0200 Subject: [PATCH 059/115] Update OIDC configuration UI. --- .../border_box_table_component.html.erb | 10 +- ...0616_migrate_oidc_settings_to_providers.rb | 71 +++++ .../app/views/hooks/login/_providers.html.erb | 8 +- .../views/hooks/login/_providers_css.html.erb | 16 +- .../controllers/saml/providers_controller.rb | 2 +- .../features/administration/saml_crud_spec.rb | 2 +- .../openid_connect/auth_provider-custom.png | Bin 0 -> 6478 bytes .../openid_connect/auth_provider-heroku.png | Bin 1251 -> 0 bytes .../openid_connect/providers/base_form.rb | 40 +++ .../providers/client_details_form.rb | 53 ++++ .../providers/metadata_details_form.rb | 45 +++ .../providers/metadata_options_form.rb | 58 ++++ .../providers/metadata_url_form.rb | 44 +++ .../providers/name_input_and_tenant_form.rb | 54 ++++ .../providers/name_input_form.rb | 45 +++ .../openid_connect/providers/row_component.rb | 57 ++-- .../sections/form_component.html.erb | 41 +++ .../providers/sections/form_component.rb | 78 +++++ .../sections/metadata_form_component.html.erb | 56 ++++ .../sections/metadata_form_component.rb | 37 +++ .../sections/show_component.html.erb | 45 +++ .../providers/sections/show_component.rb | 50 ++++ .../providers/submit_or_cancel_form.rb | 85 ++++++ .../providers/table_component.rb | 38 ++- .../providers/view_component.html.erb | 205 +++++++++++++ .../providers/view_component.rb | 42 +++ .../openid_connect/providers/base_contract.rb | 51 ++++ .../providers/create_contract.rb | 34 +++ .../providers/delete_contract.rb | 35 +++ .../providers/update_contract.rb | 34 +++ .../openid_connect/providers_controller.rb | 125 +++++--- .../app/models/openid_connect/provider.rb | 169 ++++------- .../openid_connect/provider_seeder.rb | 52 ++++ .../providers/create_service.rb | 34 +++ .../providers/delete_service.rb | 33 +++ .../providers/set_attributes_service.rb | 42 +++ .../providers/update_service.rb | 88 ++++++ .../services/openid_connect/sync_service.rb | 68 +++++ .../providers/_azure_form.html.erb | 21 -- .../openid_connect/providers/_form.html.erb | 43 --- .../openid_connect/providers/edit.html.erb | 39 ++- .../openid_connect/providers/index.html.erb | 29 +- .../openid_connect/providers/new.html.erb | 14 +- modules/openid_connect/config/locales/en.yml | 67 ++++- modules/openid_connect/config/routes.rb | 2 +- .../lib/open_project/openid_connect.rb | 26 +- .../lib/open_project/openid_connect/engine.rb | 9 +- .../controllers/providers_controller_spec.rb | 270 ------------------ .../spec/factories/oidc_provider_factory.rb | 41 +++ .../models/openid_connect/provider_spec.rb | 100 ------- .../spec/requests/openid_connect_spec.rb | 54 +--- .../openid_connect/provider_seeder_spec.rb | 135 +++++++++ spec/requests/api/v3/authentication_spec.rb | 24 +- .../openid_google_provider_callback_spec.rb | 26 +- .../users/register_user_service_spec.rb | 25 +- spec/support/shared/with_settings.rb | 2 +- 56 files changed, 2085 insertions(+), 789 deletions(-) create mode 100644 db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb create mode 100644 modules/openid_connect/app/assets/images/openid_connect/auth_provider-custom.png delete mode 100644 modules/openid_connect/app/assets/images/openid_connect/auth_provider-heroku.png create mode 100644 modules/openid_connect/app/components/openid_connect/providers/base_form.rb create mode 100644 modules/openid_connect/app/components/openid_connect/providers/client_details_form.rb create mode 100644 modules/openid_connect/app/components/openid_connect/providers/metadata_details_form.rb create mode 100644 modules/openid_connect/app/components/openid_connect/providers/metadata_options_form.rb create mode 100644 modules/openid_connect/app/components/openid_connect/providers/metadata_url_form.rb create mode 100644 modules/openid_connect/app/components/openid_connect/providers/name_input_and_tenant_form.rb create mode 100644 modules/openid_connect/app/components/openid_connect/providers/name_input_form.rb create mode 100644 modules/openid_connect/app/components/openid_connect/providers/sections/form_component.html.erb create mode 100644 modules/openid_connect/app/components/openid_connect/providers/sections/form_component.rb create mode 100644 modules/openid_connect/app/components/openid_connect/providers/sections/metadata_form_component.html.erb create mode 100644 modules/openid_connect/app/components/openid_connect/providers/sections/metadata_form_component.rb create mode 100644 modules/openid_connect/app/components/openid_connect/providers/sections/show_component.html.erb create mode 100644 modules/openid_connect/app/components/openid_connect/providers/sections/show_component.rb create mode 100644 modules/openid_connect/app/components/openid_connect/providers/submit_or_cancel_form.rb create mode 100644 modules/openid_connect/app/components/openid_connect/providers/view_component.html.erb create mode 100644 modules/openid_connect/app/components/openid_connect/providers/view_component.rb create mode 100644 modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb create mode 100644 modules/openid_connect/app/contracts/openid_connect/providers/create_contract.rb create mode 100644 modules/openid_connect/app/contracts/openid_connect/providers/delete_contract.rb create mode 100644 modules/openid_connect/app/contracts/openid_connect/providers/update_contract.rb create mode 100644 modules/openid_connect/app/seeders/env_data/openid_connect/provider_seeder.rb create mode 100644 modules/openid_connect/app/services/openid_connect/providers/create_service.rb create mode 100644 modules/openid_connect/app/services/openid_connect/providers/delete_service.rb create mode 100644 modules/openid_connect/app/services/openid_connect/providers/set_attributes_service.rb create mode 100644 modules/openid_connect/app/services/openid_connect/providers/update_service.rb create mode 100644 modules/openid_connect/app/services/openid_connect/sync_service.rb delete mode 100644 modules/openid_connect/app/views/openid_connect/providers/_azure_form.html.erb delete mode 100644 modules/openid_connect/app/views/openid_connect/providers/_form.html.erb delete mode 100644 modules/openid_connect/spec/controllers/providers_controller_spec.rb create mode 100644 modules/openid_connect/spec/factories/oidc_provider_factory.rb delete mode 100644 modules/openid_connect/spec/models/openid_connect/provider_spec.rb create mode 100644 modules/openid_connect/spec/seeders/env_data/openid_connect/provider_seeder_spec.rb diff --git a/app/components/op_primer/border_box_table_component.html.erb b/app/components/op_primer/border_box_table_component.html.erb index 293aa2d79ed5..01cbc672c596 100644 --- a/app/components/op_primer/border_box_table_component.html.erb +++ b/app/components/op_primer/border_box_table_component.html.erb @@ -46,11 +46,11 @@ See COPYRIGHT and LICENSE files for more details. if rows.empty? component.with_row(scheme: :default) { render_blank_slate } - end - - rows.each do |row| - component.with_row(scheme: :default) do - render(row_class.new(row:, table: self)) + else + rows.each do |row| + component.with_row(scheme: :default) do + render(row_class.new(row:, table: self)) + end end end end diff --git a/db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb b/db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb new file mode 100644 index 000000000000..edbaf98b5c7d --- /dev/null +++ b/db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb @@ -0,0 +1,71 @@ +# 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. +#++ + +class MigrateOidcSettingsToProviders < ActiveRecord::Migration[7.1] + def up + providers = Hash(Setting.plugin_openproject_openid_connect).with_indifferent_access[:providers] + return if providers.blank? + + providers.each do |name, configuration| + configuration.delete(:name) + migrate_provider!(name, configuration) + end + end + + def down + # This migration does not yet remove Setting.plugin_openproject_openid_connect + # so it can be retried. + end + + private + + def migrate_provider!(name, configuration) + Rails.logger.debug { "Trying to migrate OpenID provider #{name} from previous settings format..." } + call = ::OpenIDConnect::SyncService.new(name, configuration).call + + if call.success + Rails.logger.debug { <<~SUCCESS } + Successfully migrated OpenID provider #{name} from previous settings format. + You can now manage this provider in the new administrative UI within OpenProject under + the "Administration -> Authentication -> OpenID providers" section. + SUCCESS + else + raise <<~ERROR + Failed to create or update OpenID provider #{name} from previous settings format. + The error message was: #{call.message} + Please check the logs for more information and open a bug report in our community: + https://www.openproject.org/docs/development/report-a-bug/ + If you would like to skip migrating the OpenID provider setting and discard them instead, you can use our documentation + to unset any previous OpenID provider settings: + https://www.openproject.org/docs/system-admin-guide/authentication/openid-providers/ + ERROR + end + end +end diff --git a/modules/auth_plugins/app/views/hooks/login/_providers.html.erb b/modules/auth_plugins/app/views/hooks/login/_providers.html.erb index c3e34bf4f857..0b27df729c52 100644 --- a/modules/auth_plugins/app/views/hooks/login/_providers.html.erb +++ b/modules/auth_plugins/app/views/hooks/login/_providers.html.erb @@ -27,7 +27,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<% OpenProject::Plugins::AuthPlugin.providers.each do |pro| %> +<% OpenProject::Plugins::AuthPlugin.providers.each do |provider| %> <% opts = { script_name: OpenProject::Configuration.rails_relative_url_root } @@ -36,8 +36,8 @@ See COPYRIGHT and LICENSE files for more details. end %> - <%= pro[:display_name] || pro[:name] %> + href="<%= omni_auth_start_path(provider[:name], opts) %>" + class="auth-provider auth-provider-<%= provider[:name] %> <%= provider[:icon] ? 'auth-provider--imaged' : '' %> button"> + <%= provider[:display_name] || provider[:name] %> <% end %> diff --git a/modules/auth_plugins/app/views/hooks/login/_providers_css.html.erb b/modules/auth_plugins/app/views/hooks/login/_providers_css.html.erb index 88178a25f6ff..519c8199237a 100644 --- a/modules/auth_plugins/app/views/hooks/login/_providers_css.html.erb +++ b/modules/auth_plugins/app/views/hooks/login/_providers_css.html.erb @@ -27,17 +27,17 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<% OpenProject::Plugins::AuthPlugin.providers.each do |pro| %> - <% if pro[:icon] %> +<% OpenProject::Plugins::AuthPlugin.providers.each do |provider| %> + <% if provider[:icon] %> <% end -%> diff --git a/modules/auth_saml/app/controllers/saml/providers_controller.rb b/modules/auth_saml/app/controllers/saml/providers_controller.rb index ab8a43a11fe4..7574bf185024 100644 --- a/modules/auth_saml/app/controllers/saml/providers_controller.rb +++ b/modules/auth_saml/app/controllers/saml/providers_controller.rb @@ -85,7 +85,7 @@ def create def update call = Saml::Providers::UpdateService .new(model: @provider, user: User.current) - .call(options: update_params) + .call(update_params) if call.success? flash[:notice] = I18n.t(:notice_successful_update) unless @edit_mode diff --git a/modules/auth_saml/spec/features/administration/saml_crud_spec.rb b/modules/auth_saml/spec/features/administration/saml_crud_spec.rb index 2df6ed5f2512..b1685cbe8362 100644 --- a/modules/auth_saml/spec/features/administration/saml_crud_spec.rb +++ b/modules/auth_saml/spec/features/administration/saml_crud_spec.rb @@ -98,7 +98,7 @@ expect(provider.private_key.strip.gsub("\r\n", "\n")).to eq CertificateHelper.private_key.private_to_pem.strip expect(provider.idp_sso_service_url).to eq "https://example.com/sso" expect(provider.idp_slo_service_url).to eq "https://example.com/slo" - expect(provider.mapping_login).to eq "login\nmail" + expect(provider.mapping_login).to eq "login\r\nmail" expect(provider.mapping_mail).to eq "mail" expect(provider.mapping_firstname).to eq "myName" expect(provider.mapping_lastname).to eq "myLastName" diff --git a/modules/openid_connect/app/assets/images/openid_connect/auth_provider-custom.png b/modules/openid_connect/app/assets/images/openid_connect/auth_provider-custom.png new file mode 100644 index 0000000000000000000000000000000000000000..a3f8d7d23978a56c7600ab28229e26926d90b415 GIT binary patch literal 6478 zcmeHL`9G9@wEsLaV<|}}YnBws5~31evLq@*_O*nui)@K`k|OaH*|L9C$d-LyOUPcy z&e+F3)@)k`YlpLUaCCBZadms_ z?(xLa%iG8IsoyjI06hFW@I}zeSHU5#L*Imj{}b^xGU{D)Ol(~I`-H@#JF$L*v(PP2ZbaT7R^)cXW1j_x$|zyZ29D z|G?nT@W|*GVSHk8YI;&U~p^adCTBg{64ZkC?O}Z`VaUAa2B05>qx7x?C&k2@z7$?%fi(FEk{}b*bT* zwDGihqScq4(}O#CU%3QOKrg2XQNr?U&ohJg1bsVhX24NR4*z=sfSj{uM?z0`Q*&>2xT#{g~`*eDce`6FbkeC=IZ1+u!t7FQB3WdR0S*b7)S`In z=&*^M+I}YDwZDKf6Uq4q;NWCNUUH}sTezM8V2?PW}_vWXD28^G0t0>`m`}Qy9MLh)w_3=B-35@OYld~4dkEy9+pK;tS)Y?KwRmA@4$5w_EW*Mt@|kA<@D=_uhNBPy+}Ayf^eO&O?y96Zd&-c(oK5!{9X>xJaw=CYYJKL zl#>xf$nQ0P)z{k1u49c{+JDzi>JPS)aGufhprSORX2T=ZFJSVvU3IH@K=*O#Sro|6 z^NfNCtMVG;O}Ed~t#Ss$M!~enJ#PIp`sp0rE4Jx_qPRAa*jt0>TQ=)CB<)#Y!}j4# zd1M8Ay+X%6t37|)pz#M~{G!I9r87G-2w7^({9%sclDr_d%O@{vvwJ%q*>^ujPe=8B zR_=B&)Z6>lkEPCgZ)C&=1vt%diE}yAjJm_CZDLP%{nbYVQ9y$yi_+vnVU1eQKFM0M z+`r&00NxH1wV1xRW+%V)(^PM6kIqFH1u94PTjQjfjb3ekNZKXl(OjfTd$7=hY44TG zZ;bQ0u5Cx`hsv-1p66l)1HWGNd&%u<3)9O_)xV<$y(CAe_EdZK(-3^z`D77g3vak^ z7lmEtVJ%$S4u9y%0-{LTlxj*I_5R$!cV0^w{#H{q*a4Y9d>T89(e7GIwJsHL6@Nokk&4Aq?=_xqh?Xiol zGZ5CAT!s;1o!KRp8Z{302w8=@~m-3^%ou_r%H9B~3q_bW_cn$=2P3 z+IO$4_T|CZw#wC7wqf4W?+bs7v>!zo{jk}U?kKh2+3xdt{8*{EOE~!HeJ(%E%Rt^Y z7eDc>EbL$?X! zwN}Zmqg%8-@**^ae_>2e4p1nw}hJ*5vpdsvP$I7%bJz`zt@6Z((RpJIbx6 zoXa``Z6((JNTa+rb>&wd!tI_=x`%qo86yqj_PyH4F01m*>-oA6OHuZ z>Wqcv?2?MTC7T~vDZ4mmzi$DgWiP#13YGPJyO+^(FKF8O7~l{sxU)B(wxGo*R^LO) z9a(-T1*|&oNsRq(%bwUif|Mm}uNj3Qwtw==Ml3(aNvjK~DK1Dn2)v5KSU4?g$~DzA zU`zk(V3i+9F2+dx%4MakMo;$2jksh8_}SjfHDAN_9$o-ry#U+(z0c_DG~w*xqz zG=0P2j3w%zV|L4M*$n&l=7%e4L&wKIeSB4B)+E|l=y#xhooDIZi1 z{Nq0<7%+~f4S_)aGfS?5K%)O6Ir4s7+*BQ9zZPJzm5Fx;+UAP_w_m2vR9PlLaF+A8 zOQbHPn7@v1GRTXDY||7mIN%rvV$-qPsVW&Y@tLm=7(O|+SlRpRe1vF4=+g*t7nciNlq8@zlv1Bx%1<^xS z$ldZD5rV#HB+_E3yGPxFpANL3ZN_r(#bv{kfU_RF6uiGI9$-*#`tU27vG}6>8zSeCgsi#K9=@K==fR ziXd%2PXw^b@~oV6CBT;FZzRRvJ_ck&;l-w0G#H)qvwXV-(oFAGYTfYzJ>qq7FAX6u z%Jt3jp*sTWoVq5lEr!_hiKuTJV6N|Y%0>|*C|Ts__BEyfL!K$bnhppoS2cTB2tlyd z=;3 z`NTB|J;dF$niRJS(#(T>yV^}aPk!EYqRrJqCZbv@9a!EAB-S*^B84bU%1QYmvdS$v zJ^?9!5h=hB^yKN93EPT*xmdpb*GFiO#5z14<{Kk^C6DCyfrzwpPkw&|k+qdw9e0rP z31)V)7%*pGkT0!r4Z@apxhE%-A%0P$(=3P|GRvH;5=I((s*W2fMc&S{6_4b^L&n6) zuFljHkY;>#hr)=X2MPKy67US7r$m|w3(L^~4*zp7E%GW#i45aOXt25P-lqh9NmfwOrW7g-?`VQFeLWvM=JW;%B#Usgm^Qe9o@|>M zs3{r^l8;U^!dj02NZxy14bOu#J^c!Fnh+w-j6h_40A#XRs1Yz)5v1KCtg}n@qkz8% zZ;K_C3G`%1?4m>RXuw|d1{)rV6ojy;V?-vnYX$|J*N?FBkEbaCoSr`f=7jk`PsSes zOzt`cn74V{4uaK?t9-jw4W#)Z4;sAHzdiuqR>#5=L~k?>z+Eme3=rQ&0R{J^lV`1x zCBWRh$l-^WrPib1Y45#bK<+&|C}}l0a|`Qp4fLdo5iv(9Mo)l}EZs1EXw6?7NN&UY z%s{54Y<>*x(OP*n_>-Xsn$G z3f|)m+`t?S;hcj&Hu02*4sd>U97HP#_-KBF>rDW>aH>K~7l^kV1)+w2V|AbjMu1%l zOn-Sajm8+lE}JyHIdx)94rH0zv5X9nfz=XpMnIrSx`zSWcZtVAPtt`z*2wt@6wclP z=i{pm6#ZfKJKZ z$GVtvVxmWYL*H~=64UdbPoE&{k8Jg*k99fpK*V4nT?{&D(v1RJZ65a%fZVS}gr{9e zH3funq@cn5_6?8MsPlV?$jvZOn0myLc91NMf=&L^p3w(tokua?lgJ)}#){h)Cy@3l zW87g7IXpazT!A}%J(zjST=NJ&9mvv6c6_A5EbV{{UikWokZEYV{xDKvt0T+c{prbA zq@&NdEX&`3??HbhY>~~INn7?dJegYL3&234H7++9b3TADz;6i(ijUSub*j1YnG3Um zfmp(wqWX##zX`3b5UlX0?w01sLR;LZ6R=W&EF&)^Gg1nEda!|8`71QKpB+szyz>%h zz@rr2gh*`msX`kA!Yj4w5LQcHJ2L8f%Gl2Lr{L||?cnVnA%MR!)CmKMi!JIgz&7sj zGZ_$X{?ij!`7j1Rpm<}+mj`rQY!9LXR$jaH8UPggJ>UTx7Nx0B!zx7WG zwK&$2C(z`=5c{0fT)&})>Oblwrj$-kak0}a=7DjB zj-^uYXaGcf-E8su(N^KGMAoroFROCGs%Cf1>(PMX4d;5E>VsE?PG+)R`<-1stvg%Ns&b1T0wethZ z<)henX0K$?inSwN`+u_BsLt!!Y(Y#-F7?VbqyhoW1s6Q~(%+m|!yda#gvWJThWsN~tx5iKtDgErYx zZr?eV{&HKynxNTJ-G~>OgH0clD~(Q@{yP=jLm%h0zj&x_b+jwklBavcHS2Q%2Q|xg z>B=}OA9a^Td+pzT5c>-c%~Ts;V``uMPML~xqHM7-Gphwep*x%P0XG(uxkmtyQJ--p{t`#xnkC&9}aBE2msh5B}>pzEkb4&P0KXU zBr~&O7Lfh$l0%SP!0eyb)jA>&;I8M8TXvhw+_F_n4GRW;1V&u<=-D<2U`z#qU+n)X z(@m?V&J0*eX9Pghu$??>Q|NDc01}~}YY1nyo4VDGG;LY%JtBaJ53g0{6r{o^2boSMc}=uVguEqzGQf?A?3!f>DqXVnhnyU0Vt-l3Ar+r4|mEhMBXfv=2qJ zQ|XM^wcuO{JkY+gG;FVpkfo~0*RY9ie3I-AX6-0BGrKh_)` zo^o_4dQxV`ItIFRT|xYvqAv5^3$?r@b+a^j7d$bQ{|yf zPNgS%ijnleJK$6v@K74&{uv4g?}zM*Go4(7C;9+HoW=Xh+&jGphwaxa@T9!3z%%>9 z$FkT!flYK}a%k0;yeIz_KEaaCmg!px4};`EkvH&1fEJXkGVX@bKAb1`%go<*HzIfs zWxSa`2H7-#27;^vT;%{ckp3w65@D+NGJ?CRv`sref%mrh>oEQaFaSIUvU-uWF~|S_ N002ovPDHLkV1ky}OS1p~ diff --git a/modules/openid_connect/app/components/openid_connect/providers/base_form.rb b/modules/openid_connect/app/components/openid_connect/providers/base_form.rb new file mode 100644 index 000000000000..6a727f4162bd --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/base_form.rb @@ -0,0 +1,40 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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. +#++ + +module OpenIDConnect + module Providers + class BaseForm < ApplicationForm + attr_reader :provider + + def initialize(provider:) + super() + @provider = provider + end + end + end +end diff --git a/modules/openid_connect/app/components/openid_connect/providers/client_details_form.rb b/modules/openid_connect/app/components/openid_connect/providers/client_details_form.rb new file mode 100644 index 000000000000..3c7703b713d0 --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/client_details_form.rb @@ -0,0 +1,53 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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. +#++ + +module OpenIDConnect + module Providers + class ClientDetailsForm < BaseForm + form do |f| + %i[client_id client_secret].each do |attr| + f.text_field( + name: attr, + label: I18n.t("activemodel.attributes.openid_connect/provider.#{attr}"), + caption: I18n.t("openid_connect.instructions.#{attr}"), + disabled: provider.seeded_from_env?, + required: true, + input_width: :large + ) + end + f.check_box( + name: :limit_self_registration, + label: I18n.t("activemodel.attributes.openid_connect/provider.limit_self_registration"), + caption: I18n.t("openid_connect.instructions.limit_self_registration"), + disabled: provider.seeded_from_env?, + required: true + ) + end + end + end +end diff --git a/modules/openid_connect/app/components/openid_connect/providers/metadata_details_form.rb b/modules/openid_connect/app/components/openid_connect/providers/metadata_details_form.rb new file mode 100644 index 000000000000..49702255ae70 --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/metadata_details_form.rb @@ -0,0 +1,45 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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. +#++ + +module OpenIDConnect + module Providers + class MetadataDetailsForm < BaseForm + form do |f| + OpenIDConnect::Provider::DISCOVERABLE_ATTRIBUTES_ALL.each do |attr| + f.text_field( + name: attr, + label: I18n.t("activemodel.attributes.openid_connect/provider.#{attr}"), + disabled: provider.seeded_from_env?, + required: OpenIDConnect::Provider::DISCOVERABLE_ATTRIBUTES_MANDATORY.include?(attr), + input_width: :large + ) + end + end + end + end +end diff --git a/modules/openid_connect/app/components/openid_connect/providers/metadata_options_form.rb b/modules/openid_connect/app/components/openid_connect/providers/metadata_options_form.rb new file mode 100644 index 000000000000..0e82e822386d --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/metadata_options_form.rb @@ -0,0 +1,58 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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. +#++ + +module OpenIDConnect + module Providers + class MetadataOptionsForm < BaseForm + form do |f| + f.radio_button_group( + name: "metadata", + scope_name_to_model: false, + disabled: provider.seeded_from_env?, + visually_hide_label: true + ) do |radio_group| + radio_group.radio_button( + value: "none", + checked: @provider.metadata_url.blank?, + label: I18n.t("openid_connect.settings.metadata_none"), + disabled: provider.seeded_from_env?, + data: { "show-when-value-selected-target": "cause" } + ) + + radio_group.radio_button( + value: "url", + checked: @provider.metadata_url.present?, + label: I18n.t("openid_connect.settings.metadata_url"), + disabled: provider.seeded_from_env?, + data: { "show-when-value-selected-target": "cause" } + ) + end + end + end + end +end diff --git a/modules/openid_connect/app/components/openid_connect/providers/metadata_url_form.rb b/modules/openid_connect/app/components/openid_connect/providers/metadata_url_form.rb new file mode 100644 index 000000000000..b18a185536cf --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/metadata_url_form.rb @@ -0,0 +1,44 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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. +#++ + +module OpenIDConnect + module Providers + class MetadataUrlForm < BaseForm + form do |f| + f.text_field( + name: :metadata_url, + label: I18n.t("openid_connect.settings.endpoint_url"), + required: false, + disabled: provider.seeded_from_env?, + caption: I18n.t("openid_connect.instructions.endpoint_url"), + input_width: :xlarge + ) + end + end + end +end diff --git a/modules/openid_connect/app/components/openid_connect/providers/name_input_and_tenant_form.rb b/modules/openid_connect/app/components/openid_connect/providers/name_input_and_tenant_form.rb new file mode 100644 index 000000000000..16d73ccd3f15 --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/name_input_and_tenant_form.rb @@ -0,0 +1,54 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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. +#++ + +module OpenIDConnect + module Providers + class NameInputAndTenantForm < BaseForm + form do |f| + f.hidden(name: :oidc_provider, value: provider.oidc_provider) + f.text_field( + name: :display_name, + label: I18n.t("activemodel.attributes.openid_connect/provider.display_name"), + required: true, + disabled: provider.seeded_from_env?, + caption: I18n.t("openid_connect.instructions.display_name"), + input_width: :medium + ) + f.text_field( + name: :tenant, + label: I18n.t("activemodel.attributes.openid_connect/provider.tenant"), + required: true, + disabled: provider.seeded_from_env?, + value: provider.tenant || "common", + caption: I18n.t("openid_connect.instructions.tenant").html_safe, + input_width: :medium + ) + end + end + end +end diff --git a/modules/openid_connect/app/components/openid_connect/providers/name_input_form.rb b/modules/openid_connect/app/components/openid_connect/providers/name_input_form.rb new file mode 100644 index 000000000000..68728a4f46d3 --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/name_input_form.rb @@ -0,0 +1,45 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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. +#++ + +module OpenIDConnect + module Providers + class NameInputForm < BaseForm + form do |f| + f.hidden(name: :oidc_provider, value: provider.oidc_provider) + f.text_field( + name: :display_name, + label: I18n.t("activemodel.attributes.openid_connect/provider.display_name"), + required: true, + disabled: provider.seeded_from_env?, + caption: I18n.t("openid_connect.instructions.display_name"), + input_width: :medium + ) + end + end + end +end diff --git a/modules/openid_connect/app/components/openid_connect/providers/row_component.rb b/modules/openid_connect/app/components/openid_connect/providers/row_component.rb index 220b156153cd..027d9c911579 100644 --- a/modules/openid_connect/app/components/openid_connect/providers/row_component.rb +++ b/modules/openid_connect/app/components/openid_connect/providers/row_component.rb @@ -1,15 +1,36 @@ module OpenIDConnect module Providers - class RowComponent < ::RowComponent + class RowComponent < ::OpPrimer::BorderBoxRowComponent def provider model end + def column_args(column) + if column == :name + { style: "grid-column: span 3" } + else + super + end + end + def name - link_to( - provider.display_name || provider.name, - url_for(action: :edit, id: provider.id) - ) + link = render( + Primer::Beta::Link.new( + href: url_for(action: :edit, id: provider.id), + font_weight: :bold, + mr: 1 + ) + ) { provider.display_name } + if !provider.configured? + link.concat( + render(Primer::Beta::Label.new(scheme: :attention)) { I18n.t(:label_incomplete) } + ) + end + link + end + + def type + I18n.t("openid_connect.providers.#{provider.oidc_provider}.name") end def row_css_class @@ -19,28 +40,20 @@ def row_css_class ].join(" ") end - ### - def button_links - [edit_link, delete_link] + [] + end + + def users + User.where("identity_url LIKE ?", "#{provider.slug}%").count.to_s end - def edit_link - link_to( - helpers.op_icon("icon icon-edit button--link"), - url_for(action: :edit, id: provider.id), - title: t(:button_edit) - ) + def creator + helpers.avatar(provider.creator, size: :mini, hide_name: false) end - def delete_link - link_to( - helpers.op_icon("icon icon-delete button--link"), - url_for(action: :destroy, id: provider.id), - method: :delete, - data: { confirm: I18n.t(:text_are_you_sure) }, - title: t(:button_delete) - ) + def created_at + helpers.format_time provider.created_at end end end diff --git a/modules/openid_connect/app/components/openid_connect/providers/sections/form_component.html.erb b/modules/openid_connect/app/components/openid_connect/providers/sections/form_component.html.erb new file mode 100644 index 000000000000..5339d40ecdc1 --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/sections/form_component.html.erb @@ -0,0 +1,41 @@ +<%= + primer_form_with( + id: "openid-connect-providers-edit-form", + model: provider, + url:, + method: form_method, + data: { turbo: true, turbo_stream: true } + ) do |form| + flex_layout do |flex| + if @heading + flex.with_row do + render(Primer::Beta::Text.new(tag: :p, mb: 4, font_weight: :bold)) do + @heading + end + end + end + + if @banner + flex.with_row(mb: 2) do + icon = @banner_scheme == :warning ? :alert : :info + render(Primer::Alpha::Banner.new(scheme: @banner_scheme, icon:)) do + @banner + end + end + end + + flex.with_row do + render(@form_class.new(form, provider:)) + end + + flex.with_row(mt: 4) do + render(OpenIDConnect::Providers::SubmitOrCancelForm.new( + form, + provider:, + submit_button_options: { label: button_label }, + cancel_button_options: { hidden: edit_mode } + )) + end + end + end +%> diff --git a/modules/openid_connect/app/components/openid_connect/providers/sections/form_component.rb b/modules/openid_connect/app/components/openid_connect/providers/sections/form_component.rb new file mode 100644 index 000000000000..e1da600bae54 --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/sections/form_component.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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. +#++ + +module OpenIDConnect::Providers::Sections + class FormComponent < ::Saml::Providers::Sections::SectionComponent + attr_reader :edit_state, :next_edit_state, :edit_mode + + def initialize(provider, + edit_state:, + form_class:, + heading:, + banner: nil, + banner_scheme: :default, + next_edit_state: nil, + edit_mode: nil) + super(provider) + + @edit_state = edit_state + @next_edit_state = next_edit_state + @edit_mode = edit_mode + @form_class = form_class + @heading = heading + @banner = banner + @banner_scheme = banner_scheme + end + + def url + if provider.new_record? + openid_connect_providers_path(edit_state:, edit_mode:, next_edit_state:) + else + openid_connect_provider_path(edit_state:, edit_mode:, next_edit_state:, id: provider.id) + end + end + + def form_method + if provider.new_record? + :post + else + :put + end + end + + def button_label + if edit_mode + I18n.t(:button_continue) + else + I18n.t(:button_update) + end + end + end +end diff --git a/modules/openid_connect/app/components/openid_connect/providers/sections/metadata_form_component.html.erb b/modules/openid_connect/app/components/openid_connect/providers/sections/metadata_form_component.html.erb new file mode 100644 index 000000000000..efe3f6dd7712 --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/sections/metadata_form_component.html.erb @@ -0,0 +1,56 @@ +<%= + primer_form_with( + model: provider, + id: "openid-connect-providers-edit-form", + url: openid_connect_provider_path(provider, edit_mode:, next_edit_state: :metadata_details), + data: { + controller: "show-when-value-selected", + turbo: true, + turbo_stream: true, + }, + method: :put, + ) do |form| + flex_layout do |flex| + unless edit_mode + flex.with_row do + render(Primer::Alpha::Banner.new(mb: 2, scheme: :warning, icon: :alert)) do + t("openid_connect.providers.section_texts.metadata_form_banner") + end + end + end + + flex.with_row do + render(Primer::Beta::Text.new(tag: :p, font_weight: :bold)) { + I18n.t("openid_connect.providers.label_metadata") + } + end + + flex.with_row do + render(Primer::Beta::Text.new(tag: :p)) { + I18n.t("openid_connect.providers.section_texts.metadata_form_description") + } + end + + flex.with_row do + render(OpenIDConnect::Providers::MetadataOptionsForm.new(form, provider:)) + end + + flex.with_row( + mt: 2, + hidden: provider.metadata_url.blank?, + data: { value: :url, 'show-when-value-selected-target': "effect" } + ) do + render(OpenIDConnect::Providers::MetadataUrlForm.new(form, provider:)) + end + + flex.with_row(mt: 4) do + render(OpenIDConnect::Providers::SubmitOrCancelForm.new( + form, + provider:, + submit_button_options: { label: button_label }, + cancel_button_options: { hidden: edit_mode }, + state: :metadata)) + end + end + end +%> diff --git a/modules/openid_connect/app/components/openid_connect/providers/sections/metadata_form_component.rb b/modules/openid_connect/app/components/openid_connect/providers/sections/metadata_form_component.rb new file mode 100644 index 000000000000..74e4ed983db8 --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/sections/metadata_form_component.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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. +#++ +# +module OpenIDConnect::Providers::Sections + class MetadataFormComponent < FormComponent + def initialize(provider, edit_mode: nil) + super(provider, edit_state: :metadata, edit_mode:, form_class: nil, heading: nil) + end + end +end diff --git a/modules/openid_connect/app/components/openid_connect/providers/sections/show_component.html.erb b/modules/openid_connect/app/components/openid_connect/providers/sections/show_component.html.erb new file mode 100644 index 000000000000..f0a6c588f4fc --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/sections/show_component.html.erb @@ -0,0 +1,45 @@ +<%= + grid_layout('op-saml-view-row', + tag: :div, + test_selector: "openid_connect_provider_#{@target_state}", + align_items: :center) do |grid| + grid.with_area(:title, mr: 3) do + concat render(Primer::Beta::Text.new(font_weight: :bold)) { @heading } + if @label + concat render(Primer::Beta::Label.new(scheme: @label_scheme, ml: 1)) { @label } + end + end + + grid.with_area(:description) do + render(Primer::Beta::Text.new(tag: :p, font_size: :small, color: :subtle)) do + @description + end + end + + disabled = provider.seeded_from_env? + if show_edit? + grid.with_area(:action) do + flex_layout(justify_content: :flex_end) do |icons_container| + if @action + icons_container.with_column do + render(@action) + end + end + + icons_container.with_column do + render( + Primer::Beta::IconButton.new( + icon: disabled ? :eye : :pencil, + tag: :a, + scheme: :invisible, + href: url_for(action: :edit, id: provider.id, edit_state: @target_state), + data: { turbo: true, turbo_stream: true }, + aria: { label: I18n.t(disabled ? :label_show : :label_edit) } + ) + ) + end + end + end + end + end +%> diff --git a/modules/openid_connect/app/components/openid_connect/providers/sections/show_component.rb b/modules/openid_connect/app/components/openid_connect/providers/sections/show_component.rb new file mode 100644 index 000000000000..630b15558b5f --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/sections/show_component.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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. +#++ +# +module OpenIDConnect::Providers::Sections + class ShowComponent < ::Saml::Providers::Sections::SectionComponent + def initialize(provider, view_mode:, target_state:, + heading:, description:, action: nil, label: nil, label_scheme: :attention) + super(provider) + + @target_state = target_state + @view_mode = view_mode + @heading = heading + @description = description + @label = label + @label_scheme = label_scheme + @action = action + end + + def show_edit? + provider.persisted? + end + end +end diff --git a/modules/openid_connect/app/components/openid_connect/providers/submit_or_cancel_form.rb b/modules/openid_connect/app/components/openid_connect/providers/submit_or_cancel_form.rb new file mode 100644 index 000000000000..d04799752bb6 --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/submit_or_cancel_form.rb @@ -0,0 +1,85 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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. +#++ + +module OpenIDConnect + module Providers + class SubmitOrCancelForm < ApplicationForm + form do |f| + if @state + f.hidden( + name: :edit_state, + scope_name_to_model: false, + value: @state + ) + end + + f.group(layout: :horizontal) do |button_group| + button_group.submit(**@submit_button_options) unless @provider.seeded_from_env? + button_group.button(**@cancel_button_options) unless @cancel_button_options[:hidden] + end + end + + def initialize(provider:, state: nil, submit_button_options: {}, cancel_button_options: {}) + super() + @state = state + @provider = provider + @submit_button_options = default_submit_button_options.merge(submit_button_options) + @cancel_button_options = default_cancel_button_options.merge(cancel_button_options) + end + + private + + def default_submit_button_options + { + name: :submit, + scheme: :primary, + label: I18n.t(:button_continue), + disabled: false + } + end + + def default_cancel_button_options + { + name: :cancel, + scheme: :default, + tag: :a, + href: back_link, + label: I18n.t("button_cancel") + } + end + + def back_link + if @provider.new_record? + OpenProject::StaticRouting::StaticRouter.new.url_helpers.openid_connect_providers_path + else + OpenProject::StaticRouting::StaticRouter.new.url_helpers.edit_openid_connect_provider_path(@provider) + end + end + end + end +end diff --git a/modules/openid_connect/app/components/openid_connect/providers/table_component.rb b/modules/openid_connect/app/components/openid_connect/providers/table_component.rb index 8b4225548255..c132e72cd3e1 100644 --- a/modules/openid_connect/app/components/openid_connect/providers/table_component.rb +++ b/modules/openid_connect/app/components/openid_connect/providers/table_component.rb @@ -1,12 +1,24 @@ module OpenIDConnect module Providers - class TableComponent < ::TableComponent - columns :name + class TableComponent < ::OpPrimer::BorderBoxTableComponent + columns :name, :type, :users, :creator, :created_at def initial_sort %i[id asc] end + def header_args(column) + if column == :name + { style: "grid-column: span 3" } + else + super + end + end + + def has_actions? + true + end + def sortable? false end @@ -17,9 +29,29 @@ def empty_row_message def headers [ - ["name", { caption: I18n.t("attributes.name") }] + [:name, { caption: I18n.t("attributes.name") }], + [:type, { caption: I18n.t("attributes.type") }], + [:users, { caption: I18n.t(:label_user_plural) }], + [:creator, { caption: I18n.t("js.label_created_by") }], + [:created_at, { caption: OpenIDConnect::Provider.human_attribute_name(:created_at) }] ] end + + def blank_title + I18n.t("openid_connect.providers.label_empty_title") + end + + def blank_description + I18n.t("openid_connect.providers.label_empty_description") + end + + def row_class + ::OpenIDConnect::Providers::RowComponent + end + + def blank_icon + :key + end end end end diff --git a/modules/openid_connect/app/components/openid_connect/providers/view_component.html.erb b/modules/openid_connect/app/components/openid_connect/providers/view_component.html.erb new file mode 100644 index 000000000000..fd1337d1bd29 --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/view_component.html.erb @@ -0,0 +1,205 @@ +<% ns = OpenIDConnect::Providers %> +<%= component_wrapper do %> + <% if provider.seeded_from_env? %> + <%= + render(Primer::Alpha::Banner.new(mb: 2, scheme: :default, icon: :bell, spacious: true)) do + I18n.t("openid_connect.providers.seeded_from_env") + end + %> + <% end %> + + <%= render(border_box_container) do |component| + case provider.oidc_provider + when 'google' + component.with_row(scheme: :default) do + basic_details_component = if edit_state == :name + ns::Sections::FormComponent.new( + provider, + form_class: ns::NameInputForm, + edit_state:, + next_edit_state: :client_details, + edit_mode:, + heading: nil + ) + else + ns::Sections::ShowComponent.new( + provider, + view_mode:, + target_state: :name, + heading: t("activemodel.attributes.openid_connect/provider.display_name"), + description: t("openid_connect.providers.section_texts.display_name") + ) + end + render(basic_details_component) + end + + component.with_row(scheme: :default) do + if edit_state == :client_details + render(ns::Sections::FormComponent.new( + provider, + form_class: ns::ClientDetailsForm, + edit_state:, + edit_mode:, + heading: nil, + )) + else + render(ns::Sections::ShowComponent.new( + provider, + target_state: :client_details, + view_mode:, + heading: t("openid_connect.providers.label_client_details"), + label: provider.advanced_details_configured? ? t(:label_completed) : t(:label_not_configured), + label_scheme: provider.advanced_details_configured? ? :success : :secondary, + description: t("openid_connect.providers.client_details_description") + )) + end + end + when 'microsoft_entra' + component.with_row(scheme: :default) do + basic_details_component = if edit_state == :name + ns::Sections::FormComponent.new( + provider, + form_class: ns::NameInputAndTenantForm, + edit_state:, + next_edit_state: :client_details, + edit_mode:, + heading: nil + ) + else + ns::Sections::ShowComponent.new( + provider, + view_mode:, + target_state: :name, + heading: t("activemodel.attributes.openid_connect/provider.display_name"), + description: t("openid_connect.providers.section_texts.display_name") + ) + end + render(basic_details_component) + end + + component.with_row(scheme: :default) do + if edit_state == :client_details + render(ns::Sections::FormComponent.new( + provider, + form_class: ns::ClientDetailsForm, + edit_state:, + edit_mode:, + heading: nil + )) + else + render(ns::Sections::ShowComponent.new( + provider, + target_state: :client_details, + view_mode:, + heading: t("openid_connect.providers.label_client_details"), + label: provider.advanced_details_configured? ? t(:label_completed) : t(:label_not_configured), + label_scheme: provider.advanced_details_configured? ? :success : :secondary, + description: t("openid_connect.providers.client_details_description") + )) + end + end + else # custom +# component.with_header(color: :muted) do +# render(Primer::Beta::Text.new(font_weight: :bold)) { I18n.t('openid_connect.providers.label_basic_details') } +# end + + component.with_row(scheme: :default) do + basic_details_component = + if edit_state == :name + ns::Sections::FormComponent.new( + provider, + form_class: ns::NameInputForm, + edit_state:, + next_edit_state: :metadata, + edit_mode:, + heading: nil + ) + else + ns::Sections::ShowComponent.new( + provider, + view_mode:, + target_state: :name, + heading: t("activemodel.attributes.openid_connect/provider.display_name"), + description: t("openid_connect.providers.section_texts.display_name") + ) + end + render(basic_details_component) + end + +# component.with_row(scheme: :neutral, color: :muted) do +# render(Primer::Beta::Text.new(font_weight: :bold)) { I18n.t('openid_connect.providers.label_automatic_configuration') } +# end + + component.with_row(scheme: :default) do + if edit_state == :metadata + render(ns::Sections::MetadataFormComponent.new( + provider, + edit_mode:, + )) + else + render(ns::Sections::ShowComponent.new( + provider, + target_state: :metadata, + view_mode:, + heading: t("openid_connect.providers.label_metadata"), + description: t("openid_connect.providers.section_texts.metadata"), + label: provider.metadata_url.present? ? t(:label_completed) : t(:label_not_configured), + label_scheme: provider.metadata_url.present? ? :success : :secondary + )) + end + end + +# component.with_row(scheme: :neutral, color: :muted) do +# render(Primer::Beta::Text.new(font_weight: :bold)) { I18n.t('openid_connect.providers.label_advanced_configuration') } +# end + + component.with_row(scheme: :default) do + if edit_state == :metadata_details + render(ns::Sections::FormComponent.new( + provider, + form_class: ns::MetadataDetailsForm, + edit_state:, + next_edit_state: :client_details, + edit_mode:, + banner: provider.metadata_configured? ? t("openid_connect.providers.section_texts.configuration_metadata") : nil, + banner_scheme: :default, + heading: nil + )) + else + render(ns::Sections::ShowComponent.new( + provider, + target_state: :metadata_details, + view_mode:, + heading: t("openid_connect.providers.label_configuration_details"), + description: t("openid_connect.providers.section_texts.configuration"), + label: provider.metadata_configured? ? t(:label_completed) : t(:label_not_configured), + label_scheme: provider.metadata_configured? ? :success : :secondary + )) + end + end + + component.with_row(scheme: :default) do + if edit_state == :client_details + render(ns::Sections::FormComponent.new( + provider, + form_class: ns::ClientDetailsForm, + edit_state:, + next_edit_state: :mapping, + edit_mode:, + heading: nil + )) + else + render(ns::Sections::ShowComponent.new( + provider, + target_state: :client_details, + view_mode:, + heading: t("openid_connect.providers.label_client_details"), + description: t("openid_connect.providers.client_details_description"), + label: provider.advanced_details_configured? ? t(:label_completed) : t(:label_not_configured), + label_scheme: provider.advanced_details_configured? ? :success : :secondary + )) + end + end + end + end %> +<% end %> diff --git a/modules/openid_connect/app/components/openid_connect/providers/view_component.rb b/modules/openid_connect/app/components/openid_connect/providers/view_component.rb new file mode 100644 index 000000000000..400203eb270c --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/view_component.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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. +#++ +# +module OpenIDConnect + module Providers + class ViewComponent < ApplicationComponent + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + options :view_mode, :edit_state, :edit_mode + + alias_method :provider, :model + end + end +end diff --git a/modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb b/modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb new file mode 100644 index 000000000000..e4efc55cdd0b --- /dev/null +++ b/modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb @@ -0,0 +1,51 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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. +#++ +module OpenIDConnect + module Providers + class BaseContract < ModelContract + include RequiresAdminGuard + + def self.model + OpenIDConnect::Provider + end + + attribute :display_name + attribute :oidc_provider + validates :oidc_provider, + presence: true, + inclusion: { in: OpenIDConnect::Provider::OIDC_PROVIDERS } + attribute :slug + attribute :options + attribute :limit_self_registration + attribute :metadata_url + validates :metadata_url, + url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, + if: -> { model.metadata_url_changed? } + end + end +end diff --git a/modules/openid_connect/app/contracts/openid_connect/providers/create_contract.rb b/modules/openid_connect/app/contracts/openid_connect/providers/create_contract.rb new file mode 100644 index 000000000000..3f6e5232e93d --- /dev/null +++ b/modules/openid_connect/app/contracts/openid_connect/providers/create_contract.rb @@ -0,0 +1,34 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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. +#++ +module OpenIDConnect + module Providers + class CreateContract < BaseContract + attribute :type + end + end +end diff --git a/modules/openid_connect/app/contracts/openid_connect/providers/delete_contract.rb b/modules/openid_connect/app/contracts/openid_connect/providers/delete_contract.rb new file mode 100644 index 000000000000..89a44cf5904d --- /dev/null +++ b/modules/openid_connect/app/contracts/openid_connect/providers/delete_contract.rb @@ -0,0 +1,35 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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. +#++ + +module OpenIDConnect + module Providers + class DeleteContract < ::DeleteContract + delete_permission :admin + end + end +end diff --git a/modules/openid_connect/app/contracts/openid_connect/providers/update_contract.rb b/modules/openid_connect/app/contracts/openid_connect/providers/update_contract.rb new file mode 100644 index 000000000000..4b6ec6a47baa --- /dev/null +++ b/modules/openid_connect/app/contracts/openid_connect/providers/update_contract.rb @@ -0,0 +1,34 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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. +#++ + +module OpenIDConnect + module Providers + class UpdateContract < BaseContract + end + end +end diff --git a/modules/openid_connect/app/controllers/openid_connect/providers_controller.rb b/modules/openid_connect/app/controllers/openid_connect/providers_controller.rb index ac436d6418fe..55c4e4252175 100644 --- a/modules/openid_connect/app/controllers/openid_connect/providers_controller.rb +++ b/modules/openid_connect/app/controllers/openid_connect/providers_controller.rb @@ -1,44 +1,62 @@ module OpenIDConnect class ProvidersController < ::ApplicationController + include OpTurbo::ComponentStream + layout "admin" menu_item :plugin_openid_connect before_action :require_admin before_action :check_ee before_action :find_provider, only: %i[edit update destroy] + before_action :set_edit_state, only: %i[create edit update] def index; end def new - if openid_connect_providers_available_for_configure.none? - redirect_to action: :index - else - @provider = ::OpenIDConnect::Provider.initialize_with({ use_graph_api: true }) - end + oidc_provider = case params[:oidc_provider] + when "google" + "google" + when "microsoft_entra" + "microsoft_entra" + else + "custom" + end + @provider = OpenIDConnect::Provider.new(oidc_provider:) end def create - @provider = ::OpenIDConnect::Provider.initialize_with(create_params) + create_params = params + .require(:openid_connect_provider) + .permit(:display_name, :oidc_provider, :tenant) + + call = ::OpenIDConnect::Providers::CreateService + .new(user: User.current) + .call(**create_params) + + @provider = call.result - if @provider.save - flash[:notice] = I18n.t(:notice_successful_create) - redirect_to action: :index + if call.success? + successful_save_response else - render action: :new + failed_save_response(:new) end end def edit; end def update - @provider = ::OpenIDConnect::Provider.initialize_with( - update_params.merge("name" => params[:id]) - ) - if @provider.save - flash[:notice] = I18n.t(:notice_successful_update) - redirect_to action: :index + update_params = params + .require(:openid_connect_provider) + .permit(:display_name, :oidc_provider, :limit_self_registration, *OpenIDConnect::Provider.stored_attributes[:options]) + call = OpenIDConnect::Providers::UpdateService + .new(model: @provider, user: User.current) + .call(update_params) + + if call.success? + successful_save_response else - render action: :edit + @provider = call.result + failed_save_response(edit) end end @@ -61,39 +79,74 @@ def check_ee end end - def create_params - params - .require(:openid_connect_provider) - .permit(:name, :display_name, :identifier, :secret, :limit_self_registration, :tenant, :use_graph_api) - end - - def update_params - params - .require(:openid_connect_provider) - .permit(:display_name, :identifier, :secret, :limit_self_registration, :tenant, :use_graph_api) - end - def find_provider - @provider = providers.find { |provider| provider.id.to_s == params[:id].to_s } + @provider = providers.where(id: params[:id]).first if @provider.nil? render_404 end end def providers - @providers ||= OpenProject::OpenIDConnect.providers + @providers ||= ::OpenIDConnect::Provider.where(available: true) end helper_method :providers - def openid_connect_providers_available_for_configure - Provider::ALLOWED_TYPES.dup - providers.map(&:name) - end - helper_method :openid_connect_providers_available_for_configure - def default_breadcrumb; end def show_local_breadcrumb false end + + def successful_save_response + respond_to do |format| + format.turbo_stream do + update_via_turbo_stream( + component: OpenIDConnect::Providers::ViewComponent.new( + @provider, + edit_mode: @edit_mode, + edit_state: @next_edit_state, + view_mode: :show + ) + ) + render turbo_stream: turbo_streams + end + format.html do + flash[:notice] = I18n.t(:notice_successful_update) unless @edit_mode + if @edit_mode && @next_edit_state + redirect_to edit_openid_connect_provider_path(@provider, + anchor: "openid-connect-providers-edit-form", + edit_mode: true, + edit_state: @next_edit_state) + else + redirect_to openid_connect_provider_path(@provider) + end + end + end + end + + def failed_save_response(action_to_render) + respond_to do |format| + format.turbo_stream do + update_via_turbo_stream( + component: OpenIDConnect::Providers::ViewComponent.new( + @provider, + edit_mode: @edit_mode, + edit_state: @edit_state, + view_mode: :show + ) + ) + render turbo_stream: turbo_streams + end + format.html do + render action: action_to_render + end + end + end + + def set_edit_state + @edit_state = params[:edit_state].to_sym if params.key?(:edit_state) + @edit_mode = ActiveRecord::Type::Boolean.new.cast(params[:edit_mode]) + @next_edit_state = params[:next_edit_state].to_sym if params.key?(:next_edit_state) + end end end diff --git a/modules/openid_connect/app/models/openid_connect/provider.rb b/modules/openid_connect/app/models/openid_connect/provider.rb index df19926d187e..9d5f5c4c8678 100644 --- a/modules/openid_connect/app/models/openid_connect/provider.rb +++ b/modules/openid_connect/app/models/openid_connect/provider.rb @@ -1,131 +1,84 @@ module OpenIDConnect - class Provider - ALLOWED_TYPES = ["azure", "google"].freeze + class Provider < AuthProvider + OIDC_PROVIDERS = ["google", "microsoft_entra", "custom"].freeze + DISCOVERABLE_ATTRIBUTES_ALL = %i[authorization_endpoint + userinfo_endpoint + token_endpoint + end_session_endpoint + jwks_uri + issuer].freeze + DISCOVERABLE_ATTRIBUTES_OPTIONAL = %i[end_session_endpoint].freeze + DISCOVERABLE_ATTRIBUTES_MANDATORY = DISCOVERABLE_ATTRIBUTES_ALL - %i[end_session_endpoint] - class NewProvider < OpenStruct - def to_h - @table.compact - end + store_attribute :options, :oidc_provider, :string + store_attribute :options, :metadata_url, :string + DISCOVERABLE_ATTRIBUTES_ALL.each do |attribute| + store_attribute :options, attribute, :string end + store_attribute :options, :client_id, :string + store_attribute :options, :client_secret, :string + store_attribute :options, :tenant, :string - extend ActiveModel::Naming - include ActiveModel::Conversion - extend ActiveModel::Translation - attr_reader :errors, :omniauth_provider - - attr_accessor :display_name - - delegate :name, to: :omniauth_provider, allow_nil: true - delegate :identifier, to: :omniauth_provider, allow_nil: true - delegate :secret, to: :omniauth_provider, allow_nil: true - delegate :scope, to: :omniauth_provider, allow_nil: true - delegate :to_h, to: :omniauth_provider, allow_nil: false + def self.slug_fragment = "oidc" - delegate :tenant, to: :omniauth_provider, allow_nil: false - delegate :configuration, to: :omniauth_provider, allow_nil: true - delegate :use_graph_api, to: :omniauth_provider, allow_nil: false - - def initialize(omniauth_provider) - @omniauth_provider = omniauth_provider - @errors = ActiveModel::Errors.new(self) - @display_name = omniauth_provider.to_h[:display_name] + def seeded_from_env? + (Setting.seed_openid_connect_provider || {}).key?(slug) end - def self.initialize_with(params) - normalized = normalized_params(params) - - # We want all providers to be limited by the self registration setting by default - normalized.reverse_merge!(limit_self_registration: true) - - new(NewProvider.new(normalized)) + def basic_details_configured? + display_name.present? && (oidc_provider == "microsoft_entra" ? tenant.present? : true) end - def self.normalized_params(params) - transformed = %i[limit_self_registration use_graph_api].filter_map do |key| - if params.key?(key) - value = params[key] - [key, ActiveRecord::Type::Boolean.new.deserialize(value)] - end - end - - params.merge(transformed.to_h) + def advanced_details_configured? + client_id.present? && client_secret.present? end - def new_record? - !persisted? - end - - def persisted? - omniauth_provider.is_a?(OmniAuth::OpenIDConnect::Provider) + def metadata_configured? + DISCOVERABLE_ATTRIBUTES_MANDATORY.all? do |mandatory_attribute| + public_send(mandatory_attribute).present? + end end - def limit_self_registration - (configuration || {}).fetch(:limit_self_registration, true) + def configured? + basic_details_configured? && advanced_details_configured? && metadata_configured? end - alias_method :limit_self_registration?, :limit_self_registration - def to_h - return {} if omniauth_provider.nil? - - omniauth_provider.to_h - end - - def id - return nil unless persisted? - - name - end - - def valid? - @errors.add(:name, :invalid) unless type_allowed?(name) - @errors.add(:identifier, :blank) if identifier.blank? - @errors.add(:secret, :blank) if secret.blank? - @errors.none? - end - - ## - # Checks if the provider with the given name is of an allowed type. - # - # Types can be followed by a period and arbitrary names to add several - # providers of the same type. E.g. 'azure', 'azure.dep1', 'azure.dep2'. - def type_allowed?(name) - ALLOWED_TYPES.any? { |allowed| name =~ /\A#{allowed}(\..+)?\Z/ } - end - - def save - return false unless valid? - - Setting.plugin_openproject_openid_connect = setting_with_provider - - true - end - - def destroy - Setting.plugin_openproject_openid_connect = setting_without_provider - - true - end - - def setting_with_provider - setting.deep_merge "providers" => { name => to_h.stringify_keys } - end - - def setting_without_provider - setting.tap do |s| - s["providers"].delete name + h = { + name: slug, + icon:, + display_name:, + userinfo_endpoint:, + authorization_endpoint:, + jwks_uri:, + host: URI(issuer).host, + issuer:, + identifier: client_id, + secret: client_secret, + token_endpoint:, + limit_self_registration:, + end_session_endpoint: + }.to_h + + if oidc_provider == "google" + h.merge!({ + client_auth_method: :not_basic, + send_nonce: false, # use state instead of nonce + state: lambda { SecureRandom.hex(42) } + }) end + h end - def setting - Hash(Setting.plugin_openproject_openid_connect).tap do |h| - h["providers"] ||= Hash.new + def icon + case oidc_provider + when "google" + "openid_connect/auth_provider-google.png" + when "microsoft_entra" + "openid_connect/auth_provider-azure.png" + else + "openid_connect/auth_provider-custom.png" end end - - # https://api.rubyonrails.org/classes/ActiveModel/Errors.html - def read_attribute_for_validation(attr) - send(attr) - end end end diff --git a/modules/openid_connect/app/seeders/env_data/openid_connect/provider_seeder.rb b/modules/openid_connect/app/seeders/env_data/openid_connect/provider_seeder.rb new file mode 100644 index 000000000000..207ba426c74b --- /dev/null +++ b/modules/openid_connect/app/seeders/env_data/openid_connect/provider_seeder.rb @@ -0,0 +1,52 @@ +#-- 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. +#++ + +module EnvData + module OpenIDConnect + class ProviderSeeder < Seeder + def seed_data! + Setting.seed_openid_connect_provider.each do |name, configuration| + print_status " ↳ Creating or Updating OpenID provider #{name}" do + call = ::OpenIDConnect::SyncService.new(name, configuration).call + + if call.success + print_status " - #{call.message}" + else + raise call.message + end + end + end + end + + def applicable? + Setting.seed_openid_connect_provider.present? + end + end + end +end diff --git a/modules/openid_connect/app/services/openid_connect/providers/create_service.rb b/modules/openid_connect/app/services/openid_connect/providers/create_service.rb new file mode 100644 index 000000000000..734fd5378978 --- /dev/null +++ b/modules/openid_connect/app/services/openid_connect/providers/create_service.rb @@ -0,0 +1,34 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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. +#++ + +module OpenIDConnect + module Providers + class CreateService < BaseServices::Create + end + end +end diff --git a/modules/openid_connect/app/services/openid_connect/providers/delete_service.rb b/modules/openid_connect/app/services/openid_connect/providers/delete_service.rb new file mode 100644 index 000000000000..11416b30fa3b --- /dev/null +++ b/modules/openid_connect/app/services/openid_connect/providers/delete_service.rb @@ -0,0 +1,33 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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. +#++ +module OpenIDConnect + module Providers + class DeleteService < BaseServices::Delete + end + end +end diff --git a/modules/openid_connect/app/services/openid_connect/providers/set_attributes_service.rb b/modules/openid_connect/app/services/openid_connect/providers/set_attributes_service.rb new file mode 100644 index 000000000000..92e90fe00200 --- /dev/null +++ b/modules/openid_connect/app/services/openid_connect/providers/set_attributes_service.rb @@ -0,0 +1,42 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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. +#++ + +module OpenIDConnect + module Providers + class SetAttributesService < BaseServices::SetAttributes + private + + def set_default_attributes(*) + model.change_by_system do + model.creator ||= user + model.slug ||= "#{model.class.slug_fragment}-#{model.display_name.to_url}" if model.display_name + end + end + end + end +end diff --git a/modules/openid_connect/app/services/openid_connect/providers/update_service.rb b/modules/openid_connect/app/services/openid_connect/providers/update_service.rb new file mode 100644 index 000000000000..2ca3dacb9320 --- /dev/null +++ b/modules/openid_connect/app/services/openid_connect/providers/update_service.rb @@ -0,0 +1,88 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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. +#++ + +module OpenIDConnect + module Providers + class UpdateService < BaseServices::Update + class AttributesContract < Dry::Validation::Contract + params do + OpenIDConnect::Provider::DISCOVERABLE_ATTRIBUTES_MANDATORY.each do |attribute| + required(attribute).filled(:string) + end + OpenIDConnect::Provider::DISCOVERABLE_ATTRIBUTES_OPTIONAL.each do |attribute| + optional(attribute).filled(:string) + end + end + end + + def after_validate(_params, call) + model = call.result + metadata_url = case model.oidc_provider + when "google" + "https://accounts.google.com/.well-known/openid-configuration" + when "microsoft_entra" + "https://login.microsoftonline.com/#{model.tenant || 'common'}/v2.0/.well-known/openid-configuration" + else + model.metadata_url + end + return call if metadata_url.blank? + + case (response = OpenProject.httpx.get(metadata_url)) + in {status: 200..299} + json = begin + response.json + rescue HTTPX::Error + call.errors.add(:metadata_url, :response_is_json) + call.success = false + end + result = AttributesContract.new.call(json) + if result.errors.empty? + model.assign_attributes(result.to_h) + # Microsoft responds with + # "https://login.microsoftonline.com/{tenantid}/v2.0" in issuer field for whatever reason... + if model.oidc_provider == "microsoft_entra" + model.issuer = "https://login.microsoftonline.com/#{model.tenant}/v2.0" + end + else + call.errors.add(:metadata_url, + :response_misses_required_attributes, + missing_attributes: result.errors.to_h.keys.join(", ")) + call.success = false + end + in {status: 300..} + call.errors.add(:metadata_url, :response_is_not_successful, status: response.status) + call.success = false + in {error: error} + raise error + end + + call + end + end + end +end diff --git a/modules/openid_connect/app/services/openid_connect/sync_service.rb b/modules/openid_connect/app/services/openid_connect/sync_service.rb new file mode 100644 index 000000000000..368bdbc5ef0b --- /dev/null +++ b/modules/openid_connect/app/services/openid_connect/sync_service.rb @@ -0,0 +1,68 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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. +#++ + +module OpenIDConnect + class SyncService + attr_reader :name, :configuration + + def initialize(name, configuration) + @name = name + @provider_attributes = + { + "slug" => name, + "oidc_provider" => "custom", + "display_name" => configuration["display_name"], + "client_id" => configuration["identifier"], + "client_secret" => configuration["secret"], + "issuer" => configuration["issuer"], + "authorization_endpoint" => configuration["authorization_endpoint"], + "token_endpoint" => configuration["token_endpoint"], + "userinfo_endpoint" => configuration["userinfo_endpoint"], + "end_session_endpoint" => configuration["end_session_endpoint"], + "jwks_uri" => configuration["jwks_uri"] + } + end + + def call + provider = ::OpenIDConnect::Provider.find_by(slug: name) + if provider + ::OpenIDConnect::Providers::UpdateService + .new(model: provider, user: User.system) + .call(@provider_attributes) + .on_success { |call| call.message = "Successfully updated OpenID provider #{name}." } + .on_failure { |call| call.message = "Failed to update OpenID provider: #{call.message}" } + else + ::OpenIDConnect::Providers::CreateService + .new(user: User.system) + .call(@provider_attributes) + .on_success { |call| call.message = "Successfully created OpenID provider #{name}." } + .on_failure { |call| call.message = "Failed to create OpenID provider: #{call.message}" } + end + end + end +end diff --git a/modules/openid_connect/app/views/openid_connect/providers/_azure_form.html.erb b/modules/openid_connect/app/views/openid_connect/providers/_azure_form.html.erb deleted file mode 100644 index 1c948da0dd0f..000000000000 --- a/modules/openid_connect/app/views/openid_connect/providers/_azure_form.html.erb +++ /dev/null @@ -1,21 +0,0 @@ -<% if (@provider.new_record? && !providers.map(&:name).include?('azure')) || @provider.name == 'azure' %> - <%= content_tag :fieldset, - class: 'form--fieldset', - data: { - 'admin--openid-connect-providers-target': 'azureForm', - }, - hidden: @provider.name.present? && @provider.name != 'azure' do %> -
- <%= f.text_field :tenant, required: true, placeholder: 'common', container_class: '-middle' %> -
- <%= t('openid_connect.setting_instructions.azure_tenant_html') %> -
-
-
- <%= f.check_box :use_graph_api, container_class: '-middle' %> -
- <%= t('openid_connect.setting_instructions.azure_graph_api') %> -
-
- <% end %> -<% end %> diff --git a/modules/openid_connect/app/views/openid_connect/providers/_form.html.erb b/modules/openid_connect/app/views/openid_connect/providers/_form.html.erb deleted file mode 100644 index 046a0bbfefa2..000000000000 --- a/modules/openid_connect/app/views/openid_connect/providers/_form.html.erb +++ /dev/null @@ -1,43 +0,0 @@ -<% if @provider.persisted? && @provider.name == 'azure' && @provider.tenant.empty? %> -
-
-

<%= I18n.t('openid_connect.setting_instructions.azure_deprecation_warning') %>

-
-
-<% end %> - -
- <% unless @provider.persisted? -%> -
- <%= f.collection_select :name, - openid_connect_providers_available_for_configure, - :to_s, - :capitalize, - { container_class: '-middle', required: true }, - data: { - 'action': 'admin--openid-connect-providers#updateTypeForm' - } - %> -
- <% end -%> - -
- <%= f.text_field :display_name, required: false, container_class: '-middle' %> -
- -
- <%= f.text_field :identifier, required: true, container_class: '-middle' %> -
- -
- <%= f.text_field :secret, required: true, container_class: '-middle' %> -
- -
- <%= f.check_box :limit_self_registration, required: false, container_class: '-middle' %> -
- <%= I18n.t('openid_connect.setting_instructions.limit_self_registration') %> -
-
-
-<%= render partial: 'azure_form', locals: { f: } %> diff --git a/modules/openid_connect/app/views/openid_connect/providers/edit.html.erb b/modules/openid_connect/app/views/openid_connect/providers/edit.html.erb index fd27b98d44bf..d436f2fb0347 100644 --- a/modules/openid_connect/app/views/openid_connect/providers/edit.html.erb +++ b/modules/openid_connect/app/views/openid_connect/providers/edit.html.erb @@ -1,25 +1,36 @@ -<% page_title = t('openid_connect.providers.label_edit', name: @provider.name) %> -<% local_assigns[:additional_breadcrumb] = @provider.name %> +<% page_title = t('openid_connect.providers.label_edit', name: @provider.display_name) %> +<% local_assigns[:additional_breadcrumb] = @provider.display_name %> <% html_title(t(:label_administration), page_title) -%> <%= render Primer::OpenProject::PageHeader.new do |header| - header.with_title { @provider.name } + header.with_title { @provider.display_name } header.with_breadcrumbs([{ href: admin_index_path, text: t(:label_administration) }, { href: admin_settings_authentication_path, text: t(:label_authentication) }, { href: openid_connect_providers_path, text: t("openid_connect.providers.plural") }, - @provider.name]) + @provider.display_name]) + header.with_action_button( + tag: :a, + scheme: :danger, + mobile_icon: :trash, + mobile_label: t(:button_delete), + size: :medium, + href: openid_connect_provider_path(@provider), + aria: { label: I18n.t(:button_delete) }, + data: { + confirm: t(:text_are_you_sure), + method: :delete, + }, + title: I18n.t(:button_delete) + ) do |button| + button.with_leading_visual_icon(icon: :trash) + t(:button_delete) + end end %> -<%= error_messages_for @provider %> - -<%= labelled_tabular_form_for @provider, - html: { class: 'form', autocomplete: 'off' } do |f| %> - <%= render partial: "form", locals: { f: f } %> -

- <%= styled_button_tag t(:button_save), class: '-primary -with-icon icon-checkmark' %> - <%= link_to t(:button_cancel), { action: :index }, class: 'button -with-icon icon-cancel' %> -

-<% end %> +<%= render(OpenIDConnect::Providers::ViewComponent.new(@provider, + view_mode: :edit, + edit_mode: @edit_mode, + edit_state: @edit_state)) %> diff --git a/modules/openid_connect/app/views/openid_connect/providers/index.html.erb b/modules/openid_connect/app/views/openid_connect/providers/index.html.erb index 4578dc4a5de1..30c030c5613e 100644 --- a/modules/openid_connect/app/views/openid_connect/providers/index.html.erb +++ b/modules/openid_connect/app/views/openid_connect/providers/index.html.erb @@ -11,15 +11,28 @@ <%= render(Primer::OpenProject::SubHeader.new) do |subheader| - subheader.with_action_button(scheme: :primary, - aria: { label: I18n.t("openid_connect.providers.label_add_new") }, - title: I18n.t("openid_connect.providers.label_add_new"), - tag: :a, - href: new_openid_connect_provider_path) do |button| - button.with_leading_visual_icon(icon: :plus) - t("openid_connect.providers.singular") + subheader.with_action_component do + render(Primer::Alpha::ActionMenu.new( + anchor_align: :end) + ) do |menu| + menu.with_show_button( + scheme: :primary, + aria: { label: I18n.t("openid_connect.providers.label_add_new") }, + ) do |button| + button.with_leading_visual_icon(icon: :plus) + button.with_trailing_action_icon(icon: :"triangle-down") + I18n.t("openid_connect.providers.singular") + end + + OpenIDConnect::Provider::OIDC_PROVIDERS.each do |provider_type| + menu.with_item( + label: I18n.t("openid_connect.providers.#{provider_type}.name"), + href: url_helpers.new_openid_connect_provider_path(oidc_provider: provider_type) + ) + end + end end - end if openid_connect_providers_available_for_configure.any? + end %> <%= render ::OpenIDConnect::Providers::TableComponent.new(rows: providers) %> diff --git a/modules/openid_connect/app/views/openid_connect/providers/new.html.erb b/modules/openid_connect/app/views/openid_connect/providers/new.html.erb index a82a34b5f4e1..e88aa00be8f3 100644 --- a/modules/openid_connect/app/views/openid_connect/providers/new.html.erb +++ b/modules/openid_connect/app/views/openid_connect/providers/new.html.erb @@ -10,16 +10,4 @@ end %> -<%= error_messages_for @provider %> - -<% content_controller 'admin--openid-connect-providers', - dynamic: true %> - -<%= labelled_tabular_form_for @provider, - html: { class: 'form', autocomplete: 'off' } do |f| %> - <%= render partial: "form", locals: { f: f } %> -

- <%= styled_button_tag t(:button_create), class: '-primary -with-icon icon-checkmark' %> - <%= link_to t(:button_cancel), { action: :index }, class: 'button -with-icon icon-cancel' %> -

-<% end %> +<%= render(OpenIDConnect::Providers::ViewComponent.new(@provider, edit_mode: true, edit_state: :name)) %> diff --git a/modules/openid_connect/config/locales/en.yml b/modules/openid_connect/config/locales/en.yml index affacbce8a4f..7bede7ed3395 100644 --- a/modules/openid_connect/config/locales/en.yml +++ b/modules/openid_connect/config/locales/en.yml @@ -10,25 +10,76 @@ en: openid_connect/provider: name: Name display_name: Display name - identifier: Identifier + client_id: Client ID + client_secret: Client secret secret: Secret scope: Scope limit_self_registration: Limit self registration + authorization_endpoint: Authorization endpoint + userinfo_endpoint: User information endpoint + token_endpoint: Token endpoint + end_session_endpoint: End session endpoint + jwks_uri: JWKS URI + issuer: Issuer + limit_self_registration: Limit self-registration + tenant: Tenant + metadata_url: Metadata URL + activerecord: + errors: + models: + openid_connect/provider: + attributes: + metadata_url: + format: "Discovery endpoint URL %{message}" + response_is_not_successful: " responds with %{status}." + response_is_not_json: " does not return JSON body." + response_misses_required_attributes: " does not return required attributes. Missing attributes are: %{missing_attributes}." + openid_connect: menu_title: OpenID providers + instructions: + endpoint_url: The endpoint URL given to you by the OpenID Connect provider + metadata_none: I don't have this information + metadata_url: I have a discovery endpoint URL + client_id: This is the client ID given to you by your OpenID Connect provider + client_secret: This is the client secret given to you by your OpenID Connect provider + limit_self_registration: If enabled, users can only register using this provider if configuration on the prvoder's end allows it. + display_name: Then name of the provider. This will be displayed as the login button and in the list of providers. + tenant: Please replace the default tenant with your own if applicable. See this. + settings: + metadata_none: I don't have this information + metadata_url: I have a discovery endpoint URL + endpoint_url: Endpoint URL providers: + seeded_from_env: "This provider was seeded from the environment configuration. It cannot be edited." + google: + name: Google + microsoft_entra: + name: Microsoft Entra + custom: + name: Custom label_add_new: Add a new OpenID provider label_edit: Edit OpenID provider %{name} + label_empty_title: No OIDC providers configured yet. + label_empty_description: Add a provider to see them here. + label_basic_details: Basic details + label_metadata: OpenID Connect Discovery Endpoint + label_automatic_configuration: Automatic configuration + label_advanced_configuration: Advanced configuration + label_configuration_details: Metadata + label_client_details: Client details + client_details_description: Configuration details of OpenProject as an OIDC client no_results_table: No providers have been defined yet. plural: OpenID providers singular: OpenID provider + section_texts: + metadata: Pre-fill configuration using an OpenID Connect discovery endpoint URL + metadata_form_banner: Editing the discovery endpoint may override existing pre-filled metadata values. + metadata_form_title: OpenID Connect Discovery endpoint + metadata_form_description: If your identity provider has a discovery endpoint URL. Use it below to pre-fill configuration. + configuration_metadata: The information has been pre-filled using the supplied discovery endpoint. In most cases, they do not require editing. + configuration: Configuration details of the OpenID Connect provider + display_name: The display name visible to users. setting_instructions: - azure_deprecation_warning: > - The configured Azure app points to a deprecated API from Azure. Please create a new Azure app to ensure the functionality in future. - azure_graph_api: > - Use the graph.microsoft.com userinfo endpoint to request userdata. This should be the default unless you have an older azure application. - azure_tenant_html: > - Set the tenant of your Azure endpoint. This will control who gets access to the OpenProject instance. - For more information, please see our user guide on Azure OpenID connect. limit_self_registration: > If enabled users can only register using this provider if the self registration setting allows for it. diff --git a/modules/openid_connect/config/routes.rb b/modules/openid_connect/config/routes.rb index d10644fbc8fe..f799801c0a06 100644 --- a/modules/openid_connect/config/routes.rb +++ b/modules/openid_connect/config/routes.rb @@ -3,7 +3,7 @@ scope :admin do namespace :openid_connect do - resources :providers, only: %i[index new create edit update destroy] + resources :providers, except: %i[show] end end end diff --git a/modules/openid_connect/lib/open_project/openid_connect.rb b/modules/openid_connect/lib/open_project/openid_connect.rb index c9331a39ee5d..d82e29f7e2d5 100644 --- a/modules/openid_connect/lib/open_project/openid_connect.rb +++ b/modules/openid_connect/lib/open_project/openid_connect.rb @@ -4,28 +4,26 @@ module OpenProject module OpenIDConnect - CONFIG_KEY = "openid_connect".freeze + CONFIG_KEY = :seed_openid_connect_provider + CONFIG_OPTIONS = { + description: "Provide a OpenIDConnect provider and sync its settings through ENV", + env_alias: "OPENPROJECT_OPENID__CONNECT", + default: {}, + writable: false, + format: :hash + }.freeze def providers # update base redirect URI in case settings changed ::OmniAuth::OpenIDConnect::Providers.configure( base_redirect_uri: "#{Setting.protocol}://#{Setting.host_name}#{OpenProject::Configuration['rails_relative_url_root']}" ) - ::OmniAuth::OpenIDConnect::Providers.load(configuration).map do |omniauth_provider| - ::OpenIDConnect::Provider.new(omniauth_provider) + providers = ::OpenIDConnect::Provider.where(available: true).select(&:configured?) + configuration = providers.each_with_object({}) do |provider, hash| + hash[provider.slug] = provider.to_h end + ::OmniAuth::OpenIDConnect::Providers.load(configuration) end module_function :providers - - def configuration - from_settings = if Setting.plugin_openproject_openid_connect.is_a? Hash - Hash(Setting.plugin_openproject_openid_connect["providers"]) - else - {} - end - # Settings override configuration.yml - Hash(OpenProject::Configuration[CONFIG_KEY]).deep_merge(from_settings) - end - module_function :configuration end end diff --git a/modules/openid_connect/lib/open_project/openid_connect/engine.rb b/modules/openid_connect/lib/open_project/openid_connect/engine.rb index 345651d2967e..a9914ec6c6ac 100644 --- a/modules/openid_connect/lib/open_project/openid_connect/engine.rb +++ b/modules/openid_connect/lib/open_project/openid_connect/engine.rb @@ -22,7 +22,7 @@ class Engine < ::Rails::Engine assets %w( openid_connect/auth_provider-azure.png openid_connect/auth_provider-google.png - openid_connect/auth_provider-heroku.png + openid_connect/auth_provider-custom.png ) class_inflection_override("openid_connect" => "OpenIDConnect") @@ -62,7 +62,8 @@ class Engine < ::Rails::Engine initializer "openid_connect.configure" do ::Settings::Definition.add( - OpenProject::OpenIDConnect::CONFIG_KEY, default: {}, writable: false + OpenProject::OpenIDConnect::CONFIG_KEY, + **OpenProject::OpenIDConnect::CONFIG_OPTIONS ) end @@ -70,7 +71,9 @@ class Engine < ::Rails::Engine # If response_mode 'form_post' is chosen, # the IP sends a POST to the callback. Only if # the sameSite flag is not set on the session cookie, is the cookie send along with the request. - if OpenProject::Configuration["openid_connect"]&.any? { |_, v| v["response_mode"]&.to_s == "form_post" } + if OpenProject::Configuration[OpenProject::OpenIDConnect::CONFIG_KEY]&.any? do |_, v| + v["response_mode"]&.to_s == "form_post" + end SecureHeaders::Configuration.default.cookies[:samesite][:lax] = false # Need to reload the secure_headers config to # avoid having set defaults (e.g. https) when changing the cookie values diff --git a/modules/openid_connect/spec/controllers/providers_controller_spec.rb b/modules/openid_connect/spec/controllers/providers_controller_spec.rb deleted file mode 100644 index 493b4d50d543..000000000000 --- a/modules/openid_connect/spec/controllers/providers_controller_spec.rb +++ /dev/null @@ -1,270 +0,0 @@ -#-- 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" - -RSpec.describe OpenIDConnect::ProvidersController do - let(:user) { build_stubbed(:admin) } - - let(:valid_params) do - { - name: "azure", - identifier: "IDENTIFIER", - secret: "SECRET" - } - end - - before do - login_as user - end - - context "without an EE token", with_ee: false do - it "renders upsale" do - get :index - expect(response).to have_http_status(:ok) - expect(response).to render_template "openid_connect/providers/upsale" - end - end - - context "with an EE token", with_ee: %i[sso_auth_providers] do - before do - login_as user - end - - context "when not admin" do - let(:user) { build_stubbed(:user) } - - it "renders 403" do - get :index - expect(response).to have_http_status(:forbidden) - end - end - - context "when not logged in" do - let(:user) { User.anonymous } - - it "renders 403" do - get :index - expect(response.status).to redirect_to(signin_url(back_url: openid_connect_providers_url)) - end - end - - describe "#index" do - it "renders the index page" do - get :index - expect(response).to be_successful - expect(response).to render_template "index" - end - end - - describe "#new" do - it "renders the new page" do - get :new - expect(response).to be_successful - expect(assigns[:provider]).to be_new_record - expect(response).to render_template "new" - end - - it "redirects to the index page if no provider available", with_settings: { - plugin_openproject_openid_connect: { - "providers" => OpenIDConnect::Provider::ALLOWED_TYPES.inject({}) do |accu, name| - accu.merge(name => { "identifier" => "IDENTIFIER", "secret" => "SECRET" }) - end - } - } do - get :new - expect(response).to be_redirect - end - end - - describe "#create" do - context "with valid params" do - let(:params) { { openid_connect_provider: valid_params } } - - before do - post :create, params: - end - - it "is successful" do - expect(flash[:notice]).to eq(I18n.t(:notice_successful_create)) - expect(Setting.plugin_openproject_openid_connect["providers"]).to have_key("azure") - expect(response).to be_redirect - end - - context "with limit_self_registration checked" do - let(:params) do - { openid_connect_provider: valid_params.merge(limit_self_registration: 1) } - end - - it "sets the setting" do - expect(OpenProject::Plugins::AuthPlugin) - .to be_limit_self_registration provider: valid_params[:name] - end - end - - context "with limit_self_registration unchecked" do - let(:params) do - { openid_connect_provider: valid_params.merge(limit_self_registration: 0) } - end - - it "does not set the setting" do - expect(OpenProject::Plugins::AuthPlugin) - .not_to be_limit_self_registration provider: valid_params[:name] - end - end - end - - it "renders an error if invalid params" do - post :create, params: { openid_connect_provider: valid_params.merge(identifier: "") } - expect(response).to render_template "new" - end - end - - describe "#edit" do - context "when found", with_settings: { - plugin_openproject_openid_connect: { - "providers" => { "azure" => { "identifier" => "IDENTIFIER", "secret" => "SECRET" } } - } - } do - it "renders the edit page" do - get :edit, params: { id: "azure" } - expect(response).to be_successful - expect(assigns[:provider]).to be_present - expect(response).to render_template "edit" - end - - context( - "with limit_self_registration set", - with_settings: { - plugin_openproject_openid_connect: { - "providers" => { - "azure" => { - "identifier" => "IDENTIFIER", - "secret" => "SECRET", - "limit_self_registration" => true - } - } - } - } - ) do - before do - get :edit, params: { id: "azure" } - end - - it "shows limit_self_registration as checked" do - expect(assigns[:provider]).to be_limit_self_registration - end - end - - context "with limit_self_registration not set" do - before do - get :edit, params: { id: "azure" } - end - - it "shows limit_self_registration as checked" do - expect(assigns[:provider]).to be_limit_self_registration - end - end - end - - context "when not found" do - it "renders 404" do - get :edit, params: { id: "doesnoexist" } - expect(response).not_to be_successful - expect(response).to have_http_status(:not_found) - end - end - end - - describe "#update" do - context "when found" do - before do - Setting.plugin_openproject_openid_connect = { - "providers" => { "azure" => { "identifier" => "IDENTIFIER", "secret" => "SECRET" } } - } - end - - it "successfully updates the provider configuration" do - put :update, params: { id: "azure", openid_connect_provider: valid_params.merge(secret: "NEWSECRET") } - expect(response).to be_redirect - expect(flash[:notice]).to be_present - provider = OpenProject::OpenIDConnect.providers.find { |item| item.name == "azure" } - expect(provider.secret).to eq("NEWSECRET") - end - - context "with limit_self_registration checked" do - let(:params) do - { id: "azure", openid_connect_provider: valid_params.merge(limit_self_registration: 1) } - end - - it "sets the setting" do - put(:update, params:) - - expect(OpenProject::Plugins::AuthPlugin) - .to be_limit_self_registration provider: :azure - provider = OpenProject::OpenIDConnect.providers.find { |item| item.name == "azure" } - expect(provider.limit_self_registration).to be true - end - end - - context "with limit_self_registration unchecked" do - let(:params) do - { id: :azure, openid_connect_provider: valid_params.merge(limit_self_registration: 0) } - end - - it "does not set the setting" do - put(:update, params:) - - expect(OpenProject::Plugins::AuthPlugin) - .not_to be_limit_self_registration provider: valid_params[:name] - - provider = OpenProject::OpenIDConnect.providers.find { |item| item.name == "azure" } - expect(provider.limit_self_registration).to be false - end - end - end - end - - describe "#destroy" do - context "when found" do - before do - Setting.plugin_openproject_openid_connect = { - "providers" => { "azure" => { "identifier" => "IDENTIFIER", "secret" => "SECRET" } } - } - end - - it "removes the provider" do - delete :destroy, params: { id: "azure" } - expect(response).to be_redirect - expect(flash[:notice]).to be_present - expect(OpenProject::OpenIDConnect.providers).to be_empty - end - end - end - end -end diff --git a/modules/openid_connect/spec/factories/oidc_provider_factory.rb b/modules/openid_connect/spec/factories/oidc_provider_factory.rb new file mode 100644 index 000000000000..bc5502c8dbf4 --- /dev/null +++ b/modules/openid_connect/spec/factories/oidc_provider_factory.rb @@ -0,0 +1,41 @@ +FactoryBot.define do + factory :oidc_provider, class: "OpenIDConnect::Provider" do + display_name { "Foobar" } + slug { "oidc-foobar" } + limit_self_registration { true } + creator factory: :user + + options do + { + "issuer" => "https://keycloak.local/realms/master", + "jwks_uri" => "https://keycloak.local/realms/master/protocol/openid-connect/certs", + "client_id" => "https://openproject.local", + "client_secret" => "9AWjVC3A4U1HLrZuSP4xiwHfw6zmgECn", + "metadata_url" => "https://keycloak.local/realms/master/.well-known/openid-configuration", + "oidc_provider" => "custom", + "token_endpoint" => "https://keycloak.local/realms/master/protocol/openid-connect/token", + "userinfo_endpoint" => "https://keycloak.local/realms/master/protocol/openid-connect/userinfo", + "end_session_endpoint" => "https://keycloak.local/realms/master/protocol/openid-connect/logout", + "authorization_endpoint" => "https://keycloak.local/realms/master/protocol/openid-connect/auth" + } + end + end + + factory :oidc_provider_google, class: "OpenIDConnect::Provider" do + display_name { "Google" } + slug { "oidc-google" } + limit_self_registration { true } + creator factory: :user + + options do + { "issuer" => "https://accounts.google.com", + "jwks_uri" => "https://www.googleapis.com/oauth2/v3/certs", + "client_id" => "identifier", + "client_secret" => "secret", + "oidc_provider" => "google", + "token_endpoint" => "https://oauth2.googleapis.com/token", + "userinfo_endpoint" => "https://openidconnect.googleapis.com/v1/userinfo", + "authorization_endpoint" => "https://accounts.google.com/o/oauth2/v2/auth" } + end + end +end diff --git a/modules/openid_connect/spec/models/openid_connect/provider_spec.rb b/modules/openid_connect/spec/models/openid_connect/provider_spec.rb deleted file mode 100644 index f58d071c0fdf..000000000000 --- a/modules/openid_connect/spec/models/openid_connect/provider_spec.rb +++ /dev/null @@ -1,100 +0,0 @@ -#-- 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" - -RSpec.describe OpenIDConnect::Provider do - let(:params) do - {} - end - let(:provider) do - described_class.initialize_with({ name: "azure", identifier: "id", secret: "secret" }.merge(params)) - end - - def auth_plugin - OpenProject::Plugins::AuthPlugin - end - - describe "limit_self_registration" do - before do - # required so that the auth plugin sees any providers (ee feature) - allow(EnterpriseToken).to receive(:show_banners?).and_return false - end - - context "with no limited providers" do - it "shows the provider as limited" do - provider.save - expect(auth_plugin).to be_limit_self_registration provider: provider.name - end - - context "when set to true" do - let(:params) do - { limit_self_registration: true } - end - - it "saving the provider makes it limited" do - provider.save - - expect(auth_plugin).to be_limit_self_registration provider: provider.name - end - end - - context "when set to false" do - let(:params) do - { limit_self_registration: false } - end - - it "saving the provider does nothing" do - provider.save - - expect(auth_plugin).not_to be_limit_self_registration provider: provider.name - end - end - end - - context( - "with a limited provider", - with_settings: { - plugin_openproject_openid_connect: { - "providers" => { - "azure" => { - "name" => "azure", - "identifier" => "id", - "secret" => "secret", - "limit_self_registration" => true - } - } - } - } - ) do - it "shows the provider as limited" do - expect(auth_plugin).to be_limit_self_registration provider: provider.name - end - end - end -end diff --git a/modules/openid_connect/spec/requests/openid_connect_spec.rb b/modules/openid_connect/spec/requests/openid_connect_spec.rb index 808902e6aad0..63af8aa65679 100644 --- a/modules/openid_connect/spec/requests/openid_connect_spec.rb +++ b/modules/openid_connect/spec/requests/openid_connect_spec.rb @@ -36,7 +36,7 @@ RSpec.describe "OpenID Connect", :skip_2fa_stage, # Prevent redirects to 2FA stage type: :rails_request, with_ee: %i[sso_auth_providers] do - let(:host) { OmniAuth::OpenIDConnect::Heroku.new("foo", {}).host } + let(:host) { "keycloak.local" } let(:user_info) do { sub: "87117114115116", @@ -67,20 +67,13 @@ describe "sign-up and login" do before do - allow(Setting).to receive(:plugin_openproject_openid_connect).and_return( - "providers" => { - "heroku" => { - "identifier" => "does not", - "secret" => "matter" - } - } - ) + create(:oidc_provider, slug: "keycloak", limit_self_registration: false) end it "works" do ## # it should redirect to the provider's openid connect authentication endpoint - click_on_signin + click_on_signin("keycloak") expect(response).to have_http_status :found expect(response.location).to match /https:\/\/#{host}.*$/ @@ -88,12 +81,12 @@ params = Rack::Utils.parse_nested_query(response.location.gsub(/^.*\?/, "")) expect(params).to include "client_id" - expect(params["redirect_uri"]).to match /^.*\/auth\/heroku\/callback$/ + expect(params["redirect_uri"]).to match /^.*\/auth\/keycloak\/callback$/ expect(params["scope"]).to include "openid" ## # it should redirect back from the provider to the login page - redirect_from_provider + redirect_from_provider("keycloak") expect(response).to have_http_status :found expect(response.location).to match /\/\?first_time_user=true$/ @@ -109,14 +102,14 @@ user.activate user.save! - click_on_signin + click_on_signin("keycloak") expect(response).to have_http_status :found expect(response.location).to match /https:\/\/#{host}.*$/ ## # it should then login the user upon the redirect back from the provider - redirect_from_provider + redirect_from_provider("keycloak") expect(response).to have_http_status :found expect(response.location).to match /\/my\/page/ @@ -147,6 +140,7 @@ end it "maps to the login" do + skip "Mapping is not supported yet" click_on_signin redirect_from_provider @@ -155,36 +149,4 @@ end end end - - context "provider configuration through the settings" do - before do - allow(Setting).to receive(:plugin_openproject_openid_connect).and_return( - "providers" => { - "google" => { - "identifier" => "does not", - "secret" => "matter" - }, - "azure" => { - "identifier" => "IDENTIFIER", - "secret" => "SECRET" - } - } - ) - end - - it "shows no option unless EE", with_ee: false do - get "/login" - expect(response.body).not_to match /Google/i - expect(response.body).not_to match /Azure/i - end - - it "makes providers that have been configured through settings available without requiring a restart" do - get "/login" - expect(response.body).to match /Google/i - expect(response.body).to match /Azure/i - - expect { click_on_signin("google") }.not_to raise_error - expect(response).to have_http_status :found - end - end end diff --git a/modules/openid_connect/spec/seeders/env_data/openid_connect/provider_seeder_spec.rb b/modules/openid_connect/spec/seeders/env_data/openid_connect/provider_seeder_spec.rb new file mode 100644 index 000000000000..96519c500c7b --- /dev/null +++ b/modules/openid_connect/spec/seeders/env_data/openid_connect/provider_seeder_spec.rb @@ -0,0 +1,135 @@ +# 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" + +RSpec.describe EnvData::OpenIDConnect::ProviderSeeder, :settings_reset do + let(:seed_data) { Source::SeedData.new({}) } + + subject(:seeder) { described_class.new(seed_data) } + + before do + reset(OpenProject::OpenIDConnect::CONFIG_KEY, **OpenProject::OpenIDConnect::CONFIG_OPTIONS) + end + + context "when not provided" do + it "does nothing" do + expect { seeder.seed! }.not_to change(OpenIDConnect::Provider, :count) + end + end + + context "when providing seed variables", + with_env: { + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_DISPLAY__NAME: "Keycloak", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_HOST: "keycloak.internal", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_IDENTIFIER: "https://openproject.internal", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_SECRET: "9AWjVC3A4U1HLrZuSP4xiwHfw6zmgECn", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_ISSUER: "https://keycloak.local/realms/master", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_AUTHORIZATION__ENDPOINT: "/realms/master/protocol/openid-connect/auth", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_TOKEN__ENDPOINT: "/realms/master/protocol/openid-connect/token", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_USERINFO__ENDPOINT: "/realms/master/protocol/openid-connect/userinfo", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_END__SESSION__ENDPOINT: "https://keycloak.local/realms/master/protocol/openid-connect/logout", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_JWKS__URI: "https://keycloak.local/realms/master/protocol/openid-connect/certs" + } do + it "uses those variables" do + expect { seeder.seed! }.to change(OpenIDConnect::Provider, :count).by(1) + + provider = OpenIDConnect::Provider.last + expect(provider.slug).to eq "keycloak" + expect(provider.display_name).to eq "Keycloak" + expect(provider.oidc_provider).to eq "custom" + expect(provider.client_id).to eq "https://openproject.internal" + expect(provider.client_secret).to eq "9AWjVC3A4U1HLrZuSP4xiwHfw6zmgECn" + expect(provider.issuer).to eq "https://keycloak.local/realms/master" + expect(provider.authorization_endpoint).to eq "/realms/master/protocol/openid-connect/auth" + expect(provider.token_endpoint).to eq "/realms/master/protocol/openid-connect/token" + expect(provider.userinfo_endpoint).to eq "/realms/master/protocol/openid-connect/userinfo" + expect(provider.end_session_endpoint).to eq "https://keycloak.local/realms/master/protocol/openid-connect/logout" + expect(provider.jwks_uri).to eq "https://keycloak.local/realms/master/protocol/openid-connect/certs" + expect(provider.seeded_from_env?).to be true + end + + context "when provider already exists with that name" do + it "updates the provider" do + provider = OpenIDConnect::Provider.create!(display_name: "Something", slug: "keycloak", creator: User.system) + expect(provider.seeded_from_env?).to be true + + expect { seeder.seed! }.not_to change(OpenIDConnect::Provider, :count) + + provider.reload + + expect(provider.slug).to eq "keycloak" + expect(provider.display_name).to eq "Keycloak" + expect(provider.oidc_provider).to eq "custom" + expect(provider.client_id).to eq "https://openproject.internal" + expect(provider.client_secret).to eq "9AWjVC3A4U1HLrZuSP4xiwHfw6zmgECn" + expect(provider.issuer).to eq "https://keycloak.local/realms/master" + expect(provider.authorization_endpoint).to eq "/realms/master/protocol/openid-connect/auth" + expect(provider.token_endpoint).to eq "/realms/master/protocol/openid-connect/token" + expect(provider.userinfo_endpoint).to eq "/realms/master/protocol/openid-connect/userinfo" + expect(provider.end_session_endpoint).to eq "https://keycloak.local/realms/master/protocol/openid-connect/logout" + expect(provider.jwks_uri).to eq "https://keycloak.local/realms/master/protocol/openid-connect/certs" + expect(provider.seeded_from_env?).to be true + end + end + end + + context "when providing multiple variables", + with_env: { + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_DISPLAY__NAME: "Keycloak", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_HOST: "keycloak.internal", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_IDENTIFIER: "https://openproject.internal", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_SECRET: "9AWjVC3A4U1HLrZuSP4xiwHfw6zmgECn", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_ISSUER: "https://keycloak.local/realms/master", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_AUTHORIZATION__ENDPOINT: "/realms/master/protocol/openid-connect/auth", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_TOKEN__ENDPOINT: "/realms/master/protocol/openid-connect/token", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_USERINFO__ENDPOINT: "/realms/master/protocol/openid-connect/userinfo", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_END__SESSION__ENDPOINT: "https://keycloak.local/realms/master/protocol/openid-connect/logout", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_JWKS__URI: "https://keycloak.local/realms/master/protocol/openid-connect/certs", + + OPENPROJECT_OPENID__CONNECT_KEYCLOAK123_DISPLAY__NAME: "Keycloak 123", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK123_HOST: "keycloak.internal", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK123_IDENTIFIER: "https://openproject.internal", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK123_SECRET: "9AWjVC3A4U1HLrZuSP4xiwHfw6zmgECn", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK123_ISSUER: "https://keycloak.local/realms/master", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK123_AUTHORIZATION__ENDPOINT: "/realms/master/protocol/openid-connect/auth", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK123_TOKEN__ENDPOINT: "/realms/master/protocol/openid-connect/token", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK123_USERINFO__ENDPOINT: "/realms/master/protocol/openid-connect/userinfo", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK123_END__SESSION__ENDPOINT: "https://keycloak.local/realms/master/protocol/openid-connect/logout", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK123_JWKS__URI: "https://keycloak.local/realms/master/protocol/openid-connect/certs" + } do + it "creates both" do + expect { seeder.seed! }.to change(OpenIDConnect::Provider, :count).by(2) + + providers = OpenIDConnect::Provider.pluck(:slug) + expect(providers).to contain_exactly("keycloak", "keycloak123") + end + end +end diff --git a/spec/requests/api/v3/authentication_spec.rb b/spec/requests/api/v3/authentication_spec.rb index 045177706b6a..30171e45691c 100644 --- a/spec/requests/api/v3/authentication_spec.rb +++ b/spec/requests/api/v3/authentication_spec.rb @@ -366,28 +366,7 @@ def set_basic_auth_header(user, password) end end - describe( - "OIDC", - :webmock, - with_settings: { - plugin_openproject_openid_connect: { - "providers" => { - "keycloak" => { - "display_name" => "Keycloak", - "identifier" => "https://openproject.local", - "secret" => "9AWjVC3A4U1HLrZuSP4xiwHfw6zmgECn", - "host" => "keycloak.local", - "issuer" => "https://keycloak.local/realms/master", - "authorization_endpoint" => "/realms/master/protocol/openid-connect/auth", - "token_endpoint" => "/realms/master/protocol/openid-connect/token", - "userinfo_endpoint" => "/realms/master/protocol/openid-connect/userinfo", - "end_session_endpoint" => "https://keycloak.local/realms/master/protocol/openid-connect/logout", - "jwks_uri" => "https://keycloak.local/realms/master/protocol/openid-connect/certs" - } - } - } - } - ) do + describe("OIDC", :webmock) do let(:rsa_signed_access_token_without_aud) do "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI5N0FteXZvUzhCRkZSZm01ODVHUGdBMTZHMUgyVjIyRWR4eHVBWVV1b0trIn0.eyJleHAiOjE3MjEyODM0MzAsImlhdCI6MTcyMTI4MzM3MCwianRpIjoiYzUyNmI0MzUtOTkxZi00NzRhLWFkMWItYzM3MTQ1NmQxZmQwIiwiaXNzIjoiaHR0cHM6Ly9rZXljbG9hay5sb2NhbC9yZWFsbXMvbWFzdGVyIiwiYXVkIjpbIm1hc3Rlci1yZWFsbSIsImFjY291bnQiXSwic3ViIjoiYjcwZTJmYmYtZWE2OC00MjBjLWE3YTUtMGEyODdjYjY4OWM2IiwidHlwIjoiQmVhcmVyIiwiYXpwIjoiaHR0cHM6Ly9vcGVucHJvamVjdC5sb2NhbCIsInNlc3Npb25fc3RhdGUiOiJlYjIzNTI0MC0wYjQ3LTQ4ZmEtOGIzZS1mM2IzMTBkMzUyZTMiLCJhY3IiOiIxIiwiYWxsb3dlZC1vcmlnaW5zIjpbImh0dHBzOi8vb3BlbnByb2plY3QubG9jYWwiXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImNyZWF0ZS1yZWFsbSIsImRlZmF1bHQtcm9sZXMtbWFzdGVyIiwib2ZmbGluZV9hY2Nlc3MiLCJhZG1pbiIsInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsibWFzdGVyLXJlYWxtIjp7InJvbGVzIjpbInZpZXctcmVhbG0iLCJ2aWV3LWlkZW50aXR5LXByb3ZpZGVycyIsIm1hbmFnZS1pZGVudGl0eS1wcm92aWRlcnMiLCJpbXBlcnNvbmF0aW9uIiwiY3JlYXRlLWNsaWVudCIsIm1hbmFnZS11c2VycyIsInF1ZXJ5LXJlYWxtcyIsInZpZXctYXV0aG9yaXphdGlvbiIsInF1ZXJ5LWNsaWVudHMiLCJxdWVyeS11c2VycyIsIm1hbmFnZS1ldmVudHMiLCJtYW5hZ2UtcmVhbG0iLCJ2aWV3LWV2ZW50cyIsInZpZXctdXNlcnMiLCJ2aWV3LWNsaWVudHMiLCJtYW5hZ2UtYXV0aG9yaXphdGlvbiIsIm1hbmFnZS1jbGllbnRzIiwicXVlcnktZ3JvdXBzIl19LCJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6ImVtYWlsIHByb2ZpbGUiLCJzaWQiOiJlYjIzNTI0MC0wYjQ3LTQ4ZmEtOGIzZS1mM2IzMTBkMzUyZTMiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsInByZWZlcnJlZF91c2VybmFtZSI6ImFkbWluIn0.cLgbN9kygRwthUx0R0FazPfIUeEUVnw4HnDgN-Hsnm9oXVr6MqmfTRKEI-6n62dlnVKsdadF_tWf3jp26d6neLj1zlR-vojwaHm8A08S9m6IeMr9e0CGiYVHjrJtEeTgq6P9cJJfe7uuhSSvlG3ltFPDxaAe14Dz3BjhLO3iaCRkWfAZjKmnW-IMzzzHfGH-7of7qCAlF5ObEax38mf1Q0OmsPA4_5po-FFtw7H7FfDjsr6EXgtdwloDePkk2XIHs2XsIo0YugVHC9GqCWgBA8MBvCirFivqM53paZMnjhpQH-xgTpYGWlw3WNbG2Rny2GoEwIxdYOUO2amDQ_zkrQ" end @@ -400,6 +379,7 @@ def set_basic_auth_header(user, password) let(:keys_request_stub) { nil } before do + create(:oidc_provider, slug: "keycloak") create(:user, identity_url: "keycloak:#{token_sub}") keys_request_stub diff --git a/spec/requests/openid_google_provider_callback_spec.rb b/spec/requests/openid_google_provider_callback_spec.rb index 5f86a8ac23dc..d648991cc819 100644 --- a/spec/requests/openid_google_provider_callback_spec.rb +++ b/spec/requests/openid_google_provider_callback_spec.rb @@ -33,6 +33,7 @@ include Rack::Test::Methods include API::V3::Utilities::PathHelper + let(:provider) { create(:oidc_provider_google, limit_self_registration: false) } let(:auth_hash) do { "state" => "623960f1b4f1020941387659f022497f536ad3c95fa7e53b0f03bdbf36debd59f76320801ea2723df520", "code" => "4/0AVHEtk6HMPLH08Uw8OVoSaAbd2oTi7Z6wOlBsMQ99Yj3qgKhhyKAxUQBvQ2MZuRzvueOgQ", @@ -41,7 +42,7 @@ "prompt" => "none" } end let(:uri) do - uri = URI("/auth/google/callback") + uri = URI("/auth/#{provider.slug}/callback") uri.query = URI.encode_www_form([["code", auth_hash["code"]], ["state", auth_hash["state"]], ["scope", auth_hash["scope"]], @@ -51,14 +52,7 @@ end before do - # enable self registration for Google which is limited by default - expect(OpenProject::Plugins::AuthPlugin) - .to receive(:limit_self_registration?) - .with(provider: "google") - .twice - .and_return false - - stub_request(:post, "https://accounts.google.com/o/oauth2/token").to_return( + stub_request(:post, "https://oauth2.googleapis.com/token").to_return( status: 200, body: { "access_token" => @@ -72,7 +66,7 @@ }.to_json, headers: { "content-type" => "application/json; charset=utf-8" } ) - stub_request(:get, Addressable::Template.new("https://www.googleapis.com/oauth2/v3/userinfo{?alt}")).to_return( + stub_request(:get, "https://openidconnect.googleapis.com/v1/userinfo").to_return( status: 200, body: { "sub" => "107403511037921355307", "name" => "Firstname Lastname", @@ -86,16 +80,14 @@ ) allow_any_instance_of(OmniAuth::Strategies::OpenIDConnect).to receive(:session) { - { "omniauth.state" => auth_hash["state"] } + { + "omniauth.state" => auth_hash["state"] + } } end - it "redirects user without errors", :webmock, with_settings: { - plugin_openproject_openid_connect: { - "providers" => { "google" => { "identifier" => "identifier", "secret" => "secret" } } - } - } do - response = get uri.to_s + it "redirects user without errors", :webmock do + response = get(uri.to_s) expect(response).to have_http_status(:found) expect(response.location).to eq("http://#{Setting.host_name}/two_factor_authentication/request") end diff --git a/spec/services/users/register_user_service_spec.rb b/spec/services/users/register_user_service_spec.rb index 16151dc25534..9d98aa9db5c8 100644 --- a/spec/services/users/register_user_service_spec.rb +++ b/spec/services/users/register_user_service_spec.rb @@ -100,13 +100,9 @@ def with_all_registration_options(except: []) context "with limit_self_registration enabled and self_registration disabled", with_settings: { self_registration: 0, - plugin_openproject_openid_connect: { - providers: { - azure: { identifier: "foo", secret: "bar", limit_self_registration: true } - } - } } do it "fails to activate due to disabled self registration" do + create(:oidc_provider, slug: 'azure') call = instance.call expect(call).not_to be_success expect(call.result).to eq user @@ -117,13 +113,9 @@ def with_all_registration_options(except: []) context "with limit_self_registration enabled and self_registration manual", with_settings: { self_registration: 2, - plugin_openproject_openid_connect: { - providers: { - azure: { identifier: "foo", secret: "bar", limit_self_registration: true } - } - } } do it "registers the user, but does not activate it" do + create(:oidc_provider, slug: 'azure') call = instance.call expect(call).to be_success expect(call.result).to eq user @@ -136,13 +128,10 @@ def with_all_registration_options(except: []) context "with limit_self_registration enabled and self_registration email", with_settings: { self_registration: 1, - plugin_openproject_openid_connect: { - providers: { - azure: { identifier: "foo", secret: "bar", limit_self_registration: true } - } - } } do it "registers the user, but does not activate it" do + create(:oidc_provider, slug: 'azure') + call = instance.call expect(call).to be_success expect(call.result).to eq user @@ -155,13 +144,9 @@ def with_all_registration_options(except: []) context "with limit_self_registration enabled and self_registration automatic", with_settings: { self_registration: 3, - plugin_openproject_openid_connect: { - providers: { - azure: { identifier: "foo", secret: "bar", limit_self_registration: true } - } - } } do it "activates the user" do + create(:oidc_provider, slug: 'azure') call = instance.call expect(call).to be_success expect(call.result).to eq user diff --git a/spec/support/shared/with_settings.rb b/spec/support/shared/with_settings.rb index d4286554d096..6ab6307e4e06 100644 --- a/spec/support/shared/with_settings.rb +++ b/spec/support/shared/with_settings.rb @@ -42,8 +42,8 @@ def aggregate_mocked_settings(example, settings) shared_let(:definitions_before) { Settings::Definition.all.dup } def reset(setting, **definitions) + setting = setting.to_sym definitions = Settings::Definition::DEFINITIONS[setting] if definitions.empty? - Settings::Definition.all.delete(setting) Settings::Definition.add(setting, **definitions) end From 2e22ab258b63090d832fbb8019ed5e22fb608b23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 14 Oct 2024 16:29:19 +0200 Subject: [PATCH 060/115] Remove has_actions --- .../app/components/openid_connect/providers/table_component.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/openid_connect/app/components/openid_connect/providers/table_component.rb b/modules/openid_connect/app/components/openid_connect/providers/table_component.rb index c132e72cd3e1..9f23c1b974dc 100644 --- a/modules/openid_connect/app/components/openid_connect/providers/table_component.rb +++ b/modules/openid_connect/app/components/openid_connect/providers/table_component.rb @@ -16,7 +16,7 @@ def header_args(column) end def has_actions? - true + false end def sortable? From db8283017c6c02912b15a1147fb052f2d0302f9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 14 Oct 2024 16:36:21 +0200 Subject: [PATCH 061/115] Extend form validation --- .../openid_connect/providers/base_contract.rb | 25 +++++++++++++++++++ .../app/models/openid_connect/provider.rb | 4 +-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb b/modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb index e4efc55cdd0b..03f909a67bc0 100644 --- a/modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb +++ b/modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb @@ -46,6 +46,31 @@ def self.model validates :metadata_url, url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, if: -> { model.metadata_url_changed? } + + attribute :authorization_endpoint + validates :authorization_endpoint, + url: { allow_blank: false, allow_nil: false, schemes: %w[http https] }, + if: -> { model.authorization_endpoint_changed? } + + attribute :userinfo_endpoint + validates :userinfo_endpoint, + url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, + if: -> { model.userinfo_endpoint_changed? } + + attribute :token_endpoint + validates :token_endpoint, + url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, + if: -> { model.token_endpoint_changed? } + + attribute :end_session_endpoint + validates :end_session_endpoint, + url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, + if: -> { model.end_session_endpoint_changed? } + + attribute :jwks_uri + validates :jwks_uri, + url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, + if: -> { model.jwks_uri_changed? } end end end diff --git a/modules/openid_connect/app/models/openid_connect/provider.rb b/modules/openid_connect/app/models/openid_connect/provider.rb index 9d5f5c4c8678..dd446950ecb6 100644 --- a/modules/openid_connect/app/models/openid_connect/provider.rb +++ b/modules/openid_connect/app/models/openid_connect/provider.rb @@ -1,6 +1,6 @@ module OpenIDConnect class Provider < AuthProvider - OIDC_PROVIDERS = ["google", "microsoft_entra", "custom"].freeze + OIDC_PROVIDERS = %w[google microsoft_entra custom].freeze DISCOVERABLE_ATTRIBUTES_ALL = %i[authorization_endpoint userinfo_endpoint token_endpoint @@ -8,7 +8,7 @@ class Provider < AuthProvider jwks_uri issuer].freeze DISCOVERABLE_ATTRIBUTES_OPTIONAL = %i[end_session_endpoint].freeze - DISCOVERABLE_ATTRIBUTES_MANDATORY = DISCOVERABLE_ATTRIBUTES_ALL - %i[end_session_endpoint] + DISCOVERABLE_ATTRIBUTES_MANDATORY = DISCOVERABLE_ATTRIBUTES_ALL - %i[end_session_endpoint jwks_uri] store_attribute :options, :oidc_provider, :string store_attribute :options, :metadata_url, :string From ef21f6ee5fbe1b51c4aa0d72b572e9fb5bcd6131 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 14 Oct 2024 16:43:00 +0200 Subject: [PATCH 062/115] Extract metadata_url getter --- .../providers/update_service.rb | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/modules/openid_connect/app/services/openid_connect/providers/update_service.rb b/modules/openid_connect/app/services/openid_connect/providers/update_service.rb index 2ca3dacb9320..b3f0fe25e002 100644 --- a/modules/openid_connect/app/services/openid_connect/providers/update_service.rb +++ b/modules/openid_connect/app/services/openid_connect/providers/update_service.rb @@ -42,14 +42,7 @@ class AttributesContract < Dry::Validation::Contract def after_validate(_params, call) model = call.result - metadata_url = case model.oidc_provider - when "google" - "https://accounts.google.com/.well-known/openid-configuration" - when "microsoft_entra" - "https://login.microsoftonline.com/#{model.tenant || 'common'}/v2.0/.well-known/openid-configuration" - else - model.metadata_url - end + metadata_url = get_metadata_url(model) return call if metadata_url.blank? case (response = OpenProject.httpx.get(metadata_url)) @@ -78,11 +71,26 @@ def after_validate(_params, call) call.errors.add(:metadata_url, :response_is_not_successful, status: response.status) call.success = false in {error: error} - raise error + call.message = error.message + call.success = false + else + call.message = I18n.t(:notice_internal_server_error) + call.success = false end call end + + def get_metadata_url(model) + case model.oidc_provider + when "google" + "https://accounts.google.com/.well-known/openid-configuration" + when "microsoft_entra" + "https://login.microsoftonline.com/#{model.tenant || 'common'}/v2.0/.well-known/openid-configuration" + else + model.metadata_url + end + end end end end From e857bcdaf56c6f0d5e834849ef731cce569ea8db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 14 Oct 2024 20:20:32 +0200 Subject: [PATCH 063/115] Add mapping --- config/locales/en.yml | 1 + .../app/forms/saml/providers/mapping_form.rb | 10 ++-- modules/auth_saml/config/locales/en.yml | 2 +- .../providers/attribute_mapping_form.rb | 48 +++++++++++++++++++ .../providers/view_component.html.erb | 25 +++++++++- .../openid_connect/providers/base_contract.rb | 4 ++ .../app/models/openid_connect/provider.rb | 7 +++ modules/openid_connect/config/locales/de.yml | 27 ----------- modules/openid_connect/config/locales/en.yml | 11 ++++- 9 files changed, 100 insertions(+), 35 deletions(-) create mode 100644 modules/openid_connect/app/components/openid_connect/providers/attribute_mapping_form.rb delete mode 100644 modules/openid_connect/config/locales/de.yml diff --git a/config/locales/en.yml b/config/locales/en.yml index 9ac82e45a972..68e065616e0b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2374,6 +2374,7 @@ en: label_custom_favicon: "Custom favicon" label_custom_touch_icon: "Custom touch icon" label_logout: "Sign out" + label_mapping_for: "Mapping for: %{attribute}" label_main_menu: "Side Menu" label_manage: "Manage" label_manage_groups: "Manage groups" diff --git a/modules/auth_saml/app/forms/saml/providers/mapping_form.rb b/modules/auth_saml/app/forms/saml/providers/mapping_form.rb index fa1be96a4795..518a680a774e 100644 --- a/modules/auth_saml/app/forms/saml/providers/mapping_form.rb +++ b/modules/auth_saml/app/forms/saml/providers/mapping_form.rb @@ -32,7 +32,7 @@ class MappingForm < BaseForm form do |f| f.text_area( name: :mapping_login, - label: I18n.t("saml.providers.label_mapping_for", attribute: User.human_attribute_name(:login)), + label: I18n.t("label_mapping_for", attribute: User.human_attribute_name(:login)), caption: I18n.t("saml.instructions.mapping_login"), required: true, disabled: provider.seeded_from_env?, @@ -41,7 +41,7 @@ class MappingForm < BaseForm ) f.text_area( name: :mapping_mail, - label: I18n.t("saml.providers.label_mapping_for", attribute: User.human_attribute_name(:mail)), + label: I18n.t("label_mapping_for", attribute: User.human_attribute_name(:mail)), caption: I18n.t("saml.instructions.mapping_mail"), required: true, disabled: provider.seeded_from_env?, @@ -50,7 +50,7 @@ class MappingForm < BaseForm ) f.text_area( name: :mapping_firstname, - label: I18n.t("saml.providers.label_mapping_for", attribute: User.human_attribute_name(:first_name)), + label: I18n.t("label_mapping_for", attribute: User.human_attribute_name(:first_name)), caption: I18n.t("saml.instructions.mapping_firstname"), required: true, disabled: provider.seeded_from_env?, @@ -59,7 +59,7 @@ class MappingForm < BaseForm ) f.text_area( name: :mapping_lastname, - label: I18n.t("saml.providers.label_mapping_for", attribute: User.human_attribute_name(:last_name)), + label: I18n.t("label_mapping_for", attribute: User.human_attribute_name(:last_name)), caption: I18n.t("saml.instructions.mapping_lastname"), required: true, disabled: provider.seeded_from_env?, @@ -68,7 +68,7 @@ class MappingForm < BaseForm ) f.text_field( name: :mapping_uid, - label: I18n.t("saml.providers.label_mapping_for", attribute: I18n.t("saml.providers.label_uid")), + label: I18n.t("label_mapping_for", attribute: I18n.t("saml.providers.label_uid")), caption: I18n.t("saml.instructions.mapping_uid"), disabled: provider.seeded_from_env?, rows: 8, diff --git a/modules/auth_saml/config/locales/en.yml b/modules/auth_saml/config/locales/en.yml index 62be3e7ff8f1..00d78f16416d 100644 --- a/modules/auth_saml/config/locales/en.yml +++ b/modules/auth_saml/config/locales/en.yml @@ -54,7 +54,7 @@ en: label_edit: Edit SAML identity provider %{name} label_uid: Internal user id label_mapping: Mapping - label_mapping_for: "Mapping for: %{attribute}" + label_requested_attribute_for: "Requested attribute for: %{attribute}" no_results_table: No SAML identity providers have been defined yet. plural: SAML identity providers diff --git a/modules/openid_connect/app/components/openid_connect/providers/attribute_mapping_form.rb b/modules/openid_connect/app/components/openid_connect/providers/attribute_mapping_form.rb new file mode 100644 index 000000000000..2c9717ba860b --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/attribute_mapping_form.rb @@ -0,0 +1,48 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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. +#++ + +module OpenIDConnect + module Providers + class AttributeMappingForm < BaseForm + form do |f| + OpenIDConnect::Provider::MAPPABLE_ATTRIBUTES.each do |attr| + attribute_name = User.human_attribute_name(attr == :email ? :mail : attr) + + f.text_field( + name: "#{attr}_mapping", + label: I18n.t("label_mapping_for", attribute: attribute_name), + caption: I18n.t("openid_connect.instructions.mapping_#{attr}"), + disabled: provider.seeded_from_env?, + required: false, + input_width: :medium + ) + end + end + end + end +end diff --git a/modules/openid_connect/app/components/openid_connect/providers/view_component.html.erb b/modules/openid_connect/app/components/openid_connect/providers/view_component.html.erb index fd1337d1bd29..2db8e067f9f0 100644 --- a/modules/openid_connect/app/components/openid_connect/providers/view_component.html.erb +++ b/modules/openid_connect/app/components/openid_connect/providers/view_component.html.erb @@ -159,7 +159,7 @@ provider, form_class: ns::MetadataDetailsForm, edit_state:, - next_edit_state: :client_details, + next_edit_state: :attribute_mapping, edit_mode:, banner: provider.metadata_configured? ? t("openid_connect.providers.section_texts.configuration_metadata") : nil, banner_scheme: :default, @@ -178,6 +178,29 @@ end end + component.with_row(scheme: :default) do + if edit_state == :attribute_mapping + render(ns::Sections::FormComponent.new( + provider, + form_class: ns::AttributeMappingForm, + edit_state:, + next_edit_state: :client_details, + edit_mode:, + heading: nil + )) + else + render(ns::Sections::ShowComponent.new( + provider, + target_state: :attribute_mapping, + view_mode:, + heading: t("openid_connect.providers.label_attribute_mapping"), + description: t("openid_connect.providers.section_texts.attribute_mapping"), + label: provider.metadata_configured? ? t(:label_completed) : nil, + label_scheme: provider.metadata_configured? ? :success : :secondary + )) + end + end + component.with_row(scheme: :default) do if edit_state == :client_details render(ns::Sections::FormComponent.new( diff --git a/modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb b/modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb index 03f909a67bc0..afafe6c84dad 100644 --- a/modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb +++ b/modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb @@ -71,6 +71,10 @@ def self.model validates :jwks_uri, url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, if: -> { model.jwks_uri_changed? } + + OpenIDConnect::Provider::MAPPABLE_ATTRIBUTES.each do |attr| + attribute :"mapping_#{attr}" + end end end end diff --git a/modules/openid_connect/app/models/openid_connect/provider.rb b/modules/openid_connect/app/models/openid_connect/provider.rb index dd446950ecb6..9d6ac05bd26f 100644 --- a/modules/openid_connect/app/models/openid_connect/provider.rb +++ b/modules/openid_connect/app/models/openid_connect/provider.rb @@ -10,11 +10,18 @@ class Provider < AuthProvider DISCOVERABLE_ATTRIBUTES_OPTIONAL = %i[end_session_endpoint].freeze DISCOVERABLE_ATTRIBUTES_MANDATORY = DISCOVERABLE_ATTRIBUTES_ALL - %i[end_session_endpoint jwks_uri] + MAPPABLE_ATTRIBUTES = %i[login email first_name last_name].freeze + store_attribute :options, :oidc_provider, :string store_attribute :options, :metadata_url, :string + DISCOVERABLE_ATTRIBUTES_ALL.each do |attribute| store_attribute :options, attribute, :string end + MAPPABLE_ATTRIBUTES.each do |attribute| + store_attribute :options, "#{attribute}_mapping", :string + end + store_attribute :options, :client_id, :string store_attribute :options, :client_secret, :string store_attribute :options, :tenant, :string diff --git a/modules/openid_connect/config/locales/de.yml b/modules/openid_connect/config/locales/de.yml deleted file mode 100644 index b7a306417071..000000000000 --- a/modules/openid_connect/config/locales/de.yml +++ /dev/null @@ -1,27 +0,0 @@ -de: - logout_warning: > - Sie wurden ausgeloggt. Inhalte von Formularen, die sie abschicken möchten, - können verloren gehen. Bitte [loggen Sie sich wieder ein]. - activemodel: - attributes: - openid_connect/provider: - name: Name - display_name: Angezeigter Name - identifier: Identifier - secret: Secret - scope: Scope - limit_self_registration: Selbstregistrierung einschränken - openid_connect: - menu_title: OpenID-Provider - providers: - label_add_new: Einen neuen OpenID-Provider hinzufügen - label_edit: OpenID-Provider %{name} bearbeiten - no_results_table: Es wurden noch keine OpenID-Provider konfiguriert. - plural: OpenID-Provider - singular: OpenID-Provider - upsale: - description: Use existing OpenID credentials with OpenProject for easier access and interoperability with a range of other providers. - setting_instructions: - limit_self_registration: > - Wenn diese Option aktiv ist, können sich neue Nutzer mit diesem OpenID-Provider nur registrieren, - wenn die Selbstregistrierungs-Einstellung es erlaubt. diff --git a/modules/openid_connect/config/locales/en.yml b/modules/openid_connect/config/locales/en.yml index 7bede7ed3395..83965c96b137 100644 --- a/modules/openid_connect/config/locales/en.yml +++ b/modules/openid_connect/config/locales/en.yml @@ -21,7 +21,6 @@ en: end_session_endpoint: End session endpoint jwks_uri: JWKS URI issuer: Issuer - limit_self_registration: Limit self-registration tenant: Tenant metadata_url: Metadata URL activerecord: @@ -46,6 +45,14 @@ en: limit_self_registration: If enabled, users can only register using this provider if configuration on the prvoder's end allows it. display_name: Then name of the provider. This will be displayed as the login button and in the list of providers. tenant: Please replace the default tenant with your own if applicable. See this. + mapping_login: > + Provide a custom mapping in the userinfo response to be used for the login attribute. + mapping_email: > + Provide a custom mapping in the userinfo response to be used for the email attribute. + mapping_first_name: > + Provide a custom mapping in the userinfo response to be used for the first name. + mapping_last_name: > + Provide a custom mapping in the userinfo response to be used for the last name. settings: metadata_none: I don't have this information metadata_url: I have a discovery endpoint URL @@ -68,6 +75,7 @@ en: label_advanced_configuration: Advanced configuration label_configuration_details: Metadata label_client_details: Client details + label_attribute_mapping: Attribute mapping client_details_description: Configuration details of OpenProject as an OIDC client no_results_table: No providers have been defined yet. plural: OpenID providers @@ -80,6 +88,7 @@ en: configuration_metadata: The information has been pre-filled using the supplied discovery endpoint. In most cases, they do not require editing. configuration: Configuration details of the OpenID Connect provider display_name: The display name visible to users. + attribute_mapping: Configure the mapping of attributes between OpenProject and the OpenID Connect provider. setting_instructions: limit_self_registration: > If enabled users can only register using this provider if the self registration setting allows for it. From 19eab3bfd5ad180a799f171ed3c15ca7eaff8d76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 14 Oct 2024 20:45:00 +0200 Subject: [PATCH 064/115] Same button layout as saml --- .../providers/sections/form_component.rb | 8 ++-- .../sections/metadata_form_component.rb | 3 -- .../providers/view_component.html.erb | 37 ++++++++++--------- 3 files changed, 25 insertions(+), 23 deletions(-) diff --git a/modules/openid_connect/app/components/openid_connect/providers/sections/form_component.rb b/modules/openid_connect/app/components/openid_connect/providers/sections/form_component.rb index e1da600bae54..28ac2a6880af 100644 --- a/modules/openid_connect/app/components/openid_connect/providers/sections/form_component.rb +++ b/modules/openid_connect/app/components/openid_connect/providers/sections/form_component.rb @@ -68,10 +68,12 @@ def form_method end def button_label - if edit_mode - I18n.t(:button_continue) + return I18n.t(:button_save) unless edit_mode + + if next_edit_state.nil? + I18n.t(:button_finish_setup) else - I18n.t(:button_update) + I18n.t(:button_continue) end end end diff --git a/modules/openid_connect/app/components/openid_connect/providers/sections/metadata_form_component.rb b/modules/openid_connect/app/components/openid_connect/providers/sections/metadata_form_component.rb index 74e4ed983db8..b8ea2f88a11e 100644 --- a/modules/openid_connect/app/components/openid_connect/providers/sections/metadata_form_component.rb +++ b/modules/openid_connect/app/components/openid_connect/providers/sections/metadata_form_component.rb @@ -30,8 +30,5 @@ # module OpenIDConnect::Providers::Sections class MetadataFormComponent < FormComponent - def initialize(provider, edit_mode: nil) - super(provider, edit_state: :metadata, edit_mode:, form_class: nil, heading: nil) - end end end diff --git a/modules/openid_connect/app/components/openid_connect/providers/view_component.html.erb b/modules/openid_connect/app/components/openid_connect/providers/view_component.html.erb index 2db8e067f9f0..e6edcae42890 100644 --- a/modules/openid_connect/app/components/openid_connect/providers/view_component.html.erb +++ b/modules/openid_connect/app/components/openid_connect/providers/view_component.html.erb @@ -134,7 +134,11 @@ if edit_state == :metadata render(ns::Sections::MetadataFormComponent.new( provider, + form_class: nil, + heading: nil, + edit_state:, edit_mode:, + next_edit_state: :metadata_details )) else render(ns::Sections::ShowComponent.new( @@ -159,7 +163,7 @@ provider, form_class: ns::MetadataDetailsForm, edit_state:, - next_edit_state: :attribute_mapping, + next_edit_state: :client_details, edit_mode:, banner: provider.metadata_configured? ? t("openid_connect.providers.section_texts.configuration_metadata") : nil, banner_scheme: :default, @@ -179,47 +183,46 @@ end component.with_row(scheme: :default) do - if edit_state == :attribute_mapping + if edit_state == :client_details render(ns::Sections::FormComponent.new( provider, - form_class: ns::AttributeMappingForm, + form_class: ns::ClientDetailsForm, edit_state:, - next_edit_state: :client_details, + next_edit_state: :attribute_mapping, edit_mode:, heading: nil )) else render(ns::Sections::ShowComponent.new( provider, - target_state: :attribute_mapping, + target_state: :client_details, view_mode:, - heading: t("openid_connect.providers.label_attribute_mapping"), - description: t("openid_connect.providers.section_texts.attribute_mapping"), - label: provider.metadata_configured? ? t(:label_completed) : nil, - label_scheme: provider.metadata_configured? ? :success : :secondary + heading: t("openid_connect.providers.label_client_details"), + description: t("openid_connect.providers.client_details_description"), + label: provider.advanced_details_configured? ? t(:label_completed) : t(:label_not_configured), + label_scheme: provider.advanced_details_configured? ? :success : :secondary )) end end component.with_row(scheme: :default) do - if edit_state == :client_details + if edit_state == :attribute_mapping render(ns::Sections::FormComponent.new( provider, - form_class: ns::ClientDetailsForm, + form_class: ns::AttributeMappingForm, edit_state:, - next_edit_state: :mapping, edit_mode:, heading: nil )) else render(ns::Sections::ShowComponent.new( provider, - target_state: :client_details, + target_state: :attribute_mapping, view_mode:, - heading: t("openid_connect.providers.label_client_details"), - description: t("openid_connect.providers.client_details_description"), - label: provider.advanced_details_configured? ? t(:label_completed) : t(:label_not_configured), - label_scheme: provider.advanced_details_configured? ? :success : :secondary + heading: t("openid_connect.providers.label_attribute_mapping"), + description: t("openid_connect.providers.section_texts.attribute_mapping"), + label: provider.metadata_configured? ? t(:label_completed) : nil, + label_scheme: provider.metadata_configured? ? :success : :secondary )) end end From 77b8da473059bfe1183b85fd5ebc910f2ad7c5d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 14 Oct 2024 20:53:46 +0200 Subject: [PATCH 065/115] Don't walk through sections without edit_mode --- .../providers/sections/form_component.rb | 12 ++++++++++-- .../sections/metadata_form_component.html.erb | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/modules/openid_connect/app/components/openid_connect/providers/sections/form_component.rb b/modules/openid_connect/app/components/openid_connect/providers/sections/form_component.rb index 28ac2a6880af..99a83b3788a0 100644 --- a/modules/openid_connect/app/components/openid_connect/providers/sections/form_component.rb +++ b/modules/openid_connect/app/components/openid_connect/providers/sections/form_component.rb @@ -53,9 +53,17 @@ def initialize(provider, def url if provider.new_record? - openid_connect_providers_path(edit_state:, edit_mode:, next_edit_state:) + openid_connect_providers_path(**form_url_params) else - openid_connect_provider_path(edit_state:, edit_mode:, next_edit_state:, id: provider.id) + openid_connect_provider_path(provider, **form_url_params) + end + end + + def form_url_params + if edit_mode + { edit_state:, edit_mode:, next_edit_state: } + else + { edit_state: } end end diff --git a/modules/openid_connect/app/components/openid_connect/providers/sections/metadata_form_component.html.erb b/modules/openid_connect/app/components/openid_connect/providers/sections/metadata_form_component.html.erb index efe3f6dd7712..c1d86a4b6b56 100644 --- a/modules/openid_connect/app/components/openid_connect/providers/sections/metadata_form_component.html.erb +++ b/modules/openid_connect/app/components/openid_connect/providers/sections/metadata_form_component.html.erb @@ -2,7 +2,7 @@ primer_form_with( model: provider, id: "openid-connect-providers-edit-form", - url: openid_connect_provider_path(provider, edit_mode:, next_edit_state: :metadata_details), + url:, data: { controller: "show-when-value-selected", turbo: true, From a1dd5278f93b9b21d529ab8537b1b0c76cd6cf61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 14 Oct 2024 21:04:36 +0200 Subject: [PATCH 066/115] Hash builder with mapping --- .../providers/attribute_mapping_form.rb | 2 +- .../app/models/openid_connect/provider.rb | 29 +----- .../openid_connect/provider/hash_builder.rb | 42 ++++++++ .../openid_connect/configuration_mapper.rb | 95 +++++++++++++++++++ 4 files changed, 141 insertions(+), 27 deletions(-) create mode 100644 modules/openid_connect/app/models/openid_connect/provider/hash_builder.rb create mode 100644 modules/openid_connect/app/services/openid_connect/configuration_mapper.rb diff --git a/modules/openid_connect/app/components/openid_connect/providers/attribute_mapping_form.rb b/modules/openid_connect/app/components/openid_connect/providers/attribute_mapping_form.rb index 2c9717ba860b..1ed8eb37544a 100644 --- a/modules/openid_connect/app/components/openid_connect/providers/attribute_mapping_form.rb +++ b/modules/openid_connect/app/components/openid_connect/providers/attribute_mapping_form.rb @@ -34,7 +34,7 @@ class AttributeMappingForm < BaseForm attribute_name = User.human_attribute_name(attr == :email ? :mail : attr) f.text_field( - name: "#{attr}_mapping", + name: :"mapping_#{attr}", label: I18n.t("label_mapping_for", attribute: attribute_name), caption: I18n.t("openid_connect.instructions.mapping_#{attr}"), disabled: provider.seeded_from_env?, diff --git a/modules/openid_connect/app/models/openid_connect/provider.rb b/modules/openid_connect/app/models/openid_connect/provider.rb index 9d6ac05bd26f..692eb6816828 100644 --- a/modules/openid_connect/app/models/openid_connect/provider.rb +++ b/modules/openid_connect/app/models/openid_connect/provider.rb @@ -1,5 +1,7 @@ module OpenIDConnect class Provider < AuthProvider + include HashBuilder + OIDC_PROVIDERS = %w[google microsoft_entra custom].freeze DISCOVERABLE_ATTRIBUTES_ALL = %i[authorization_endpoint userinfo_endpoint @@ -19,7 +21,7 @@ class Provider < AuthProvider store_attribute :options, attribute, :string end MAPPABLE_ATTRIBUTES.each do |attribute| - store_attribute :options, "#{attribute}_mapping", :string + store_attribute :options, "mapping_#{attribute}", :string end store_attribute :options, :client_id, :string @@ -50,32 +52,7 @@ def configured? basic_details_configured? && advanced_details_configured? && metadata_configured? end - def to_h - h = { - name: slug, - icon:, - display_name:, - userinfo_endpoint:, - authorization_endpoint:, - jwks_uri:, - host: URI(issuer).host, - issuer:, - identifier: client_id, - secret: client_secret, - token_endpoint:, - limit_self_registration:, - end_session_endpoint: - }.to_h - if oidc_provider == "google" - h.merge!({ - client_auth_method: :not_basic, - send_nonce: false, # use state instead of nonce - state: lambda { SecureRandom.hex(42) } - }) - end - h - end def icon case oidc_provider diff --git a/modules/openid_connect/app/models/openid_connect/provider/hash_builder.rb b/modules/openid_connect/app/models/openid_connect/provider/hash_builder.rb new file mode 100644 index 000000000000..417d22eac481 --- /dev/null +++ b/modules/openid_connect/app/models/openid_connect/provider/hash_builder.rb @@ -0,0 +1,42 @@ +module OpenIDConnect + module Provider::HashBuilder + def attribute_map + OpenIDConnect::Provider::MAPPABLE_ATTRIBUTES + .index_with { |attr| public_send(:"mapping_#{attr}") } + .compact_blank + end + + def to_h # rubocop:disable Metrics/AbcSize + h = { + name: slug, + icon:, + display_name:, + userinfo_endpoint:, + authorization_endpoint:, + jwks_uri:, + host: URI(issuer).host, + issuer:, + identifier: client_id, + secret: client_secret, + token_endpoint:, + limit_self_registration:, + end_session_endpoint:, + attribute_map: + } + .merge(attribute_map) + .compact_blank + + if oidc_provider == "google" + h.merge!( + { + client_auth_method: :not_basic, + send_nonce: false, # use state instead of nonce + state: lambda { SecureRandom.hex(42) } + } + ) + end + + h + end + end +end diff --git a/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb b/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb new file mode 100644 index 000000000000..748745546989 --- /dev/null +++ b/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb @@ -0,0 +1,95 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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. +#++ + +module Saml + class ConfigurationMapper + attr_reader :configuration + + def initialize(configuration) + @configuration = configuration + end + + def call! + options = mapped_options(configuration.deep_stringify_keys) + { + "options" => options, + "slug" => options.delete("name"), + "display_name" => options.delete("display_name") || "SAML" + } + end + + private + + def mapped_options(options) + options["idp_sso_service_url"] ||= options.delete("idp_sso_target_url") + options["idp_slo_service_url"] ||= options.delete("idp_slo_target_url") + options["sp_entity_id"] ||= options.delete("issuer") + + build_idp_cert(options) + extract_security_options(options) + extract_mapping(options) + + options.compact + end + + def extract_mapping(options) + return unless options["attribute_statements"] + + options["mapping_login"] = extract_mapping_attribute(options, "login") + options["mapping_mail"] = extract_mapping_attribute(options, "email") + options["mapping_firstname"] = extract_mapping_attribute(options, "first_name") + options["mapping_lastname"] = extract_mapping_attribute(options, "last_name") + options["mapping_uid"] = extract_mapping_attribute(options, "uid") + end + + def extract_mapping_attribute(options, key) + value = options["attribute_statements"][key] + + if value.present? + Array(value).join("\n") + end + end + + def build_idp_cert(options) + if options["idp_cert"] + options["idp_cert"] = OneLogin::RubySaml::Utils.format_cert(options["idp_cert"]) + elsif options["idp_cert_multi"] + options["idp_cert"] = options["idp_cert_multi"]["signing"] + .map { |cert| OneLogin::RubySaml::Utils.format_cert(cert) } + .join("\n") + end + end + + def extract_security_options(options) + return unless options["security"] + + options.merge! options["security"].slice("authn_requests_signed", "want_assertions_signed", + "want_assertions_encrypted", "digest_method", "signature_method") + end + end +end From 4e897fb17e9b96ad5af4d964dd5e72dc33cc7b2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 14 Oct 2024 21:14:43 +0200 Subject: [PATCH 067/115] Allow custom icon --- .../openid_connect/providers/metadata_details_form.rb | 9 +++++++++ .../openid_connect/app/models/openid_connect/provider.rb | 5 ++--- modules/openid_connect/config/locales/en.yml | 1 + 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/modules/openid_connect/app/components/openid_connect/providers/metadata_details_form.rb b/modules/openid_connect/app/components/openid_connect/providers/metadata_details_form.rb index 49702255ae70..ec28d450750e 100644 --- a/modules/openid_connect/app/components/openid_connect/providers/metadata_details_form.rb +++ b/modules/openid_connect/app/components/openid_connect/providers/metadata_details_form.rb @@ -39,6 +39,15 @@ class MetadataDetailsForm < BaseForm input_width: :large ) end + + f.text_field( + name: :icon, + label: I18n.t("activemodel.attributes.openid_connect/provider.icon"), + caption: I18n.t("saml.instructions.icon"), + disabled: provider.seeded_from_env?, + required: false, + input_width: :large + ) end end end diff --git a/modules/openid_connect/app/models/openid_connect/provider.rb b/modules/openid_connect/app/models/openid_connect/provider.rb index 692eb6816828..17e50f95aea9 100644 --- a/modules/openid_connect/app/models/openid_connect/provider.rb +++ b/modules/openid_connect/app/models/openid_connect/provider.rb @@ -16,6 +16,7 @@ class Provider < AuthProvider store_attribute :options, :oidc_provider, :string store_attribute :options, :metadata_url, :string + store_attribute :options, :icon, :string DISCOVERABLE_ATTRIBUTES_ALL.each do |attribute| store_attribute :options, attribute, :string @@ -52,8 +53,6 @@ def configured? basic_details_configured? && advanced_details_configured? && metadata_configured? end - - def icon case oidc_provider when "google" @@ -61,7 +60,7 @@ def icon when "microsoft_entra" "openid_connect/auth_provider-azure.png" else - "openid_connect/auth_provider-custom.png" + super.presence || "openid_connect/auth_provider-custom.png" end end end diff --git a/modules/openid_connect/config/locales/en.yml b/modules/openid_connect/config/locales/en.yml index 83965c96b137..652bdf07f3ea 100644 --- a/modules/openid_connect/config/locales/en.yml +++ b/modules/openid_connect/config/locales/en.yml @@ -23,6 +23,7 @@ en: issuer: Issuer tenant: Tenant metadata_url: Metadata URL + icon: Custom icon activerecord: errors: models: From 980dd1cc615382c42f13f657305429231636a743 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 14 Oct 2024 21:22:53 +0200 Subject: [PATCH 068/115] Move migration to engine --- .../migrate/20240829140616_migrate_oidc_settings_to_providers.rb | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {db => modules/openid_connect/db}/migrate/20240829140616_migrate_oidc_settings_to_providers.rb (100%) diff --git a/db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb b/modules/openid_connect/db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb similarity index 100% rename from db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb rename to modules/openid_connect/db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb From 3b4ccfdf9af16c5d017e5ebb4c8fbade58534d35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 14 Oct 2024 21:23:40 +0200 Subject: [PATCH 069/115] Format migration text --- .../20240829140616_migrate_oidc_settings_to_providers.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/modules/openid_connect/db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb b/modules/openid_connect/db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb index edbaf98b5c7d..1396d0da9fec 100644 --- a/modules/openid_connect/db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb +++ b/modules/openid_connect/db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb @@ -60,10 +60,13 @@ def migrate_provider!(name, configuration) raise <<~ERROR Failed to create or update OpenID provider #{name} from previous settings format. The error message was: #{call.message} + Please check the logs for more information and open a bug report in our community: https://www.openproject.org/docs/development/report-a-bug/ + If you would like to skip migrating the OpenID provider setting and discard them instead, you can use our documentation to unset any previous OpenID provider settings: + https://www.openproject.org/docs/system-admin-guide/authentication/openid-providers/ ERROR end From 533312bfd06af6e8aa049df817168c5774816fbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 14 Oct 2024 21:28:45 +0200 Subject: [PATCH 070/115] Configuration mapper --- .../openid_connect/configuration_mapper.rb | 58 ++++++------------- .../services/openid_connect/sync_service.rb | 21 ++----- 2 files changed, 22 insertions(+), 57 deletions(-) diff --git a/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb b/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb index 748745546989..59ad4826a0ce 100644 --- a/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb +++ b/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module Saml +module OpenIDConnect class ConfigurationMapper attr_reader :configuration @@ -36,60 +36,38 @@ def initialize(configuration) def call! options = mapped_options(configuration.deep_stringify_keys) + { - "options" => options, "slug" => options.delete("name"), - "display_name" => options.delete("display_name") || "SAML" + "display_name" => options.delete("display_name") || "OpenID Connect", + "oidc_provider" => "custom", + "client_id" => options["identifier"], + "client_secret" => options["secret"], + "issuer" => options["issuer"], + "authorization_endpoint" => options["authorization_endpoint"], + "token_endpoint" => options["token_endpoint"], + "userinfo_endpoint" => options["userinfo_endpoint"], + "end_session_endpoint" => options["end_session_endpoint"], + "jwks_uri" => options["jwks_uri"] } end private def mapped_options(options) - options["idp_sso_service_url"] ||= options.delete("idp_sso_target_url") - options["idp_slo_service_url"] ||= options.delete("idp_slo_target_url") - options["sp_entity_id"] ||= options.delete("issuer") - - build_idp_cert(options) - extract_security_options(options) extract_mapping(options) options.compact end def extract_mapping(options) - return unless options["attribute_statements"] - - options["mapping_login"] = extract_mapping_attribute(options, "login") - options["mapping_mail"] = extract_mapping_attribute(options, "email") - options["mapping_firstname"] = extract_mapping_attribute(options, "first_name") - options["mapping_lastname"] = extract_mapping_attribute(options, "last_name") - options["mapping_uid"] = extract_mapping_attribute(options, "uid") - end - - def extract_mapping_attribute(options, key) - value = options["attribute_statements"][key] - - if value.present? - Array(value).join("\n") - end - end - - def build_idp_cert(options) - if options["idp_cert"] - options["idp_cert"] = OneLogin::RubySaml::Utils.format_cert(options["idp_cert"]) - elsif options["idp_cert_multi"] - options["idp_cert"] = options["idp_cert_multi"]["signing"] - .map { |cert| OneLogin::RubySaml::Utils.format_cert(cert) } - .join("\n") - end - end - - def extract_security_options(options) - return unless options["security"] + return unless options["attribute_map"] - options.merge! options["security"].slice("authn_requests_signed", "want_assertions_signed", - "want_assertions_encrypted", "digest_method", "signature_method") + options["mapping_login"] = options["attribute_map"]["login"] + options["mapping_mail"] = options["attribute_map"]["email"] + options["mapping_firstname"] = options["attribute_map"]["first_name"] + options["mapping_lastname"] = options["attribute_map"]["last_name"] + options["mapping_uid"] = options["attribute_map"]["uid"] end end end diff --git a/modules/openid_connect/app/services/openid_connect/sync_service.rb b/modules/openid_connect/app/services/openid_connect/sync_service.rb index 368bdbc5ef0b..73e5d5e8bcfa 100644 --- a/modules/openid_connect/app/services/openid_connect/sync_service.rb +++ b/modules/openid_connect/app/services/openid_connect/sync_service.rb @@ -32,34 +32,21 @@ class SyncService def initialize(name, configuration) @name = name - @provider_attributes = - { - "slug" => name, - "oidc_provider" => "custom", - "display_name" => configuration["display_name"], - "client_id" => configuration["identifier"], - "client_secret" => configuration["secret"], - "issuer" => configuration["issuer"], - "authorization_endpoint" => configuration["authorization_endpoint"], - "token_endpoint" => configuration["token_endpoint"], - "userinfo_endpoint" => configuration["userinfo_endpoint"], - "end_session_endpoint" => configuration["end_session_endpoint"], - "jwks_uri" => configuration["jwks_uri"] - } + @configuration = ::OpenIDConnect::ConfigurationMapper.new(configuration).call! end - def call + def call # rubocop:disable Metrics/AbcSize provider = ::OpenIDConnect::Provider.find_by(slug: name) if provider ::OpenIDConnect::Providers::UpdateService .new(model: provider, user: User.system) - .call(@provider_attributes) + .call(@configuration) .on_success { |call| call.message = "Successfully updated OpenID provider #{name}." } .on_failure { |call| call.message = "Failed to update OpenID provider: #{call.message}" } else ::OpenIDConnect::Providers::CreateService .new(user: User.system) - .call(@provider_attributes) + .call(@configuration) .on_success { |call| call.message = "Successfully created OpenID provider #{name}." } .on_failure { |call| call.message = "Failed to create OpenID provider: #{call.message}" } end From 30fae2b090dc3d015283d0c4ded34960bf3ec673 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 14 Oct 2024 21:38:06 +0200 Subject: [PATCH 071/115] Rename seeder --- .../env_data/openid_connect/provider_seeder.rb | 4 ++-- .../lib/open_project/openid_connect.rb | 9 --------- .../lib/open_project/openid_connect/engine.rb | 16 +++++++++------- .../openid_connect/provider_seeder_spec.rb | 7 ++++++- 4 files changed, 17 insertions(+), 19 deletions(-) diff --git a/modules/openid_connect/app/seeders/env_data/openid_connect/provider_seeder.rb b/modules/openid_connect/app/seeders/env_data/openid_connect/provider_seeder.rb index 207ba426c74b..861dd96206ad 100644 --- a/modules/openid_connect/app/seeders/env_data/openid_connect/provider_seeder.rb +++ b/modules/openid_connect/app/seeders/env_data/openid_connect/provider_seeder.rb @@ -31,7 +31,7 @@ module EnvData module OpenIDConnect class ProviderSeeder < Seeder def seed_data! - Setting.seed_openid_connect_provider.each do |name, configuration| + Setting.seed_oidc_provider.each do |name, configuration| print_status " ↳ Creating or Updating OpenID provider #{name}" do call = ::OpenIDConnect::SyncService.new(name, configuration).call @@ -45,7 +45,7 @@ def seed_data! end def applicable? - Setting.seed_openid_connect_provider.present? + Setting.seed_oidc_provider.present? end end end diff --git a/modules/openid_connect/lib/open_project/openid_connect.rb b/modules/openid_connect/lib/open_project/openid_connect.rb index d82e29f7e2d5..859f3a34a01d 100644 --- a/modules/openid_connect/lib/open_project/openid_connect.rb +++ b/modules/openid_connect/lib/open_project/openid_connect.rb @@ -4,15 +4,6 @@ module OpenProject module OpenIDConnect - CONFIG_KEY = :seed_openid_connect_provider - CONFIG_OPTIONS = { - description: "Provide a OpenIDConnect provider and sync its settings through ENV", - env_alias: "OPENPROJECT_OPENID__CONNECT", - default: {}, - writable: false, - format: :hash - }.freeze - def providers # update base redirect URI in case settings changed ::OmniAuth::OpenIDConnect::Providers.configure( diff --git a/modules/openid_connect/lib/open_project/openid_connect/engine.rb b/modules/openid_connect/lib/open_project/openid_connect/engine.rb index a9914ec6c6ac..c7e9cff0235e 100644 --- a/modules/openid_connect/lib/open_project/openid_connect/engine.rb +++ b/modules/openid_connect/lib/open_project/openid_connect/engine.rb @@ -60,13 +60,6 @@ class Engine < ::Rails::Engine end end - initializer "openid_connect.configure" do - ::Settings::Definition.add( - OpenProject::OpenIDConnect::CONFIG_KEY, - **OpenProject::OpenIDConnect::CONFIG_OPTIONS - ) - end - initializer "openid_connect.form_post_method" do # If response_mode 'form_post' is chosen, # the IP sends a POST to the callback. Only if @@ -81,6 +74,15 @@ class Engine < ::Rails::Engine end end + initializer "openid_connect.configuration" do + ::Settings::Definition.add :seed_oidc_provider, + description: "Provide a OIDC provider and sync its settings through ENV", + env_alias: "OPENPROJECT_OIDC", + writable: false, + default: {}, + format: :hash + end + config.to_prepare do ::OpenProject::OpenIDConnect::Hooks::Hook end diff --git a/modules/openid_connect/spec/seeders/env_data/openid_connect/provider_seeder_spec.rb b/modules/openid_connect/spec/seeders/env_data/openid_connect/provider_seeder_spec.rb index 96519c500c7b..0c75dabb2a09 100644 --- a/modules/openid_connect/spec/seeders/env_data/openid_connect/provider_seeder_spec.rb +++ b/modules/openid_connect/spec/seeders/env_data/openid_connect/provider_seeder_spec.rb @@ -36,7 +36,12 @@ subject(:seeder) { described_class.new(seed_data) } before do - reset(OpenProject::OpenIDConnect::CONFIG_KEY, **OpenProject::OpenIDConnect::CONFIG_OPTIONS) + reset(:seed_oidc_provider, + description: "Provide a OIDC provider and sync its settings through ENV", + env_alias: "OPENPROJECT_OPENID__CONNECT", + writable: false, + default: {}, + format: :hash) end context "when not provided" do From 7247a232ae36beaefefaaad1f9713be123368645 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Mon, 14 Oct 2024 21:41:17 +0200 Subject: [PATCH 072/115] Remove form_post security impact option This is probably no longer in place, but should be solved differently by now (by session mapping similar to saml RelayState) --- .../lib/open_project/openid_connect/engine.rb | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/modules/openid_connect/lib/open_project/openid_connect/engine.rb b/modules/openid_connect/lib/open_project/openid_connect/engine.rb index c7e9cff0235e..bde77b42ab7f 100644 --- a/modules/openid_connect/lib/open_project/openid_connect/engine.rb +++ b/modules/openid_connect/lib/open_project/openid_connect/engine.rb @@ -60,24 +60,10 @@ class Engine < ::Rails::Engine end end - initializer "openid_connect.form_post_method" do - # If response_mode 'form_post' is chosen, - # the IP sends a POST to the callback. Only if - # the sameSite flag is not set on the session cookie, is the cookie send along with the request. - if OpenProject::Configuration[OpenProject::OpenIDConnect::CONFIG_KEY]&.any? do |_, v| - v["response_mode"]&.to_s == "form_post" - end - SecureHeaders::Configuration.default.cookies[:samesite][:lax] = false - # Need to reload the secure_headers config to - # avoid having set defaults (e.g. https) when changing the cookie values - load Rails.root.join("config/initializers/secure_headers.rb") - end - end - initializer "openid_connect.configuration" do ::Settings::Definition.add :seed_oidc_provider, description: "Provide a OIDC provider and sync its settings through ENV", - env_alias: "OPENPROJECT_OIDC", + env_alias: "OPENPROJECT_OPENID__CONNECT", writable: false, default: {}, format: :hash From a72c0cb3d166ca42ee3a78c5df6cbd899887c9bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 15 Oct 2024 09:06:33 +0200 Subject: [PATCH 073/115] Allow seeding with path config as shown in docs --- .../app/models/openid_connect/provider.rb | 2 +- .../openid_connect/provider_seeder.rb | 2 +- .../openid_connect/configuration_mapper.rb | 27 ++++++++++++++++--- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/modules/openid_connect/app/models/openid_connect/provider.rb b/modules/openid_connect/app/models/openid_connect/provider.rb index 17e50f95aea9..06905634e297 100644 --- a/modules/openid_connect/app/models/openid_connect/provider.rb +++ b/modules/openid_connect/app/models/openid_connect/provider.rb @@ -32,7 +32,7 @@ class Provider < AuthProvider def self.slug_fragment = "oidc" def seeded_from_env? - (Setting.seed_openid_connect_provider || {}).key?(slug) + (Setting.seed_oidc_provider || {}).key?(slug) end def basic_details_configured? diff --git a/modules/openid_connect/app/seeders/env_data/openid_connect/provider_seeder.rb b/modules/openid_connect/app/seeders/env_data/openid_connect/provider_seeder.rb index 861dd96206ad..732ff3bc3e48 100644 --- a/modules/openid_connect/app/seeders/env_data/openid_connect/provider_seeder.rb +++ b/modules/openid_connect/app/seeders/env_data/openid_connect/provider_seeder.rb @@ -33,7 +33,7 @@ class ProviderSeeder < Seeder def seed_data! Setting.seed_oidc_provider.each do |name, configuration| print_status " ↳ Creating or Updating OpenID provider #{name}" do - call = ::OpenIDConnect::SyncService.new(name, configuration).call + call = ::OpenIDConnect::SyncService.new(name, configuration.merge(name:)).call if call.success print_status " - #{call.message}" diff --git a/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb b/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb index 59ad4826a0ce..bb56b5ec63b2 100644 --- a/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb +++ b/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb @@ -44,9 +44,9 @@ def call! "client_id" => options["identifier"], "client_secret" => options["secret"], "issuer" => options["issuer"], - "authorization_endpoint" => options["authorization_endpoint"], - "token_endpoint" => options["token_endpoint"], - "userinfo_endpoint" => options["userinfo_endpoint"], + "authorization_endpoint" => extract_url(options, "authorization_endpoint"), + "token_endpoint" => extract_url(options, "token_endpoint"), + "userinfo_endpoint" => extract_url(options, "userinfo_endpoint"), "end_session_endpoint" => options["end_session_endpoint"], "jwks_uri" => options["jwks_uri"] } @@ -54,6 +54,27 @@ def call! private + def extract_url(options, key) + value = options[key] + return value if value.start_with?('http') + unless value.start_with?("/") + raise ArgumentError.new("Provided #{key} '#{value}' needs to be http(s) URL or path starting with a slash.") + end + + URI + .join(base_url(options), value) + .to_s + end + + def base_url(options) + raise ArgumentError.new("Missing host in configuration") unless options["host"] + URI::Generic.build( + host: options["host"], + port: options["port"], + scheme: options["scheme"] || "https" + ).to_s + end + def mapped_options(options) extract_mapping(options) From 15490f22f15a9f91300aec98b187dc8452ee6d23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 15 Oct 2024 09:23:33 +0200 Subject: [PATCH 074/115] Reintroduce sections, add one for mapping --- config/locales/en.yml | 1 + .../providers/view_component.html.erb | 28 +++++++++++-------- .../app/models/openid_connect/provider.rb | 6 ++++ modules/openid_connect/config/locales/en.yml | 2 +- 4 files changed, 24 insertions(+), 13 deletions(-) diff --git a/config/locales/en.yml b/config/locales/en.yml index 68e065616e0b..67c686f7d8f7 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2141,6 +2141,7 @@ en: label_api_doc: "API documentation" label_backup: "Backup" label_backup_code: "Backup code" + label_basic_details: "Basic details" label_between: "between" label_blocked_by: "blocked by" label_blocks: "blocks" diff --git a/modules/openid_connect/app/components/openid_connect/providers/view_component.html.erb b/modules/openid_connect/app/components/openid_connect/providers/view_component.html.erb index e6edcae42890..3291270d85ff 100644 --- a/modules/openid_connect/app/components/openid_connect/providers/view_component.html.erb +++ b/modules/openid_connect/app/components/openid_connect/providers/view_component.html.erb @@ -9,6 +9,10 @@ <% end %> <%= render(border_box_container) do |component| + component.with_header(color: :muted) do + render(Primer::Beta::Text.new(font_weight: :bold)) { I18n.t(:label_basic_details) } + end + case provider.oidc_provider when 'google' component.with_row(scheme: :default) do @@ -99,10 +103,6 @@ end end else # custom -# component.with_header(color: :muted) do -# render(Primer::Beta::Text.new(font_weight: :bold)) { I18n.t('openid_connect.providers.label_basic_details') } -# end - component.with_row(scheme: :default) do basic_details_component = if edit_state == :name @@ -126,9 +126,9 @@ render(basic_details_component) end -# component.with_row(scheme: :neutral, color: :muted) do -# render(Primer::Beta::Text.new(font_weight: :bold)) { I18n.t('openid_connect.providers.label_automatic_configuration') } -# end + component.with_row(scheme: :neutral, color: :muted) do + render(Primer::Beta::Text.new(font_weight: :bold)) { I18n.t('openid_connect.providers.label_automatic_configuration') } + end component.with_row(scheme: :default) do if edit_state == :metadata @@ -153,9 +153,9 @@ end end -# component.with_row(scheme: :neutral, color: :muted) do -# render(Primer::Beta::Text.new(font_weight: :bold)) { I18n.t('openid_connect.providers.label_advanced_configuration') } -# end + component.with_row(scheme: :neutral, color: :muted) do + render(Primer::Beta::Text.new(font_weight: :bold)) { I18n.t('openid_connect.providers.label_advanced_configuration') } + end component.with_row(scheme: :default) do if edit_state == :metadata_details @@ -205,6 +205,10 @@ end end + component.with_row(scheme: :neutral, color: :muted) do + render(Primer::Beta::Text.new(font_weight: :bold)) { I18n.t('openid_connect.providers.label_optional_configuration') } + end + component.with_row(scheme: :default) do if edit_state == :attribute_mapping render(ns::Sections::FormComponent.new( @@ -221,8 +225,8 @@ view_mode:, heading: t("openid_connect.providers.label_attribute_mapping"), description: t("openid_connect.providers.section_texts.attribute_mapping"), - label: provider.metadata_configured? ? t(:label_completed) : nil, - label_scheme: provider.metadata_configured? ? :success : :secondary + label: provider.mapping_configured? ? t(:label_completed) : nil, + label_scheme: provider.mapping_configured? ? :success : :secondary )) end end diff --git a/modules/openid_connect/app/models/openid_connect/provider.rb b/modules/openid_connect/app/models/openid_connect/provider.rb index 06905634e297..d6faaed5d0c1 100644 --- a/modules/openid_connect/app/models/openid_connect/provider.rb +++ b/modules/openid_connect/app/models/openid_connect/provider.rb @@ -49,6 +49,12 @@ def metadata_configured? end end + def mapping_configured? + MAPPABLE_ATTRIBUTES.any? do |mandatory_attribute| + public_send(:"mapping_#{mandatory_attribute}").present? + end + end + def configured? basic_details_configured? && advanced_details_configured? && metadata_configured? end diff --git a/modules/openid_connect/config/locales/en.yml b/modules/openid_connect/config/locales/en.yml index 652bdf07f3ea..4a7bfe91daba 100644 --- a/modules/openid_connect/config/locales/en.yml +++ b/modules/openid_connect/config/locales/en.yml @@ -70,9 +70,9 @@ en: label_edit: Edit OpenID provider %{name} label_empty_title: No OIDC providers configured yet. label_empty_description: Add a provider to see them here. - label_basic_details: Basic details label_metadata: OpenID Connect Discovery Endpoint label_automatic_configuration: Automatic configuration + label_optional_configuration: Optional configuration label_advanced_configuration: Advanced configuration label_configuration_details: Metadata label_client_details: Client details From 7ecd5df4721be6e306c2612dab2d5ad207965413 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 15 Oct 2024 10:56:10 +0200 Subject: [PATCH 075/115] Fix inflection --- .rubocop.yml | 1 + config/initializers/zeitwerk.rb | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.rubocop.yml b/.rubocop.yml index 821c3902e520..d45f3c613d7b 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -248,6 +248,7 @@ RSpec/DescribeMethod: # to match the exact file name RSpec/SpecFilePathFormat: CustomTransform: + OpenIDConnect: openid_connect OAuthClients: oauth_clients IgnoreMethods: true diff --git a/config/initializers/zeitwerk.rb b/config/initializers/zeitwerk.rb index f346f581530a..5681349c4f81 100644 --- a/config/initializers/zeitwerk.rb +++ b/config/initializers/zeitwerk.rb @@ -34,6 +34,8 @@ "OAuth#{default_inflect($1, abspath)}" when /\A(.*)_oauth\z/ "#{default_inflect($1, abspath)}OAuth" + when "openid_connect" + "OpenIDConnect" when "oauth" "OAuth" when /\Aclamav_(.*)\z/ From 7c873ebbf201501dc158796b937cdb95783461d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 15 Oct 2024 14:50:28 +0200 Subject: [PATCH 076/115] Extend spec to use absolute URL --- .../openid_connect/provider_seeder_spec.rb | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/modules/openid_connect/spec/seeders/env_data/openid_connect/provider_seeder_spec.rb b/modules/openid_connect/spec/seeders/env_data/openid_connect/provider_seeder_spec.rb index 0c75dabb2a09..fb79e6291380 100644 --- a/modules/openid_connect/spec/seeders/env_data/openid_connect/provider_seeder_spec.rb +++ b/modules/openid_connect/spec/seeders/env_data/openid_connect/provider_seeder_spec.rb @@ -53,7 +53,7 @@ context "when providing seed variables", with_env: { OPENPROJECT_OPENID__CONNECT_KEYCLOAK_DISPLAY__NAME: "Keycloak", - OPENPROJECT_OPENID__CONNECT_KEYCLOAK_HOST: "keycloak.internal", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_HOST: "keycloak.local", OPENPROJECT_OPENID__CONNECT_KEYCLOAK_IDENTIFIER: "https://openproject.internal", OPENPROJECT_OPENID__CONNECT_KEYCLOAK_SECRET: "9AWjVC3A4U1HLrZuSP4xiwHfw6zmgECn", OPENPROJECT_OPENID__CONNECT_KEYCLOAK_ISSUER: "https://keycloak.local/realms/master", @@ -73,9 +73,9 @@ expect(provider.client_id).to eq "https://openproject.internal" expect(provider.client_secret).to eq "9AWjVC3A4U1HLrZuSP4xiwHfw6zmgECn" expect(provider.issuer).to eq "https://keycloak.local/realms/master" - expect(provider.authorization_endpoint).to eq "/realms/master/protocol/openid-connect/auth" - expect(provider.token_endpoint).to eq "/realms/master/protocol/openid-connect/token" - expect(provider.userinfo_endpoint).to eq "/realms/master/protocol/openid-connect/userinfo" + expect(provider.authorization_endpoint).to eq "https://keycloak.local/realms/master/protocol/openid-connect/auth" + expect(provider.token_endpoint).to eq "https://keycloak.local/realms/master/protocol/openid-connect/token" + expect(provider.userinfo_endpoint).to eq "https://keycloak.local/realms/master/protocol/openid-connect/userinfo" expect(provider.end_session_endpoint).to eq "https://keycloak.local/realms/master/protocol/openid-connect/logout" expect(provider.jwks_uri).to eq "https://keycloak.local/realms/master/protocol/openid-connect/certs" expect(provider.seeded_from_env?).to be true @@ -96,9 +96,9 @@ expect(provider.client_id).to eq "https://openproject.internal" expect(provider.client_secret).to eq "9AWjVC3A4U1HLrZuSP4xiwHfw6zmgECn" expect(provider.issuer).to eq "https://keycloak.local/realms/master" - expect(provider.authorization_endpoint).to eq "/realms/master/protocol/openid-connect/auth" - expect(provider.token_endpoint).to eq "/realms/master/protocol/openid-connect/token" - expect(provider.userinfo_endpoint).to eq "/realms/master/protocol/openid-connect/userinfo" + expect(provider.authorization_endpoint).to eq "https://keycloak.local/realms/master/protocol/openid-connect/auth" + expect(provider.token_endpoint).to eq "https://keycloak.local/realms/master/protocol/openid-connect/token" + expect(provider.userinfo_endpoint).to eq "https://keycloak.local/realms/master/protocol/openid-connect/userinfo" expect(provider.end_session_endpoint).to eq "https://keycloak.local/realms/master/protocol/openid-connect/logout" expect(provider.jwks_uri).to eq "https://keycloak.local/realms/master/protocol/openid-connect/certs" expect(provider.seeded_from_env?).to be true @@ -109,7 +109,7 @@ context "when providing multiple variables", with_env: { OPENPROJECT_OPENID__CONNECT_KEYCLOAK_DISPLAY__NAME: "Keycloak", - OPENPROJECT_OPENID__CONNECT_KEYCLOAK_HOST: "keycloak.internal", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK_HOST: "keycloak.local", OPENPROJECT_OPENID__CONNECT_KEYCLOAK_IDENTIFIER: "https://openproject.internal", OPENPROJECT_OPENID__CONNECT_KEYCLOAK_SECRET: "9AWjVC3A4U1HLrZuSP4xiwHfw6zmgECn", OPENPROJECT_OPENID__CONNECT_KEYCLOAK_ISSUER: "https://keycloak.local/realms/master", @@ -120,7 +120,7 @@ OPENPROJECT_OPENID__CONNECT_KEYCLOAK_JWKS__URI: "https://keycloak.local/realms/master/protocol/openid-connect/certs", OPENPROJECT_OPENID__CONNECT_KEYCLOAK123_DISPLAY__NAME: "Keycloak 123", - OPENPROJECT_OPENID__CONNECT_KEYCLOAK123_HOST: "keycloak.internal", + OPENPROJECT_OPENID__CONNECT_KEYCLOAK123_HOST: "keycloak.local", OPENPROJECT_OPENID__CONNECT_KEYCLOAK123_IDENTIFIER: "https://openproject.internal", OPENPROJECT_OPENID__CONNECT_KEYCLOAK123_SECRET: "9AWjVC3A4U1HLrZuSP4xiwHfw6zmgECn", OPENPROJECT_OPENID__CONNECT_KEYCLOAK123_ISSUER: "https://keycloak.local/realms/master", From 6fe5456dc9723e23d0f2f80e56e238d9402e45f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 15 Oct 2024 19:09:26 +0200 Subject: [PATCH 077/115] Feature spec --- .../providers/view_component.html.erb | 2 +- .../app/models/openid_connect/provider.rb | 2 + .../providers/update_service.rb | 3 +- modules/openid_connect/config/locales/en.yml | 4 +- .../administration/oidc_custom_crud_spec.rb | 156 ++++++++++ .../spec/fixtures/keycloak_localhost.json | 290 ++++++++++++++++++ 6 files changed, 454 insertions(+), 3 deletions(-) create mode 100644 modules/openid_connect/spec/features/administration/oidc_custom_crud_spec.rb create mode 100644 modules/openid_connect/spec/fixtures/keycloak_localhost.json diff --git a/modules/openid_connect/app/components/openid_connect/providers/view_component.html.erb b/modules/openid_connect/app/components/openid_connect/providers/view_component.html.erb index 3291270d85ff..2c6e794ee0a2 100644 --- a/modules/openid_connect/app/components/openid_connect/providers/view_component.html.erb +++ b/modules/openid_connect/app/components/openid_connect/providers/view_component.html.erb @@ -165,7 +165,7 @@ edit_state:, next_edit_state: :client_details, edit_mode:, - banner: provider.metadata_configured? ? t("openid_connect.providers.section_texts.configuration_metadata") : nil, + banner: provider.metadata_url.present? ? t("openid_connect.providers.section_texts.configuration_metadata") : nil, banner_scheme: :default, heading: nil )) diff --git a/modules/openid_connect/app/models/openid_connect/provider.rb b/modules/openid_connect/app/models/openid_connect/provider.rb index d6faaed5d0c1..05e6e1960979 100644 --- a/modules/openid_connect/app/models/openid_connect/provider.rb +++ b/modules/openid_connect/app/models/openid_connect/provider.rb @@ -44,6 +44,8 @@ def advanced_details_configured? end def metadata_configured? + return false unless metadata_url.present? + DISCOVERABLE_ATTRIBUTES_MANDATORY.all? do |mandatory_attribute| public_send(mandatory_attribute).present? end diff --git a/modules/openid_connect/app/services/openid_connect/providers/update_service.rb b/modules/openid_connect/app/services/openid_connect/providers/update_service.rb index b3f0fe25e002..689f73bae2fb 100644 --- a/modules/openid_connect/app/services/openid_connect/providers/update_service.rb +++ b/modules/openid_connect/app/services/openid_connect/providers/update_service.rb @@ -50,7 +50,8 @@ def after_validate(_params, call) json = begin response.json rescue HTTPX::Error - call.errors.add(:metadata_url, :response_is_json) + binding.pry + call.errors.add(:metadata_url, :response_is_not_json) call.success = false end result = AttributesContract.new.call(json) diff --git a/modules/openid_connect/config/locales/en.yml b/modules/openid_connect/config/locales/en.yml index 4a7bfe91daba..5ab34f68ef52 100644 --- a/modules/openid_connect/config/locales/en.yml +++ b/modules/openid_connect/config/locales/en.yml @@ -66,9 +66,11 @@ en: name: Microsoft Entra custom: name: Custom + upsale: + description: Connect OpenProject to an OpenID connect identity provider label_add_new: Add a new OpenID provider label_edit: Edit OpenID provider %{name} - label_empty_title: No OIDC providers configured yet. + label_empty_title: No OpenID providers configured yet. label_empty_description: Add a provider to see them here. label_metadata: OpenID Connect Discovery Endpoint label_automatic_configuration: Automatic configuration diff --git a/modules/openid_connect/spec/features/administration/oidc_custom_crud_spec.rb b/modules/openid_connect/spec/features/administration/oidc_custom_crud_spec.rb new file mode 100644 index 000000000000..05b8c23b15aa --- /dev/null +++ b/modules/openid_connect/spec/features/administration/oidc_custom_crud_spec.rb @@ -0,0 +1,156 @@ +#-- 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_module_spec_helper + +RSpec.describe "OIDC administration CRUD", + :js, + :with_cuprite do + shared_let(:user) { create(:admin) } + + before do + login_as(user) + end + + context "with EE", with_ee: %i[sso_auth_providers] do + it "can manage OIDC providers through the UI" do + visit "/admin/openid_connect/providers" + expect(page).to have_text "No OpenID providers configured yet." + click_link_or_button "OpenID provider" + click_link_or_button "Custom" + + fill_in "Display name", with: "My provider" + click_link_or_button "Continue" + + # Skip metadata + click_link_or_button "Continue" + + # Fill out configuration + fill_in "Authorization endpoint", with: "https://example.com/sso" + fill_in "User information endpoint", with: "https://example.com/sso/userinfo" + fill_in "Token endpoint", with: "https://example.com/sso/token" + fill_in "Issuer", with: "foobar" + + click_link_or_button "Continue" + + # Client credentials + fill_in "Client ID", with: "client_id" + fill_in "Client secret", with: "client secret" + + click_link_or_button "Continue" + + # Mapping form + fill_in "Mapping for: Username", with: "login" + fill_in "Mapping for: Email", with: "mail" + fill_in "Mapping for: First name", with: "myName" + fill_in "Mapping for: Last name", with: "myLastName" + click_link_or_button "Finish setup" + + # We're now on the show page + within_test_selector("openid_connect_provider_metadata") do + expect(page).to have_text "Not configured" + end + + # Back to index + visit "/admin/openid_connect/providers" + expect(page).to have_text "My provider" + expect(page).to have_css(".users", text: 0) + expect(page).to have_css(".creator", text: user.name) + + click_link_or_button "My provider" + + provider = OpenIDConnect::Provider.find_by!(display_name: "My provider") + expect(provider.slug).to eq "oidc-my-provider" + expect(provider.authorization_endpoint).to eq "https://example.com/sso" + expect(provider.token_endpoint).to eq "https://example.com/sso/token" + expect(provider.userinfo_endpoint).to eq "https://example.com/sso/userinfo" + + expect(provider.issuer).to eq "foobar" + expect(provider.client_id).to eq "client_id" + expect(provider.client_secret).to eq "client secret" + + expect(provider.mapping_login).to eq "login" + expect(provider.mapping_email).to eq "mail" + expect(provider.mapping_first_name).to eq "myName" + expect(provider.mapping_last_name).to eq "myLastName" + + accept_confirm do + click_link_or_button "Delete" + end + + expect(page).to have_text "No OpenID providers configured yet." + end + + it "can import metadata from URL", :webmock do + visit "/admin/openid_connect/providers" + + click_link_or_button "OpenID provider" + click_link_or_button "Custom" + + fill_in "Display name", with: "My provider" + click_link_or_button "Continue" + + url = "https://example.com/metadata" + metadata = Rails.root.join("modules/openid_connect/spec/fixtures/keycloak_localhost.json").read + stub_request(:get, url).to_return(status: 200, body: metadata, headers: { "Content-Type" => "application/json" }) + + choose "I have a discovery endpoint URL" + fill_in "openid_connect_provider_metadata_url", with: url + + click_link_or_button "Continue" + expect(page).to have_text "The information has been pre-filled using the supplied discovery endpoint." + expect(page).to have_field "Authorization endpoint", with: "http://localhost:8080/realms/test/protocol/openid-connect/auth" + expect(page).to have_field "Token endpoint", with: "http://localhost:8080/realms/test/protocol/openid-connect/token" + expect(page).to have_field "User information endpoint", with: "http://localhost:8080/realms/test/protocol/openid-connect/userinfo" + expect(page).to have_field "End session endpoint", with: "http://localhost:8080/realms/test/protocol/openid-connect/logout" + expect(page).to have_field "Issuer", with: "http://localhost:8080/realms/test" + + expect(WebMock).to have_requested(:get, url) + end + + context "when provider exists already" do + let!(:provider) { create(:oidc_provider, display_name: "My provider") } + + it "shows an error trying to use the same name" do + visit "/admin/openid_connect/providers/new" + fill_in "Display name", with: "My provider" + click_link_or_button "Continue" + + expect(page).to have_text "Display name has already been taken." + end + end + end + + context "without EE", without_ee: %i[sso_auth_providers] do + it "renders the upsale page" do + visit "/admin/openid_connect/providers" + expect(page).to have_text "OpenID providers is an Enterprise add-on" + end + end +end diff --git a/modules/openid_connect/spec/fixtures/keycloak_localhost.json b/modules/openid_connect/spec/fixtures/keycloak_localhost.json new file mode 100644 index 000000000000..af2646381597 --- /dev/null +++ b/modules/openid_connect/spec/fixtures/keycloak_localhost.json @@ -0,0 +1,290 @@ +{ + "issuer": "http://localhost:8080/realms/test", + "authorization_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/auth", + "token_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/token", + "introspection_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/token/introspect", + "userinfo_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/userinfo", + "end_session_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/logout", + "frontchannel_logout_session_supported": true, + "frontchannel_logout_supported": true, + "jwks_uri": "http://localhost:8080/realms/test/protocol/openid-connect/certs", + "check_session_iframe": "http://localhost:8080/realms/test/protocol/openid-connect/login-status-iframe.html", + "grant_types_supported": [ + "authorization_code", + "implicit", + "refresh_token", + "password", + "client_credentials", + "urn:ietf:params:oauth:grant-type:device_code", + "urn:openid:params:grant-type:ciba" + ], + "acr_values_supported": [ + "0", + "1" + ], + "response_types_supported": [ + "code", + "none", + "id_token", + "token", + "id_token token", + "code id_token", + "code token", + "code id_token token" + ], + "subject_types_supported": [ + "public", + "pairwise" + ], + "id_token_signing_alg_values_supported": [ + "PS384", + "ES384", + "RS384", + "HS256", + "HS512", + "ES256", + "RS256", + "HS384", + "ES512", + "PS256", + "PS512", + "RS512" + ], + "id_token_encryption_alg_values_supported": [ + "RSA-OAEP", + "RSA-OAEP-256", + "RSA1_5" + ], + "id_token_encryption_enc_values_supported": [ + "A256GCM", + "A192GCM", + "A128GCM", + "A128CBC-HS256", + "A192CBC-HS384", + "A256CBC-HS512" + ], + "userinfo_signing_alg_values_supported": [ + "PS384", + "ES384", + "RS384", + "HS256", + "HS512", + "ES256", + "RS256", + "HS384", + "ES512", + "PS256", + "PS512", + "RS512", + "none" + ], + "userinfo_encryption_alg_values_supported": [ + "RSA-OAEP", + "RSA-OAEP-256", + "RSA1_5" + ], + "userinfo_encryption_enc_values_supported": [ + "A256GCM", + "A192GCM", + "A128GCM", + "A128CBC-HS256", + "A192CBC-HS384", + "A256CBC-HS512" + ], + "request_object_signing_alg_values_supported": [ + "PS384", + "ES384", + "RS384", + "HS256", + "HS512", + "ES256", + "RS256", + "HS384", + "ES512", + "PS256", + "PS512", + "RS512", + "none" + ], + "request_object_encryption_alg_values_supported": [ + "RSA-OAEP", + "RSA-OAEP-256", + "RSA1_5" + ], + "request_object_encryption_enc_values_supported": [ + "A256GCM", + "A192GCM", + "A128GCM", + "A128CBC-HS256", + "A192CBC-HS384", + "A256CBC-HS512" + ], + "response_modes_supported": [ + "query", + "fragment", + "form_post", + "query.jwt", + "fragment.jwt", + "form_post.jwt", + "jwt" + ], + "registration_endpoint": "http://localhost:8080/realms/test/clients-registrations/openid-connect", + "token_endpoint_auth_methods_supported": [ + "private_key_jwt", + "client_secret_basic", + "client_secret_post", + "tls_client_auth", + "client_secret_jwt" + ], + "token_endpoint_auth_signing_alg_values_supported": [ + "PS384", + "ES384", + "RS384", + "HS256", + "HS512", + "ES256", + "RS256", + "HS384", + "ES512", + "PS256", + "PS512", + "RS512" + ], + "introspection_endpoint_auth_methods_supported": [ + "private_key_jwt", + "client_secret_basic", + "client_secret_post", + "tls_client_auth", + "client_secret_jwt" + ], + "introspection_endpoint_auth_signing_alg_values_supported": [ + "PS384", + "ES384", + "RS384", + "HS256", + "HS512", + "ES256", + "RS256", + "HS384", + "ES512", + "PS256", + "PS512", + "RS512" + ], + "authorization_signing_alg_values_supported": [ + "PS384", + "ES384", + "RS384", + "HS256", + "HS512", + "ES256", + "RS256", + "HS384", + "ES512", + "PS256", + "PS512", + "RS512" + ], + "authorization_encryption_alg_values_supported": [ + "RSA-OAEP", + "RSA-OAEP-256", + "RSA1_5" + ], + "authorization_encryption_enc_values_supported": [ + "A256GCM", + "A192GCM", + "A128GCM", + "A128CBC-HS256", + "A192CBC-HS384", + "A256CBC-HS512" + ], + "claims_supported": [ + "aud", + "sub", + "iss", + "auth_time", + "name", + "given_name", + "family_name", + "preferred_username", + "email", + "acr" + ], + "claim_types_supported": [ + "normal" + ], + "claims_parameter_supported": true, + "scopes_supported": [ + "openid", + "email", + "roles", + "microprofile-jwt", + "web-origins", + "profile", + "offline_access", + "phone", + "address", + "acr" + ], + "request_parameter_supported": true, + "request_uri_parameter_supported": true, + "require_request_uri_registration": true, + "code_challenge_methods_supported": [ + "plain", + "S256" + ], + "tls_client_certificate_bound_access_tokens": true, + "revocation_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/revoke", + "revocation_endpoint_auth_methods_supported": [ + "private_key_jwt", + "client_secret_basic", + "client_secret_post", + "tls_client_auth", + "client_secret_jwt" + ], + "revocation_endpoint_auth_signing_alg_values_supported": [ + "PS384", + "ES384", + "RS384", + "HS256", + "HS512", + "ES256", + "RS256", + "HS384", + "ES512", + "PS256", + "PS512", + "RS512" + ], + "backchannel_logout_supported": true, + "backchannel_logout_session_supported": true, + "device_authorization_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/auth/device", + "backchannel_token_delivery_modes_supported": [ + "poll", + "ping" + ], + "backchannel_authentication_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/ext/ciba/auth", + "backchannel_authentication_request_signing_alg_values_supported": [ + "PS384", + "ES384", + "RS384", + "ES256", + "RS256", + "ES512", + "PS256", + "PS512", + "RS512" + ], + "require_pushed_authorization_requests": false, + "pushed_authorization_request_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/ext/par/request", + "mtls_endpoint_aliases": { + "token_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/token", + "revocation_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/revoke", + "introspection_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/token/introspect", + "device_authorization_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/auth/device", + "registration_endpoint": "http://localhost:8080/realms/test/clients-registrations/openid-connect", + "userinfo_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/userinfo", + "pushed_authorization_request_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/ext/par/request", + "backchannel_authentication_endpoint": "http://localhost:8080/realms/test/protocol/openid-connect/ext/ciba/auth" + } +} From 7b8bbee839453a2748cd864c002951f0c07f180f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 15 Oct 2024 19:14:15 +0200 Subject: [PATCH 078/115] Contract specs --- .../providers/create_contract_spec.rb | 49 ++++++++++++++++++ .../providers/delete_contract_spec.rb | 51 +++++++++++++++++++ .../providers/update_contract_spec.rb | 49 ++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 modules/openid_connect/spec/contracts/openid_connect/providers/create_contract_spec.rb create mode 100644 modules/openid_connect/spec/contracts/openid_connect/providers/delete_contract_spec.rb create mode 100644 modules/openid_connect/spec/contracts/openid_connect/providers/update_contract_spec.rb diff --git a/modules/openid_connect/spec/contracts/openid_connect/providers/create_contract_spec.rb b/modules/openid_connect/spec/contracts/openid_connect/providers/create_contract_spec.rb new file mode 100644 index 000000000000..36dd0cff327c --- /dev/null +++ b/modules/openid_connect/spec/contracts/openid_connect/providers/create_contract_spec.rb @@ -0,0 +1,49 @@ +#-- 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 "contracts/shared/model_contract_shared_context" + +RSpec.describe OpenIDConnect::Providers::CreateContract do + include_context "ModelContract shared context" + + let(:provider) { build(:oidc_provider) } + let(:contract) { described_class.new provider, current_user } + + context "when admin" do + let(:current_user) { build_stubbed(:admin) } + + it_behaves_like "contract is valid" + end + + context "when non-admin" do + let(:current_user) { build_stubbed(:user) } + + it_behaves_like "contract is invalid", base: :error_unauthorized + end +end diff --git a/modules/openid_connect/spec/contracts/openid_connect/providers/delete_contract_spec.rb b/modules/openid_connect/spec/contracts/openid_connect/providers/delete_contract_spec.rb new file mode 100644 index 000000000000..d8d524f49a83 --- /dev/null +++ b/modules/openid_connect/spec/contracts/openid_connect/providers/delete_contract_spec.rb @@ -0,0 +1,51 @@ +# 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 "contracts/shared/model_contract_shared_context" + +RSpec.describe OpenIDConnect::Providers::DeleteContract do + include_context "ModelContract shared context" + + let(:provider) { build_stubbed(:oidc_provider) } + let(:contract) { described_class.new provider, current_user } + + context "when admin" do + let(:current_user) { build_stubbed(:admin) } + + it_behaves_like "contract is valid" + end + + context "when non-admin" do + let(:current_user) { build_stubbed(:user) } + + it_behaves_like "contract is invalid", base: :error_unauthorized + end +end diff --git a/modules/openid_connect/spec/contracts/openid_connect/providers/update_contract_spec.rb b/modules/openid_connect/spec/contracts/openid_connect/providers/update_contract_spec.rb new file mode 100644 index 000000000000..a4b72c653195 --- /dev/null +++ b/modules/openid_connect/spec/contracts/openid_connect/providers/update_contract_spec.rb @@ -0,0 +1,49 @@ +#-- 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 "contracts/shared/model_contract_shared_context" + +RSpec.describe OpenIDConnect::Providers::UpdateContract do + let(:provider) { build_stubbed(:oidc_provider) } + let(:contract) { described_class.new provider, current_user } + + include_context "ModelContract shared context" + + context "when admin" do + let(:current_user) { build_stubbed(:admin) } + + it_behaves_like "contract is valid" + end + + context "when non-admin" do + let(:current_user) { build_stubbed(:user) } + + it_behaves_like "contract is invalid", base: :error_unauthorized + end +end From fc4908cc39776bb80c201c19a52c146c87102423 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 15 Oct 2024 19:31:27 +0200 Subject: [PATCH 079/115] Config mapper spec --- .../openid_connect/configuration_mapper.rb | 10 +- .../configuration_mapper_spec.rb | 148 ++++++++++++++++++ 2 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 modules/openid_connect/spec/services/openid_connect/configuration_mapper_spec.rb diff --git a/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb b/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb index bb56b5ec63b2..a3655056a8fc 100644 --- a/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb +++ b/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb @@ -47,16 +47,17 @@ def call! "authorization_endpoint" => extract_url(options, "authorization_endpoint"), "token_endpoint" => extract_url(options, "token_endpoint"), "userinfo_endpoint" => extract_url(options, "userinfo_endpoint"), - "end_session_endpoint" => options["end_session_endpoint"], - "jwks_uri" => options["jwks_uri"] - } + "end_session_endpoint" => extract_url(options, "end_session_endpoint"), + "jwks_uri" => extract_url(options, "jwks_uri") + }.compact end private def extract_url(options, key) value = options[key] - return value if value.start_with?('http') + return value if value.blank? || value.start_with?("http") + unless value.start_with?("/") raise ArgumentError.new("Provided #{key} '#{value}' needs to be http(s) URL or path starting with a slash.") end @@ -68,6 +69,7 @@ def extract_url(options, key) def base_url(options) raise ArgumentError.new("Missing host in configuration") unless options["host"] + URI::Generic.build( host: options["host"], port: options["port"], diff --git a/modules/openid_connect/spec/services/openid_connect/configuration_mapper_spec.rb b/modules/openid_connect/spec/services/openid_connect/configuration_mapper_spec.rb new file mode 100644 index 000000000000..e5dd3128f681 --- /dev/null +++ b/modules/openid_connect/spec/services/openid_connect/configuration_mapper_spec.rb @@ -0,0 +1,148 @@ +# 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" + +RSpec.describe OpenIDConnect::ConfigurationMapper, type: :model do + let(:instance) { described_class.new(configuration) } + let(:result) { instance.call! } + + describe "display_name" do + subject { result["display_name"] } + + context "when provided" do + let(:configuration) { { display_name: "My OIDC Provider" } } + + it { is_expected.to eq("My OIDC Provider") } + end + + context "when not provided" do + let(:configuration) { {} } + + it { is_expected.to eq("OpenID Connect") } + end + end + + describe "slug" do + subject { result["slug"] } + + context "when provided from name" do + let(:configuration) { { name: "OIDCwat" } } + + it { is_expected.to eq("OIDCwat") } + end + + context "when not provided" do + let(:configuration) { {} } + + it { is_expected.to be_nil } + end + end + + describe "client_id" do + subject { result } + + context "when provided" do + let(:configuration) { { identifier: "foo" } } + + it { is_expected.to include("client_id" => "foo") } + end + + context "when not provided" do + let(:configuration) { { foo: "bar" } } + + it { is_expected.not_to have_key("client_id") } + end + end + + describe "client_secret" do + subject { result } + + context "when provided" do + let(:configuration) { { secret: "foo" } } + + it { is_expected.to include("client_secret" => "foo") } + end + + context "when not provided" do + let(:configuration) { { foo: "bar" } } + + it { is_expected.not_to have_key("client_secret") } + end + end + + describe "issuer" do + subject { result } + + context "when provided" do + let(:configuration) { { issuer: "foo" } } + + it { is_expected.to include("issuer" => "foo") } + end + + context "when not provided" do + let(:configuration) { { foo: "bar" } } + + it { is_expected.not_to have_key("issuer") } + end + end + + %w[authorization_endpoint token_endpoint userinfo_endpoint end_session_endpoint jwks_uri].each do |key| + describe "setting #{key}" do + subject { result } + + context "when provided as url" do + let(:configuration) { { key => "https://foo.example.com/sso" } } + + it { is_expected.to include(key => "https://foo.example.com/sso") } + end + + context "when provided as path without host" do + let(:configuration) { { key => "/foo" } } + + it "raises an error" do + expect { subject }.to raise_error("Missing host in configuration") + end + end + + context "when provided as path with host" do + let(:configuration) { { host: "example.com", scheme: "https", key => "/foo" } } + + it { is_expected.to include(key => "https://example.com/foo") } + end + + context "when not provided" do + let(:configuration) { { foo: "bar" } } + + it { is_expected.not_to have_key(key) } + end + end + end +end From 2f3e7daef8ce467c866ed4440352991599832b40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 15 Oct 2024 20:01:24 +0200 Subject: [PATCH 080/115] Service specs --- .../providers/update_service.rb | 5 +- .../spec/factories/oidc_provider_factory.rb | 1 - .../providers/create_service_spec.rb | 36 +++++ .../providers/set_attributes_service_spec.rb | 130 ++++++++++++++++++ .../providers/update_service_spec.rb | 36 +++++ 5 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 modules/openid_connect/spec/services/openid_connect/providers/create_service_spec.rb create mode 100644 modules/openid_connect/spec/services/openid_connect/providers/set_attributes_service_spec.rb create mode 100644 modules/openid_connect/spec/services/openid_connect/providers/update_service_spec.rb diff --git a/modules/openid_connect/app/services/openid_connect/providers/update_service.rb b/modules/openid_connect/app/services/openid_connect/providers/update_service.rb index 689f73bae2fb..68b366a15d15 100644 --- a/modules/openid_connect/app/services/openid_connect/providers/update_service.rb +++ b/modules/openid_connect/app/services/openid_connect/providers/update_service.rb @@ -45,12 +45,15 @@ def after_validate(_params, call) metadata_url = get_metadata_url(model) return call if metadata_url.blank? + extract_metadata(call, metadata_url, model) + end + + def extract_metadata(call, metadata_url, model) # rubocop:disable Metrics/AbcSize case (response = OpenProject.httpx.get(metadata_url)) in {status: 200..299} json = begin response.json rescue HTTPX::Error - binding.pry call.errors.add(:metadata_url, :response_is_not_json) call.success = false end diff --git a/modules/openid_connect/spec/factories/oidc_provider_factory.rb b/modules/openid_connect/spec/factories/oidc_provider_factory.rb index bc5502c8dbf4..069df379891c 100644 --- a/modules/openid_connect/spec/factories/oidc_provider_factory.rb +++ b/modules/openid_connect/spec/factories/oidc_provider_factory.rb @@ -11,7 +11,6 @@ "jwks_uri" => "https://keycloak.local/realms/master/protocol/openid-connect/certs", "client_id" => "https://openproject.local", "client_secret" => "9AWjVC3A4U1HLrZuSP4xiwHfw6zmgECn", - "metadata_url" => "https://keycloak.local/realms/master/.well-known/openid-configuration", "oidc_provider" => "custom", "token_endpoint" => "https://keycloak.local/realms/master/protocol/openid-connect/token", "userinfo_endpoint" => "https://keycloak.local/realms/master/protocol/openid-connect/userinfo", diff --git a/modules/openid_connect/spec/services/openid_connect/providers/create_service_spec.rb b/modules/openid_connect/spec/services/openid_connect/providers/create_service_spec.rb new file mode 100644 index 000000000000..79f9518b4bb2 --- /dev/null +++ b/modules/openid_connect/spec/services/openid_connect/providers/create_service_spec.rb @@ -0,0 +1,36 @@ +#-- 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 "services/base_services/behaves_like_create_service" + +RSpec.describe OpenIDConnect::Providers::CreateService, type: :model do + it_behaves_like "BaseServices create service" do + let(:factory) { :oidc_provider } + end +end diff --git a/modules/openid_connect/spec/services/openid_connect/providers/set_attributes_service_spec.rb b/modules/openid_connect/spec/services/openid_connect/providers/set_attributes_service_spec.rb new file mode 100644 index 000000000000..d348a60394b8 --- /dev/null +++ b/modules/openid_connect/spec/services/openid_connect/providers/set_attributes_service_spec.rb @@ -0,0 +1,130 @@ +# 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_module_spec_helper + +RSpec.describe OpenIDConnect::Providers::SetAttributesService, type: :model do + let(:current_user) { build_stubbed(:admin) } + + let(:instance) do + described_class.new(user: current_user, + model: model_instance, + contract_class:, + contract_options: {}) + end + + let(:call) { instance.call(params) } + + subject { call.result } + + describe "new instance" do + let(:model_instance) { OpenIDConnect::Provider.new(oidc_provider: 'custom', display_name: "foo") } + let(:contract_class) { OpenIDConnect::Providers::CreateContract } + + describe "default attributes" do + let(:params) { {} } + + it "sets all default attributes", :aggregate_failures do + expect(subject.display_name).to eq "foo" + expect(subject.slug).to eq "oidc-foo" + expect(subject.creator).to eq(current_user) + + expect(subject.mapping_email).to be_blank + expect(subject.mapping_first_name).to be_blank + expect(subject.mapping_last_name).to be_blank + expect(subject.mapping_login).to be_blank + end + end + + %i[token_endpoint metadata_url jwks_uri userinfo_endpoint end_session_endpoint].each do |url_attr| + describe "setting #{url_attr}" do + let(:params) do + { + url_attr => value + } + end + + context "when nil" do + let(:value) { nil } + + it "is valid" do + expect(call).to be_success + expect(call.errors).to be_empty + + expect(subject.public_send(url_attr)).to be_nil + end + end + + context "when blank" do + let(:value) { "" } + + it "is valid" do + expect(call).to be_success + expect(call.errors).to be_empty + + expect(subject.public_send(url_attr)).to eq "" + end + end + + context "when not a URL" do + let(:value) { "foo!" } + + it "is valid" do + expect(call).not_to be_success + expect(call.errors.details[url_attr]) + .to contain_exactly({ error: :url, value: }) + end + end + + context "when invalid scheme" do + let(:value) { "urn:some:info" } + + it "is valid" do + expect(call).not_to be_success + expect(call.errors.details[url_attr]) + .to contain_exactly({ error: :url, value: }) + end + end + + context "when valid" do + let(:value) { "https://foobar.example.com/slo" } + + it "is valid" do + expect(call).to be_success + expect(call.errors).to be_empty + + expect(subject.public_send(url_attr)).to eq value + end + end + end + end + end +end diff --git a/modules/openid_connect/spec/services/openid_connect/providers/update_service_spec.rb b/modules/openid_connect/spec/services/openid_connect/providers/update_service_spec.rb new file mode 100644 index 000000000000..7acd051a8991 --- /dev/null +++ b/modules/openid_connect/spec/services/openid_connect/providers/update_service_spec.rb @@ -0,0 +1,36 @@ +#-- 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 "services/base_services/behaves_like_update_service" + +RSpec.describe OpenIDConnect::Providers::UpdateService, type: :model do + it_behaves_like "BaseServices update service" do + let(:factory) { :oidc_provider } + end +end From 524a4597a3cd9279be53f3f6138103722fa42ede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 16 Oct 2024 07:14:27 +0200 Subject: [PATCH 081/115] Add claims --- config/locales/en.yml | 1 + lib/open_project/static/links.rb | 9 +++ .../openid_connect/providers/claims_form.rb | 66 +++++++++++++++++ .../providers/view_component.html.erb | 74 ++++++++++++------- .../openid_connect/providers/base_contract.rb | 16 ++++ .../openid_connect/providers_controller.rb | 3 +- .../app/models/openid_connect/provider.rb | 5 +- .../openid_connect/configuration_mapper.rb | 2 + .../providers/update_service.rb | 2 +- modules/openid_connect/config/locales/en.yml | 7 ++ .../administration/oidc_custom_crud_spec.rb | 7 ++ .../providers/set_attributes_service_spec.rb | 62 +++++++++++++++- .../users/register_user_service_spec.rb | 8 +- 13 files changed, 227 insertions(+), 35 deletions(-) create mode 100644 modules/openid_connect/app/components/openid_connect/providers/claims_form.rb diff --git a/config/locales/en.yml b/config/locales/en.yml index 67c686f7d8f7..41923099e2ae 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -962,6 +962,7 @@ en: not_a_datetime: "is not a valid date time." not_a_number: "is not a number." not_allowed: "is invalid because of missing permissions." + not_json: "is not a valid JSON object." not_an_integer: "is not an integer." not_an_iso_date: "is not a valid date. Required format: YYYY-MM-DD." not_same_project: "doesn't belong to the same project." diff --git a/lib/open_project/static/links.rb b/lib/open_project/static/links.rb index ea9d734b5d87..3cf20939e294 100644 --- a/lib/open_project/static/links.rb +++ b/lib/open_project/static/links.rb @@ -273,6 +273,15 @@ def static_links sysadmin_docs: { saml: { href: "https://www.openproject.org/docs/system-admin-guide/authentication/saml/" + }, + oidc: { + href: "https://www.openproject.org/docs/installation-and-operations/misc/custom-openid-connect-providers/" + }, + oidc_claims: { + href: "https://www.openproject.org/docs/installation-and-operations/misc/custom-openid-connect-providers/#claims" + }, + oidc_acr_values: { + href: "https://www.openproject.org/docs/installation-and-operations/misc/custom-openid-connect-providers/#non-essential-claims" } }, storage_docs: { diff --git a/modules/openid_connect/app/components/openid_connect/providers/claims_form.rb b/modules/openid_connect/app/components/openid_connect/providers/claims_form.rb new file mode 100644 index 000000000000..02722ffc0d1f --- /dev/null +++ b/modules/openid_connect/app/components/openid_connect/providers/claims_form.rb @@ -0,0 +1,66 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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. +#++ + +module OpenIDConnect + module Providers + class ClaimsForm < BaseForm + include Redmine::I18n + + form do |f| + f.text_area( + name: :claims, + rows: 10, + label: I18n.t("activemodel.attributes.openid_connect/provider.claims"), + caption: link_translate( + "openid_connect.instructions.claims", + links: { + docs_url: ::OpenProject::Static::Links[:sysadmin_docs][:oidc_claims][:href] + } + ), + disabled: provider.seeded_from_env?, + required: false, + input_width: :large + ) + + f.text_field( + name: :acr_values, + label: I18n.t("activemodel.attributes.openid_connect/provider.acr_values"), + caption: link_translate( + "openid_connect.instructions.acr_values", + links: { + docs_url: ::OpenProject::Static::Links[:sysadmin_docs][:oidc_acr_values][:href] + } + ), + disabled: provider.seeded_from_env?, + required: false, + input_width: :large + ) + end + end + end +end diff --git a/modules/openid_connect/app/components/openid_connect/providers/view_component.html.erb b/modules/openid_connect/app/components/openid_connect/providers/view_component.html.erb index 2c6e794ee0a2..585282184884 100644 --- a/modules/openid_connect/app/components/openid_connect/providers/view_component.html.erb +++ b/modules/openid_connect/app/components/openid_connect/providers/view_component.html.erb @@ -1,4 +1,3 @@ -<% ns = OpenIDConnect::Providers %> <%= component_wrapper do %> <% if provider.seeded_from_env? %> <%= @@ -17,16 +16,16 @@ when 'google' component.with_row(scheme: :default) do basic_details_component = if edit_state == :name - ns::Sections::FormComponent.new( + OpenIDConnect::Providers::Sections::FormComponent.new( provider, - form_class: ns::NameInputForm, + form_class: OpenIDConnect::Providers::NameInputForm, edit_state:, next_edit_state: :client_details, edit_mode:, heading: nil ) else - ns::Sections::ShowComponent.new( + OpenIDConnect::Providers::Sections::ShowComponent.new( provider, view_mode:, target_state: :name, @@ -39,15 +38,15 @@ component.with_row(scheme: :default) do if edit_state == :client_details - render(ns::Sections::FormComponent.new( + render(OpenIDConnect::Providers::Sections::FormComponent.new( provider, - form_class: ns::ClientDetailsForm, + form_class: OpenIDConnect::Providers::ClientDetailsForm, edit_state:, edit_mode:, heading: nil, )) else - render(ns::Sections::ShowComponent.new( + render(OpenIDConnect::Providers::Sections::ShowComponent.new( provider, target_state: :client_details, view_mode:, @@ -61,16 +60,16 @@ when 'microsoft_entra' component.with_row(scheme: :default) do basic_details_component = if edit_state == :name - ns::Sections::FormComponent.new( + OpenIDConnect::Providers::Sections::FormComponent.new( provider, - form_class: ns::NameInputAndTenantForm, + form_class: OpenIDConnect::Providers::NameInputAndTenantForm, edit_state:, next_edit_state: :client_details, edit_mode:, heading: nil ) else - ns::Sections::ShowComponent.new( + OpenIDConnect::Providers::Sections::ShowComponent.new( provider, view_mode:, target_state: :name, @@ -83,15 +82,15 @@ component.with_row(scheme: :default) do if edit_state == :client_details - render(ns::Sections::FormComponent.new( + render(OpenIDConnect::Providers::Sections::FormComponent.new( provider, - form_class: ns::ClientDetailsForm, + form_class: OpenIDConnect::Providers::ClientDetailsForm, edit_state:, edit_mode:, heading: nil )) else - render(ns::Sections::ShowComponent.new( + render(OpenIDConnect::Providers::Sections::ShowComponent.new( provider, target_state: :client_details, view_mode:, @@ -106,16 +105,16 @@ component.with_row(scheme: :default) do basic_details_component = if edit_state == :name - ns::Sections::FormComponent.new( + OpenIDConnect::Providers::Sections::FormComponent.new( provider, - form_class: ns::NameInputForm, + form_class: OpenIDConnect::Providers::NameInputForm, edit_state:, next_edit_state: :metadata, edit_mode:, heading: nil ) else - ns::Sections::ShowComponent.new( + OpenIDConnect::Providers::Sections::ShowComponent.new( provider, view_mode:, target_state: :name, @@ -132,7 +131,7 @@ component.with_row(scheme: :default) do if edit_state == :metadata - render(ns::Sections::MetadataFormComponent.new( + render(OpenIDConnect::Providers::Sections::MetadataFormComponent.new( provider, form_class: nil, heading: nil, @@ -141,7 +140,7 @@ next_edit_state: :metadata_details )) else - render(ns::Sections::ShowComponent.new( + render(OpenIDConnect::Providers::Sections::ShowComponent.new( provider, target_state: :metadata, view_mode:, @@ -159,9 +158,9 @@ component.with_row(scheme: :default) do if edit_state == :metadata_details - render(ns::Sections::FormComponent.new( + render(OpenIDConnect::Providers::Sections::FormComponent.new( provider, - form_class: ns::MetadataDetailsForm, + form_class: OpenIDConnect::Providers::MetadataDetailsForm, edit_state:, next_edit_state: :client_details, edit_mode:, @@ -170,7 +169,7 @@ heading: nil )) else - render(ns::Sections::ShowComponent.new( + render(OpenIDConnect::Providers::Sections::ShowComponent.new( provider, target_state: :metadata_details, view_mode:, @@ -184,16 +183,16 @@ component.with_row(scheme: :default) do if edit_state == :client_details - render(ns::Sections::FormComponent.new( + render(OpenIDConnect::Providers::Sections::FormComponent.new( provider, - form_class: ns::ClientDetailsForm, + form_class: OpenIDConnect::Providers::ClientDetailsForm, edit_state:, next_edit_state: :attribute_mapping, edit_mode:, heading: nil )) else - render(ns::Sections::ShowComponent.new( + render(OpenIDConnect::Providers::Sections::ShowComponent.new( provider, target_state: :client_details, view_mode:, @@ -211,15 +210,16 @@ component.with_row(scheme: :default) do if edit_state == :attribute_mapping - render(ns::Sections::FormComponent.new( + render(OpenIDConnect::Providers::Sections::FormComponent.new( provider, - form_class: ns::AttributeMappingForm, + form_class: OpenIDConnect::Providers::AttributeMappingForm, edit_state:, + next_edit_state: :claims, edit_mode:, heading: nil )) else - render(ns::Sections::ShowComponent.new( + render(OpenIDConnect::Providers::Sections::ShowComponent.new( provider, target_state: :attribute_mapping, view_mode:, @@ -230,6 +230,26 @@ )) end end + + component.with_row(scheme: :default) do + if edit_state == :claims + render(OpenIDConnect::Providers::Sections::FormComponent.new( + provider, + form_class: OpenIDConnect::Providers::ClaimsForm, + edit_state:, + edit_mode:, + heading: nil + )) + else + render(OpenIDConnect::Providers::Sections::ShowComponent.new( + provider, + target_state: :claims, + view_mode:, + heading: t("activemodel.attributes.openid_connect/provider.claims"), + description: t("openid_connect.providers.section_texts.claims") + )) + end + end end end %> <% end %> diff --git a/modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb b/modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb index afafe6c84dad..f6bb171b0003 100644 --- a/modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb +++ b/modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb @@ -42,6 +42,12 @@ def self.model attribute :slug attribute :options attribute :limit_self_registration + + attribute :claims + validate :claims_are_json + + attribute :acr_values + attribute :metadata_url validates :metadata_url, url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, @@ -75,6 +81,16 @@ def self.model OpenIDConnect::Provider::MAPPABLE_ATTRIBUTES.each do |attr| attribute :"mapping_#{attr}" end + + private + + def claims_are_json + return if claims.blank? + + JSON.parse(claims) + rescue JSON::ParserError + errors.add(:claims, :not_json) + end end end end diff --git a/modules/openid_connect/app/controllers/openid_connect/providers_controller.rb b/modules/openid_connect/app/controllers/openid_connect/providers_controller.rb index 55c4e4252175..c5ba074407e0 100644 --- a/modules/openid_connect/app/controllers/openid_connect/providers_controller.rb +++ b/modules/openid_connect/app/controllers/openid_connect/providers_controller.rb @@ -47,7 +47,8 @@ def edit; end def update update_params = params .require(:openid_connect_provider) - .permit(:display_name, :oidc_provider, :limit_self_registration, *OpenIDConnect::Provider.stored_attributes[:options]) + .permit(:display_name, :oidc_provider, :limit_self_registration, + *OpenIDConnect::Provider.stored_attributes[:options]) call = OpenIDConnect::Providers::UpdateService .new(model: @provider, user: User.current) .call(update_params) diff --git a/modules/openid_connect/app/models/openid_connect/provider.rb b/modules/openid_connect/app/models/openid_connect/provider.rb index 05e6e1960979..03f644071ef4 100644 --- a/modules/openid_connect/app/models/openid_connect/provider.rb +++ b/modules/openid_connect/app/models/openid_connect/provider.rb @@ -29,6 +29,9 @@ class Provider < AuthProvider store_attribute :options, :client_secret, :string store_attribute :options, :tenant, :string + store_attribute :options, :claims, :string + store_attribute :options, :acr_values, :string + def self.slug_fragment = "oidc" def seeded_from_env? @@ -44,7 +47,7 @@ def advanced_details_configured? end def metadata_configured? - return false unless metadata_url.present? + return false if metadata_url.blank? DISCOVERABLE_ATTRIBUTES_MANDATORY.all? do |mandatory_attribute| public_send(mandatory_attribute).present? diff --git a/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb b/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb index a3655056a8fc..dc57588397d7 100644 --- a/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb +++ b/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb @@ -44,6 +44,8 @@ def call! "client_id" => options["identifier"], "client_secret" => options["secret"], "issuer" => options["issuer"], + "claims" => options["claims"], + "acr_values" => options["acr_values"], "authorization_endpoint" => extract_url(options, "authorization_endpoint"), "token_endpoint" => extract_url(options, "token_endpoint"), "userinfo_endpoint" => extract_url(options, "userinfo_endpoint"), diff --git a/modules/openid_connect/app/services/openid_connect/providers/update_service.rb b/modules/openid_connect/app/services/openid_connect/providers/update_service.rb index 68b366a15d15..3297b5109b2b 100644 --- a/modules/openid_connect/app/services/openid_connect/providers/update_service.rb +++ b/modules/openid_connect/app/services/openid_connect/providers/update_service.rb @@ -68,7 +68,7 @@ def extract_metadata(call, metadata_url, model) # rubocop:disable Metrics/AbcSiz else call.errors.add(:metadata_url, :response_misses_required_attributes, - missing_attributes: result.errors.to_h.keys.join(", ")) + missing_attributes: result.errors.attribute_names.join(", ")) call.success = false end in {status: 300..} diff --git a/modules/openid_connect/config/locales/en.yml b/modules/openid_connect/config/locales/en.yml index 5ab34f68ef52..5cf78c3bd806 100644 --- a/modules/openid_connect/config/locales/en.yml +++ b/modules/openid_connect/config/locales/en.yml @@ -24,6 +24,8 @@ en: tenant: Tenant metadata_url: Metadata URL icon: Custom icon + claims: Claims + acr_values: ACR values activerecord: errors: models: @@ -46,6 +48,10 @@ en: limit_self_registration: If enabled, users can only register using this provider if configuration on the prvoder's end allows it. display_name: Then name of the provider. This will be displayed as the login button and in the list of providers. tenant: Please replace the default tenant with your own if applicable. See this. + claims: > + You can request additional claims for the userinfo and id token endpoints. Please see [our OpenID connect documentation](docs_url) for more information. + acr_values: > + Request non-essential claims in an easier format. See [our documentation on acr_values](docs_url) for more information. mapping_login: > Provide a custom mapping in the userinfo response to be used for the login attribute. mapping_email: > @@ -92,6 +98,7 @@ en: configuration: Configuration details of the OpenID Connect provider display_name: The display name visible to users. attribute_mapping: Configure the mapping of attributes between OpenProject and the OpenID Connect provider. + claims: Request additional claims for the ID token or userinfo response. setting_instructions: limit_self_registration: > If enabled users can only register using this provider if the self registration setting allows for it. diff --git a/modules/openid_connect/spec/features/administration/oidc_custom_crud_spec.rb b/modules/openid_connect/spec/features/administration/oidc_custom_crud_spec.rb index 05b8c23b15aa..dee4f64db892 100644 --- a/modules/openid_connect/spec/features/administration/oidc_custom_crud_spec.rb +++ b/modules/openid_connect/spec/features/administration/oidc_custom_crud_spec.rb @@ -70,6 +70,13 @@ fill_in "Mapping for: Email", with: "mail" fill_in "Mapping for: First name", with: "myName" fill_in "Mapping for: Last name", with: "myLastName" + + click_link_or_button "Continue" + + # Claims + fill_in "Claims", with: '{"foo": "bar"}' + fill_in "ACR values", with: "foo bar" + click_link_or_button "Finish setup" # We're now on the show page diff --git a/modules/openid_connect/spec/services/openid_connect/providers/set_attributes_service_spec.rb b/modules/openid_connect/spec/services/openid_connect/providers/set_attributes_service_spec.rb index d348a60394b8..88975b615814 100644 --- a/modules/openid_connect/spec/services/openid_connect/providers/set_attributes_service_spec.rb +++ b/modules/openid_connect/spec/services/openid_connect/providers/set_attributes_service_spec.rb @@ -46,7 +46,7 @@ subject { call.result } describe "new instance" do - let(:model_instance) { OpenIDConnect::Provider.new(oidc_provider: 'custom', display_name: "foo") } + let(:model_instance) { OpenIDConnect::Provider.new(oidc_provider: "custom", display_name: "foo") } let(:contract_class) { OpenIDConnect::Providers::CreateContract } describe "default attributes" do @@ -64,6 +64,66 @@ end end + describe "setting claims" do + let(:params) do + { + claims: value + } + end + + context "when nil" do + let(:value) { nil } + + it "is valid" do + expect(call).to be_success + expect(call.errors).to be_empty + + expect(subject.claims).to be_nil + end + end + + context "when blank" do + let(:value) { "" } + + it "is valid" do + expect(call).to be_success + expect(call.errors).to be_empty + + expect(subject.claims).to eq "" + end + end + + context "when invalid JSON" do + let(:value) { "foo" } + + it "is invalid" do + expect(call).not_to be_success + expect(call.errors.details[:claims]) + .to contain_exactly({ error: :not_json }) + end + end + + context "when valid JSON" do + let(:value) do + { + id_token: { + acr: { + essential: true, + values: %w[phr phrh Multi_Factor] + } + } + }.to_json + end + + it "is valid" do + expect(call).to be_success + expect(call.errors).to be_empty + + expect(subject.claims).to eq value + end + end + end + %i[token_endpoint metadata_url jwks_uri userinfo_endpoint end_session_endpoint].each do |url_attr| describe "setting #{url_attr}" do let(:params) do diff --git a/spec/services/users/register_user_service_spec.rb b/spec/services/users/register_user_service_spec.rb index 9d98aa9db5c8..9a8cfa2f1458 100644 --- a/spec/services/users/register_user_service_spec.rb +++ b/spec/services/users/register_user_service_spec.rb @@ -102,7 +102,7 @@ def with_all_registration_options(except: []) self_registration: 0, } do it "fails to activate due to disabled self registration" do - create(:oidc_provider, slug: 'azure') + create(:oidc_provider, slug: "azure") call = instance.call expect(call).not_to be_success expect(call.result).to eq user @@ -115,7 +115,7 @@ def with_all_registration_options(except: []) self_registration: 2, } do it "registers the user, but does not activate it" do - create(:oidc_provider, slug: 'azure') + create(:oidc_provider, slug: "azure") call = instance.call expect(call).to be_success expect(call.result).to eq user @@ -130,7 +130,7 @@ def with_all_registration_options(except: []) self_registration: 1, } do it "registers the user, but does not activate it" do - create(:oidc_provider, slug: 'azure') + create(:oidc_provider, slug: "azure") call = instance.call expect(call).to be_success @@ -146,7 +146,7 @@ def with_all_registration_options(except: []) self_registration: 3, } do it "activates the user" do - create(:oidc_provider, slug: 'azure') + create(:oidc_provider, slug: "azure") call = instance.call expect(call).to be_success expect(call.result).to eq user From dcc293c8a48151af2e3daefc6de39913f1810c2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 16 Oct 2024 09:06:16 +0200 Subject: [PATCH 082/115] Remove metadata_url from discoverable attribute check It is not relevant for the configured? check --- modules/openid_connect/app/models/openid_connect/provider.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/modules/openid_connect/app/models/openid_connect/provider.rb b/modules/openid_connect/app/models/openid_connect/provider.rb index 03f644071ef4..4d14fa366581 100644 --- a/modules/openid_connect/app/models/openid_connect/provider.rb +++ b/modules/openid_connect/app/models/openid_connect/provider.rb @@ -47,8 +47,6 @@ def advanced_details_configured? end def metadata_configured? - return false if metadata_url.blank? - DISCOVERABLE_ATTRIBUTES_MANDATORY.all? do |mandatory_attribute| public_send(mandatory_attribute).present? end From 3312cc95d913f4eaebd9d5bc1a7a28093f5a543d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 16 Oct 2024 10:38:53 +0200 Subject: [PATCH 083/115] Allow path based attributes after all --- .../openid_connect/providers/base_contract.rb | 39 +++++-------------- 1 file changed, 10 insertions(+), 29 deletions(-) diff --git a/modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb b/modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb index f6bb171b0003..6f9f1e4b797e 100644 --- a/modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb +++ b/modules/openid_connect/app/contracts/openid_connect/providers/base_contract.rb @@ -48,35 +48,12 @@ def self.model attribute :acr_values - attribute :metadata_url - validates :metadata_url, - url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, - if: -> { model.metadata_url_changed? } - - attribute :authorization_endpoint - validates :authorization_endpoint, - url: { allow_blank: false, allow_nil: false, schemes: %w[http https] }, - if: -> { model.authorization_endpoint_changed? } - - attribute :userinfo_endpoint - validates :userinfo_endpoint, - url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, - if: -> { model.userinfo_endpoint_changed? } - - attribute :token_endpoint - validates :token_endpoint, - url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, - if: -> { model.token_endpoint_changed? } - - attribute :end_session_endpoint - validates :end_session_endpoint, - url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, - if: -> { model.end_session_endpoint_changed? } - - attribute :jwks_uri - validates :jwks_uri, - url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, - if: -> { model.jwks_uri_changed? } + %i[metadata_url authorization_endpoint userinfo_endpoint token_endpoint end_session_endpoint jwks_uri].each do |attr| + attribute attr + validates attr, + url: { allow_blank: true, allow_nil: true, schemes: %w[http https] }, + if: -> { model.public_send(:"#{attr}_changed?") && !path_attribute?(model.public_send(attr)) } + end OpenIDConnect::Provider::MAPPABLE_ATTRIBUTES.each do |attr| attribute :"mapping_#{attr}" @@ -84,6 +61,10 @@ def self.model private + def path_attribute?(attr) + attr.blank? || attr.start_with?("/") + end + def claims_are_json return if claims.blank? From 718d934875a2e834be0f8f45b53c911375f78fff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 16 Oct 2024 11:06:30 +0200 Subject: [PATCH 084/115] Fix generation of provider classes from new config --- .../app/models/openid_connect/provider.rb | 3 ++ .../openid_connect/provider/hash_builder.rb | 35 +++++++++++-------- .../openid_connect/configuration_mapper.rb | 26 +++++++++++--- .../providers/set_attributes_service.rb | 22 ++++++++++++ .../services/openid_connect/sync_service.rb | 1 + ...0616_migrate_oidc_settings_to_providers.rb | 1 - .../lib/open_project/openid_connect.rb | 31 ++++++++++++---- 7 files changed, 93 insertions(+), 26 deletions(-) diff --git a/modules/openid_connect/app/models/openid_connect/provider.rb b/modules/openid_connect/app/models/openid_connect/provider.rb index 4d14fa366581..cd03b98f626e 100644 --- a/modules/openid_connect/app/models/openid_connect/provider.rb +++ b/modules/openid_connect/app/models/openid_connect/provider.rb @@ -32,6 +32,9 @@ class Provider < AuthProvider store_attribute :options, :claims, :string store_attribute :options, :acr_values, :string + # azure specific option + store_attribute :options, :use_graph_api, :boolean + def self.slug_fragment = "oidc" def seeded_from_env? diff --git a/modules/openid_connect/app/models/openid_connect/provider/hash_builder.rb b/modules/openid_connect/app/models/openid_connect/provider/hash_builder.rb index 417d22eac481..5e59c0178956 100644 --- a/modules/openid_connect/app/models/openid_connect/provider/hash_builder.rb +++ b/modules/openid_connect/app/models/openid_connect/provider/hash_builder.rb @@ -7,14 +7,14 @@ def attribute_map end def to_h # rubocop:disable Metrics/AbcSize - h = { + { name: slug, + oidc_provider:, icon:, display_name:, userinfo_endpoint:, authorization_endpoint:, jwks_uri:, - host: URI(issuer).host, issuer:, identifier: client_id, secret: client_secret, @@ -22,21 +22,26 @@ def to_h # rubocop:disable Metrics/AbcSize limit_self_registration:, end_session_endpoint:, attribute_map: - } - .merge(attribute_map) - .compact_blank + }.merge(attribute_map) + .merge(provider_specific_to_h) + .compact_blank + end - if oidc_provider == "google" - h.merge!( - { - client_auth_method: :not_basic, - send_nonce: false, # use state instead of nonce - state: lambda { SecureRandom.hex(42) } - } - ) + def provider_specific_to_h + case oidc_provider + when "google" + { + client_auth_method: :not_basic, + send_nonce: false, # use state instead of nonce + state: lambda { SecureRandom.hex(42) } + } + when "microsoft_entra" + { + use_graph_api: + } + else + {} end - - h end end end diff --git a/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb b/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb index dc57588397d7..b5495afb0048 100644 --- a/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb +++ b/modules/openid_connect/app/services/openid_connect/configuration_mapper.rb @@ -34,17 +34,18 @@ def initialize(configuration) @configuration = configuration end - def call! + def call! # rubocop:disable Metrics/AbcSize options = mapped_options(configuration.deep_stringify_keys) { - "slug" => options.delete("name"), - "display_name" => options.delete("display_name") || "OpenID Connect", - "oidc_provider" => "custom", + "slug" => options["name"], + "display_name" => options["display_name"].presence || "OpenID Connect", + "oidc_provider" => oidc_provider(options), "client_id" => options["identifier"], "client_secret" => options["secret"], "issuer" => options["issuer"], "claims" => options["claims"], + "use_graph_api" => options["use_graph_api"], "acr_values" => options["acr_values"], "authorization_endpoint" => extract_url(options, "authorization_endpoint"), "token_endpoint" => extract_url(options, "token_endpoint"), @@ -56,6 +57,17 @@ def call! private + def oidc_provider(options) + case options["name"] + when /azure/ + "microsoft_entra" + when /google/ + "google" + else + "custom" + end + end + def extract_url(options, key) value = options[key] return value if value.blank? || value.start_with?("http") @@ -64,6 +76,12 @@ def extract_url(options, key) raise ArgumentError.new("Provided #{key} '#{value}' needs to be http(s) URL or path starting with a slash.") end + # Allow returning the value as is for built-in providers + # with fixed host names + if oidc_provider(options) != "custom" + return value + end + URI .join(base_url(options), value) .to_s diff --git a/modules/openid_connect/app/services/openid_connect/providers/set_attributes_service.rb b/modules/openid_connect/app/services/openid_connect/providers/set_attributes_service.rb index 92e90fe00200..ec1879e64a01 100644 --- a/modules/openid_connect/app/services/openid_connect/providers/set_attributes_service.rb +++ b/modules/openid_connect/app/services/openid_connect/providers/set_attributes_service.rb @@ -37,6 +37,28 @@ def set_default_attributes(*) model.slug ||= "#{model.class.slug_fragment}-#{model.display_name.to_url}" if model.display_name end end + + def set_attributes(params) + update_options(params.delete(:options)) if params.key?(:options) + + super + + update_available_state + end + + def update_available_state + model.change_by_system do + model.available = model.configured? + end + end + + def update_options(options) + options + .select { |key, _| Saml::Provider.stored_attributes[:options].include?(key.to_s) } + .each do |key, value| + model.public_send(:"#{key}=", value) + end + end end end end diff --git a/modules/openid_connect/app/services/openid_connect/sync_service.rb b/modules/openid_connect/app/services/openid_connect/sync_service.rb index 73e5d5e8bcfa..093b944ba682 100644 --- a/modules/openid_connect/app/services/openid_connect/sync_service.rb +++ b/modules/openid_connect/app/services/openid_connect/sync_service.rb @@ -32,6 +32,7 @@ class SyncService def initialize(name, configuration) @name = name + configuration[:name] = name @configuration = ::OpenIDConnect::ConfigurationMapper.new(configuration).call! end diff --git a/modules/openid_connect/db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb b/modules/openid_connect/db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb index 1396d0da9fec..46ea3ba0545e 100644 --- a/modules/openid_connect/db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb +++ b/modules/openid_connect/db/migrate/20240829140616_migrate_oidc_settings_to_providers.rb @@ -34,7 +34,6 @@ def up return if providers.blank? providers.each do |name, configuration| - configuration.delete(:name) migrate_provider!(name, configuration) end end diff --git a/modules/openid_connect/lib/open_project/openid_connect.rb b/modules/openid_connect/lib/open_project/openid_connect.rb index 859f3a34a01d..2c8df881e6c4 100644 --- a/modules/openid_connect/lib/open_project/openid_connect.rb +++ b/modules/openid_connect/lib/open_project/openid_connect.rb @@ -4,17 +4,36 @@ module OpenProject module OpenIDConnect - def providers + def self.configuration + providers = ::OpenIDConnect::Provider.where(available: true) + + OpenProject::Cache.fetch(providers.cache_key) do + providers.each_with_object({}) do |provider, hash| + hash[provider.slug.to_sym] = provider.to_h + end + end + end + + def self.providers # update base redirect URI in case settings changed ::OmniAuth::OpenIDConnect::Providers.configure( base_redirect_uri: "#{Setting.protocol}://#{Setting.host_name}#{OpenProject::Configuration['rails_relative_url_root']}" ) - providers = ::OpenIDConnect::Provider.where(available: true).select(&:configured?) - configuration = providers.each_with_object({}) do |provider, hash| - hash[provider.slug] = provider.to_h + + configuration.map do |slug, configuration| + provider = configuration.delete(:oidc_provider) + clazz = + case provider + when "google" + ::OmniAuth::OpenIDConnect::Google + when "microsoft_entra" + ::OmniAuth::OpenIDConnect::Azure + else + ::OmniAuth::OpenIDConnect::Provider + end + + clazz.new(slug, configuration) end - ::OmniAuth::OpenIDConnect::Providers.load(configuration) end - module_function :providers end end From d0390a25243d5e27ef76fb73ad4cd5377ad3859f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 16 Oct 2024 11:15:01 +0200 Subject: [PATCH 085/115] Skip metadata check for built-in otherwise they will appear incomplete, even though they are complete --- .../app/models/openid_connect/provider.rb | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/modules/openid_connect/app/models/openid_connect/provider.rb b/modules/openid_connect/app/models/openid_connect/provider.rb index cd03b98f626e..14a35feeed55 100644 --- a/modules/openid_connect/app/models/openid_connect/provider.rb +++ b/modules/openid_connect/app/models/openid_connect/provider.rb @@ -41,15 +41,13 @@ def seeded_from_env? (Setting.seed_oidc_provider || {}).key?(slug) end - def basic_details_configured? - display_name.present? && (oidc_provider == "microsoft_entra" ? tenant.present? : true) - end - def advanced_details_configured? client_id.present? && client_secret.present? end def metadata_configured? + return true if google? || entra_id? + DISCOVERABLE_ATTRIBUTES_MANDATORY.all? do |mandatory_attribute| public_send(mandatory_attribute).present? end @@ -61,8 +59,16 @@ def mapping_configured? end end + def google? + oidc_provider == "google" + end + + def entra_id? + oidc_provider == "microsoft_entra" + end + def configured? - basic_details_configured? && advanced_details_configured? && metadata_configured? + display_name.present? && advanced_details_configured? && metadata_configured? end def icon From 46eb11ad2e539c978e224c229e5ac2c5f77f84d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 16 Oct 2024 11:19:23 +0200 Subject: [PATCH 086/115] Remove unused providers helper --- .../openid_connect/providers_controller.rb | 16 ++++++---------- .../openid_connect/providers/index.html.erb | 2 +- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/modules/openid_connect/app/controllers/openid_connect/providers_controller.rb b/modules/openid_connect/app/controllers/openid_connect/providers_controller.rb index c5ba074407e0..ab54f477fff7 100644 --- a/modules/openid_connect/app/controllers/openid_connect/providers_controller.rb +++ b/modules/openid_connect/app/controllers/openid_connect/providers_controller.rb @@ -10,7 +10,9 @@ class ProvidersController < ::ApplicationController before_action :find_provider, only: %i[edit update destroy] before_action :set_edit_state, only: %i[create edit update] - def index; end + def index + @providers = ::OpenIDConnect::Provider.all + end def new oidc_provider = case params[:oidc_provider] @@ -81,16 +83,10 @@ def check_ee end def find_provider - @provider = providers.where(id: params[:id]).first - if @provider.nil? - render_404 - end - end - - def providers - @providers ||= ::OpenIDConnect::Provider.where(available: true) + @provider = OpenIDConnect::Provider.find(params[:id]) + rescue ActiveRecord::RecordNotFound + render_404 end - helper_method :providers def default_breadcrumb; end diff --git a/modules/openid_connect/app/views/openid_connect/providers/index.html.erb b/modules/openid_connect/app/views/openid_connect/providers/index.html.erb index 30c030c5613e..69ee277667dc 100644 --- a/modules/openid_connect/app/views/openid_connect/providers/index.html.erb +++ b/modules/openid_connect/app/views/openid_connect/providers/index.html.erb @@ -35,4 +35,4 @@ end %> -<%= render ::OpenIDConnect::Providers::TableComponent.new(rows: providers) %> +<%= render ::OpenIDConnect::Providers::TableComponent.new(rows: @providers) %> From d5987fa6c1b6f8eb98c56978695cd29932a3c23a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 16 Oct 2024 11:38:55 +0200 Subject: [PATCH 087/115] Remove state lambda It is defined in the omnaiuth gem already (however with different bits) --- .../app/models/openid_connect/provider/hash_builder.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/modules/openid_connect/app/models/openid_connect/provider/hash_builder.rb b/modules/openid_connect/app/models/openid_connect/provider/hash_builder.rb index 5e59c0178956..388547cd5b07 100644 --- a/modules/openid_connect/app/models/openid_connect/provider/hash_builder.rb +++ b/modules/openid_connect/app/models/openid_connect/provider/hash_builder.rb @@ -32,8 +32,7 @@ def provider_specific_to_h when "google" { client_auth_method: :not_basic, - send_nonce: false, # use state instead of nonce - state: lambda { SecureRandom.hex(42) } + send_nonce: false } when "microsoft_entra" { From 0959dcadb3a8396c515dfb0955df20b4b3df0c6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 16 Oct 2024 11:39:02 +0200 Subject: [PATCH 088/115] Set default issuer --- .../openid_connect/providers/set_attributes_service.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/openid_connect/app/services/openid_connect/providers/set_attributes_service.rb b/modules/openid_connect/app/services/openid_connect/providers/set_attributes_service.rb index ec1879e64a01..e0b2cbec2417 100644 --- a/modules/openid_connect/app/services/openid_connect/providers/set_attributes_service.rb +++ b/modules/openid_connect/app/services/openid_connect/providers/set_attributes_service.rb @@ -31,8 +31,9 @@ module Providers class SetAttributesService < BaseServices::SetAttributes private - def set_default_attributes(*) + def set_default_attributes(*) # rubocop:disable Metrics/AbcSize model.change_by_system do + model.issuer ||= OpenProject::StaticRouting::StaticUrlHelpers.new.root_url model.creator ||= user model.slug ||= "#{model.class.slug_fragment}-#{model.display_name.to_url}" if model.display_name end From 15dcc7603aa4aeb75f8a2d1523bc8b0f287d5cd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 16 Oct 2024 14:16:47 +0200 Subject: [PATCH 089/115] Allow setting omniauth direct login provider to the new auth providers --- app/models/auth_provider.rb | 4 ++++ .../authentication_settings/show.html.erb | 21 +++++++++++++++++++ config/locales/en.yml | 10 +++++++++ modules/auth_saml/app/models/saml/provider.rb | 4 ++++ .../app/models/openid_connect/provider.rb | 4 ++++ 5 files changed, 43 insertions(+) diff --git a/app/models/auth_provider.rb b/app/models/auth_provider.rb index 69d724c0faed..769d63d65cc4 100644 --- a/app/models/auth_provider.rb +++ b/app/models/auth_provider.rb @@ -36,6 +36,10 @@ def self.slug_fragment raise NotImplementedError end + def human_type + raise NotImplementedError + end + def auth_url root_url = OpenProject::StaticRouting::StaticUrlHelpers.new.root_url URI.join(root_url, "auth/#{slug}/").to_s diff --git a/app/views/admin/settings/authentication_settings/show.html.erb b/app/views/admin/settings/authentication_settings/show.html.erb index 8943b42aee49..cca0e8c6d325 100644 --- a/app/views/admin/settings/authentication_settings/show.html.erb +++ b/app/views/admin/settings/authentication_settings/show.html.erb @@ -57,6 +57,27 @@ See COPYRIGHT and LICENSE files for more details. <%= render Settings::NumericSettingComponent.new("invitation_expiration_days", unit: "days") %> +
+ <%= I18n.t(:'settings.authentication.single_sign_on') %> +
+ <% providers = AuthProvider + .where(available: true) + .order("lower(display_name) ASC") + .select(:type, :display_name, :slug) + .to_a + .map { |p| ["#{p.display_name} (#{p.human_type})", p.slug] } + %> + <%= setting_select :omniauth_direct_login_provider, + [[t(:label_disabled), ""]] + providers, + container_class: '-middle' %> + + <%= t("settings.authentication.omniauth_direct_login_hint_html", + internal_path: internal_signin_url) %> + +
+
+ +