From 985b07d1fdb0138a99f1ccc56e8025a6a3a8eef6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Wed, 23 Apr 2025 15:55:58 +0200 Subject: [PATCH 1/7] Primerize setings --- .../types/edit_page_header_component.html.erb | 13 +- app/forms/application_form.rb | 2 +- app/models/setting/self_registration.rb | 12 +- .../authentication_settings/_login.html.erb | 102 ++++++++++ .../_passwords.html.erb | 109 +++++++++++ .../_registration.html.erb | 84 ++++++++ .../authentication_settings/show.html.erb | 180 +++--------------- config/constants/settings/definition.rb | 3 +- config/locales/en.yml | 7 + config/static_links.yml | 2 + .../globals/global-listeners/settings.ts | 5 +- .../forms/rich_text_area.html.erb | 4 +- .../open_project/forms/rich_text_area.rb | 1 + lookbook/docs/patterns/02-forms.md.erb | 2 +- 14 files changed, 350 insertions(+), 176 deletions(-) create mode 100644 app/views/admin/settings/authentication_settings/_login.html.erb create mode 100644 app/views/admin/settings/authentication_settings/_passwords.html.erb create mode 100644 app/views/admin/settings/authentication_settings/_registration.html.erb diff --git a/app/components/types/edit_page_header_component.html.erb b/app/components/types/edit_page_header_component.html.erb index 6d26386c3b0..114769838ec 100644 --- a/app/components/types/edit_page_header_component.html.erb +++ b/app/components/types/edit_page_header_component.html.erb @@ -33,18 +33,7 @@ See COPYRIGHT and LICENSE files for more details. header.with_breadcrumbs(breadcrumb_items) if @tabs.present? - header.with_tab_nav(label: nil) do |tab_nav| - @tabs.each do |tab| - tab_nav.with_tab(selected: selected_tab(@tabs) == tab, href: tab[:path]) do |t| - feature = tab[:enterprise_feature] - - if feature && !EnterpriseToken.allows_to?(feature) - t.with_icon(icon: :"op-enterprise-addons", classes: "upsell-colored") - end - t.with_text { I18n.t(tab[:label]) } - end - end - end + helpers.render_tab_header_nav(header, @tabs) end end %> diff --git a/app/forms/application_form.rb b/app/forms/application_form.rb index 2fe86546d08..87a6882aa5e 100644 --- a/app/forms/application_form.rb +++ b/app/forms/application_form.rb @@ -31,7 +31,7 @@ class ApplicationForm < Primer::Forms::Base def self.settings_form form do |f| - f = SettingsFormDecorator.new(f) + f = Settings::FormDecorator.new(f) yield f end end diff --git a/app/models/setting/self_registration.rb b/app/models/setting/self_registration.rb index cfe39da6295..092078c7cb0 100644 --- a/app/models/setting/self_registration.rb +++ b/app/models/setting/self_registration.rb @@ -54,8 +54,12 @@ class Setting value key: :disabled end + def self.selected?(val) + key(value: Setting.self_registration) == val.to_sym + end + def self.disabled? - key(value: Setting.self_registration) == :disabled + selected?(:disabled) end def self.enabled? @@ -67,7 +71,7 @@ class Setting end def self.by_email? - key(value: Setting.self_registration) == :activation_by_email + selected?(:activation_by_email) end def self.manual @@ -75,7 +79,7 @@ class Setting end def self.manual? - key(value: Setting.self_registration) == :manual_activation + selected?(:manual_activation) end def self.automatic @@ -83,7 +87,7 @@ class Setting end def self.automatic? - key(value: Setting.self_registration) == :automatic_activation + selected?(:automatic_activation) end def self.unsupervised_registration? diff --git a/app/views/admin/settings/authentication_settings/_login.html.erb b/app/views/admin/settings/authentication_settings/_login.html.erb new file mode 100644 index 00000000000..78bd29a2f9b --- /dev/null +++ b/app/views/admin/settings/authentication_settings/_login.html.erb @@ -0,0 +1,102 @@ +<%#-- 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. + +++#%> + +<%= + admin_settings_primer_form_with(scope: :settings, + url: admin_settings_authentication_path(tab: params[:tab]), + method: :patch) do |f| + render_inline_settings_form(f) do |form| + form.select_list( + name: :omniauth_direct_login_provider, + input_width: :medium, + label: I18n.t(:setting_omniauth_direct_login_provider), + caption: I18n.t( + "settings.authentication.omniauth_direct_login_hint_html", + internal_path: internal_signin_url + ).html_safe, + include_blank: I18n.t(:label_none_parentheses) + ) do |select| + AuthProvider + .where(available: true) + .order("lower(display_name) ASC") + .select(:type, :display_name, :slug) + .to_a + .each do |provider| + select.option( + value: provider.slug, + label: "#{provider.display_name} (#{provider.human_type})", + selected: Setting.omniauth_direct_login_provider == provider.slug + ) + end + end + + form.select_list( + name: :autologin, + input_width: :medium, + label: I18n.t(:setting_autologin), + ) do |select| + select.option( + value: 0, + label: I18n.t(:label_disabled), + selected: Setting.autologin == 0 + ) + + Settings::Definition[:autologin].allowed.each do |days| + select.option( + value: days, + label: I18n.t("datetime.distance_in_words.x_days", count: days), + selected: Setting.autologin == days + ) + end + end + + form.check_box(name: :session_ttl_enabled) + + form.text_field(name: :session_ttl, + type: :number, + size: 6, + min: 0, + caption: I18n.t("setting_session_ttl_hint"), + trailing_visual: { text: { text: I18n.t(:label_minute_plural) } }, + input_width: :small) + + form.check_box(name: :log_requesting_user) + + form.text_field(name: :after_first_login_redirect_url, + caption: I18n.t(:setting_after_first_login_redirect_url_text_html).html_safe, + input_width: :large) + + form.text_field(name: :after_login_default_redirect_url, + caption: I18n.t(:setting_after_login_default_redirect_url_text_html).html_safe, + input_width: :large) + + form.submit + end + end +%> diff --git a/app/views/admin/settings/authentication_settings/_passwords.html.erb b/app/views/admin/settings/authentication_settings/_passwords.html.erb new file mode 100644 index 00000000000..dae58d29346 --- /dev/null +++ b/app/views/admin/settings/authentication_settings/_passwords.html.erb @@ -0,0 +1,109 @@ +<%#-- 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. + +++#%> + +<%= + admin_settings_primer_form_with(scope: :settings, + url: admin_settings_authentication_path(tab: params[:tab]), + data: { turbo_method: :patch }, + method: :patch) do |f| + render_inline_settings_form(f) do |form| + disabled = OpenProject::Configuration.disable_password_login? + + if disabled + form.html_content do + render Primer::Alpha::Banner.new( + scheme: :default, + icon: :info, + my: 2 + ) do + I18n.t( + :note_password_login_disabled, + configuration: static_link_to(:disable_password_login, label: I18n.t('label_configuration')) + ).html_safe + end + end + end + + form.text_field name: :password_min_length, + disabled:, + type: :number, + size: 6, + min: 1, + input_width: :small + + form.check_box_group(disabled:, label: I18n.t(:setting_password_active_rules)) do |group| + OpenProject::Passwords::Evaluator.known_rules.each do |rule| + group.check_box(name: "password_active_rule[]", label: I18n.t("label_password_rule_#{rule}")) + end + end + + form.text_field(name: :password_min_adhered_rules, + type: :number, + disabled:, + size: 6, + min: 0, + input_width: :small) + + form.text_field(name: :password_days_valid, + type: :number, + disabled:, + size: 6, + min: 0, + caption: I18n.t(:text_hint_disable_with_0), + input_width: :small) + + form.text_field(name: :password_count_former_banned, + type: :number, + disabled:, + size: 6, + min: 0, + input_width: :small) + + form.check_box(name: :lost_password, + disabled:) + + form.text_field(name: :brute_force_block_after_failed_logins, + type: :number, + disabled:, + size: 6, + min: 0, + caption: I18n.t(:text_hint_disable_with_0), + input_width: :small) + + form.text_field(name: :brute_force_block_minutes, + type: :number, + disabled:, + size: 6, + min: 0, + caption: I18n.t(:text_hint_disable_with_0), + trailing_visual: { text: { text: I18n.t(:label_minute_plural) } }, + input_width: :small) + end + end +%> diff --git a/app/views/admin/settings/authentication_settings/_registration.html.erb b/app/views/admin/settings/authentication_settings/_registration.html.erb new file mode 100644 index 00000000000..267dee4f2af --- /dev/null +++ b/app/views/admin/settings/authentication_settings/_registration.html.erb @@ -0,0 +1,84 @@ +<%#-- 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. + +++#%> + +<%= + admin_settings_primer_form_with(scope: :settings, + url: admin_settings_authentication_path(tab: params[:tab]), + data: { turbo_method: :patch }, + method: :patch) do |f| + render_inline_settings_form(f) do |form| + form.check_box( + name: :login_required, + caption: I18n.t(:setting_login_required_caption), + ) + + form.select_list( + name: :self_registration, + input_width: :medium, + label: I18n.t(:setting_self_registration), + include_blank: false + ) do |select| + Setting::SelfRegistration::VALUES.each do |key, value| + label = key == :disabled ? I18n.t(:label_disabled) : I18n.t("label_registration_#{key}") + select.option( + value:, + label:, + selected: Setting.self_registration == value + ) + end + end + + if Setting::SelfRegistration.unsupervised_registration? + form.html_content do + render Primer::Alpha::Banner.new( + scheme: :warning, + icon: :alert, + my: 2 + ) do + I18n.t(:setting_self_registration_warning) + end + end + end + + form.text_field( + type: :number, + min: 0, + max: 100, + input_width: :small, + trailing_visual: { text: { text: I18n.t("datetime.units.day.other") } }, + name: :invitation_expiration_days, + caption: I18n.t(:setting_invitation_expiration_days_caption), + ) + + form.multi_language_text_select(name: :registration_footer) + + form.submit + end + end +%> diff --git a/app/views/admin/settings/authentication_settings/show.html.erb b/app/views/admin/settings/authentication_settings/show.html.erb index 2b4ba182dc7..c4659022926 100644 --- a/app/views/admin/settings/authentication_settings/show.html.erb +++ b/app/views/admin/settings/authentication_settings/show.html.erb @@ -28,6 +28,28 @@ See COPYRIGHT and LICENSE files for more details. ++#%> <% html_title t(:label_administration), t(:label_authentication) -%> +<% tabs = [ + { + name: :login, + path: admin_settings_authentication_path(tab: :login), + label: :"settings.authentication.login_and_sso", + partial: "admin/settings/authentication_settings/login" + }, + { + name: :registration, + path: admin_settings_authentication_path(tab: :registration), + label: :"settings.authentication.registration", + partial: "admin/settings/authentication_settings/registration" + }, + { + name: :passwords, + path: admin_settings_authentication_path(tab: :passwords), + label: :"settings.passwords", + partial: "admin/settings/authentication_settings/passwords" + } +] +%> + <%= render Primer::OpenProject::PageHeader.new do |header| header.with_title { t(:label_authentication_settings) } @@ -36,159 +58,11 @@ See COPYRIGHT and LICENSE files for more details. { href: admin_settings_authentication_path, text: t(:label_authentication) }, t(:label_authentication_settings)] ) + render_tab_header_nav(header, tabs) end %> -<%= styled_form_tag(admin_settings_authentication_path, method: :patch) do %> -
-
- <%= I18n.t("settings.general") %> -
<%= setting_check_box :login_required %>
- -
- <%= setting_select :self_registration, [[t(:label_disabled), Setting::SelfRegistration.disabled.to_s], - [t(:label_registration_activation_by_email), Setting::SelfRegistration.by_email.to_s], - [t(:label_registration_manual_activation), Setting::SelfRegistration.manual.to_s], - [t(:label_registration_automatic_activation), Setting::SelfRegistration.automatic.to_s]], - container_class: "-middle" %> -
- <% if Setting::SelfRegistration.unsupervised_registration? %> - <%= - render Primer::Alpha::Banner.new( - scheme: :warning, - icon: :alert, - my: 2 - ) do - I18n.t(:setting_self_registration_warning) - end - %> - <% end %> -
-
- - -
- <%= setting_check_box :email_login, title: I18n.t("tooltip.setting_email_login") %> -
- - <%= 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 - ) %> - -
-
- -
- -
- -
- <%= I18n.t("settings.passwords") %> - <% if !OpenProject::Configuration.disable_password_login? %> -
<%= setting_text_field :password_min_length, size: 6, container_class: "-xslim" %>
-
- <% rules = OpenProject::Passwords::Evaluator.known_rules.map do |rule| - [t("label_password_rule_#{rule}"), rule] - end %> - <%= setting_multiselect :password_active_rules, rules %> -
-
<%= setting_text_field :password_min_adhered_rules, size: 6, container_class: "-xslim" %>
-
<%= setting_text_field :password_days_valid, size: 6, container_class: "-xslim" %> - - <%= t(:text_hint_disable_with_0) %> - -
-
<%= setting_text_field :password_count_former_banned, size: 6, container_class: "-xslim" %>
-
<%= setting_check_box :lost_password, label: :label_password_lost %>
- <% else %> -
- -
- <% end %> -
- - <% unless OpenProject::Configuration.disable_password_login? %> -
- <%= I18n.t("settings.brute_force_prevention") %> -
<%= setting_text_field :brute_force_block_after_failed_logins, container_class: "-xslim" %> - - <%= t(:text_hint_disable_with_0) %> - -
-
<%= setting_text_field :brute_force_block_minutes, unit: t(:label_minute_plural), container_class: "-xslim" %>
-
- <% end %> - -
- <%= I18n.t("settings.session") %> -
<%= setting_select :autologin, - ([[t(:label_disabled), 0]] + - Settings::Definition[:autologin].allowed.collect do |days| - [t("datetime.distance_in_words.x_days", count: days), - days.to_s] - end), - container_class: "-xslim" %> -
-
<%= setting_check_box :session_ttl_enabled %>
- -
- -
- <%= I18n.t("settings.other") %> -
<%= setting_check_box :log_requesting_user %>
- -
- <%= setting_text_field :after_first_login_redirect_url, container_class: "-middle" %> - - <%= t(:setting_after_first_login_redirect_url_text_html) %> - -
-
- <%= setting_text_field :after_login_default_redirect_url, container_class: "-middle" %> - - <%= t(:setting_after_login_default_redirect_url_text_html) %> - -
-
-
- <% unless OpenProject::Configuration.disable_password_login? %> -
- <%= link_to t(:label_ldap_authentication), { controller: "/ldap_auth_sources", action: "index" }, class: "icon icon-server-key" %> -
- <% end %> - <%= styled_button_tag t(:button_save), class: "-primary -with-icon icon-checkmark" %> -<% end %> +<%= + selected_tab = selected_tab(tabs) + render partial: "common/tabs", locals: { tabs:, selected_tab:, with_tab_nav: false } +%> diff --git a/config/constants/settings/definition.rb b/config/constants/settings/definition.rb index 03f3e58d4a4..e1241b4d3ca 100644 --- a/config/constants/settings/definition.rb +++ b/config/constants/settings/definition.rb @@ -979,7 +979,8 @@ module Settings default: nil }, self_registration: { - default: 2 + default: 2, + format: :integer }, sendmail_arguments: { description: "Arguments to call sendmail with in case it is configured as outgoing email setup", diff --git a/config/locales/en.yml b/config/locales/en.yml index e029cc1c5f4..22b2e6d980f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -3864,6 +3864,7 @@ en: This defines what is considered a "day" when displaying duration in days and hours (for example, if a day is 8 hours, 32 hours would be 4 days). setting_invitation_expiration_days: "Activation email expires after" + setting_invitation_expiration_days_caption: "Number of days after which the activation email expires." setting_work_package_done_ratio: "Progress calculation mode" setting_work_package_done_ratio_field: "Work-based" setting_work_package_done_ratio_field_caption_html: >- @@ -3883,6 +3884,9 @@ en: setting_journal_aggregation_time_minutes: "User actions aggregated within" setting_log_requesting_user: "Log user login, name, and mail address for all requests" setting_login_required: "Authentication required" + setting_login_required_caption: "When checked, all requests to the application have to be authenticated." + setting_lost_password: "Enable password reset" + setting_lost_password_caption: "When checked, allow users to reset their own passwords." setting_mail_from: "Emission email address" setting_mail_handler_api_key: "API key" setting_mail_handler_body_delimiters: "Truncate emails after one of these lines" @@ -3908,6 +3912,7 @@ en: setting_project_gantt_query_text: "You can modify the query that is used to display Gantt chart from the project overview page." setting_security_badge_displayed: "Display security badge" setting_registration_footer: "Registration footer" + setting_registration_footer_caption: "This text is displayed in the footer of the registration page. Use the HTML editor to format the text for each selected language." setting_repositories_automatic_managed_vendor: "Automatic repository vendor type" setting_repositories_encodings: "Repositories encodings" setting_repository_storage_cache_minutes: "Repository disk size cache" @@ -3951,6 +3956,8 @@ en: settings: authentication: + login_and_sso: "Login and SSO" + registration: "Registration" single_sign_on: "Single Sign-On" omniauth_direct_login_hint_html: > If this option is active, login requests will redirect to the configured omniauth provider. diff --git a/config/static_links.yml b/config/static_links.yml index 3b398f6891e..43a91cd09dd 100644 --- a/config/static_links.yml +++ b/config/static_links.yml @@ -185,3 +185,5 @@ ical_docs: href: https://www.openproject.org/docs/user-guide/calendar/#subscribe-to-a-calendar integrations: href: https://www.openproject.org/docs/system-admin-guide/integrations/ +disable_password_login: + href: https://www.openproject.org/docs/installation-and-operations/configuration/#disable-password-login diff --git a/frontend/src/app/core/setup/globals/global-listeners/settings.ts b/frontend/src/app/core/setup/globals/global-listeners/settings.ts index 0c01896dc86..4dac33b2ce0 100644 --- a/frontend/src/app/core/setup/globals/global-listeners/settings.ts +++ b/frontend/src/app/core/setup/globals/global-listeners/settings.ts @@ -32,9 +32,10 @@ export function listenToSettingChanges() { const id:string = self.attr('id') || ''; const settingName = id.replace('lang-for-', ''); const newLang = self.val() as string; - const textArea = jQuery(`#settings-${settingName}`); + const textAreaId = `#settings-${settingName}`; + const textArea = jQuery(textAreaId); /* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment */ - const editor = textArea.siblings('opce-ckeditor-augmented-textarea').data('editor'); + const editor = jQuery(`opce-ckeditor-augmented-textarea[data-textarea-selector='"${textAreaId}"'`).data('editor'); return { id, settingName, newLang, textArea, editor, diff --git a/lib/primer/open_project/forms/rich_text_area.html.erb b/lib/primer/open_project/forms/rich_text_area.html.erb index 385569de4c3..56122d61027 100644 --- a/lib/primer/open_project/forms/rich_text_area.html.erb +++ b/lib/primer/open_project/forms/rich_text_area.html.erb @@ -1,11 +1,11 @@ <%= render(FormControl.new(input: @input)) do %> <%= content_tag(:div, hidden: true) do %> - <%= builder.text_area(@input.name, **@input.input_arguments) %> + <%= builder.text_area(@input.name, **@input.input_arguments, id: @text_area_id) %> <% end %> <%= angular_component_tag "opce-ckeditor-augmented-textarea", inputs: @rich_text_options.reverse_merge( { - textareaSelector: "##{builder.field_id(@input.name)}", + textareaSelector: "##{@text_area_id}", macros: false, turboMode: true } diff --git a/lib/primer/open_project/forms/rich_text_area.rb b/lib/primer/open_project/forms/rich_text_area.rb index 1008a5d1f82..d5f984d7800 100644 --- a/lib/primer/open_project/forms/rich_text_area.rb +++ b/lib/primer/open_project/forms/rich_text_area.rb @@ -14,6 +14,7 @@ module Primer @input = input @rich_text_data = rich_text_options.delete(:data) { {} } @rich_text_options = rich_text_options + @text_area_id = rich_text_options[:text_area_id] || SecureRandom.alphanumeric(16) end end end diff --git a/lookbook/docs/patterns/02-forms.md.erb b/lookbook/docs/patterns/02-forms.md.erb index 42d5f7604d5..15c51564e54 100644 --- a/lookbook/docs/patterns/02-forms.md.erb +++ b/lookbook/docs/patterns/02-forms.md.erb @@ -507,7 +507,7 @@ end It is easier to write and read. -Under the hood, the form object is decorated with `SettingsFormDecorator`. +Under the hood, the form object is decorated with `Settings::FormDecorator`. That's where all the helper methods are defined. There aren't many for now, but this is intended to grow to support more advanced form features for administration pages. From 66a94b2be2975f6162f6298924fb82bf8776a97e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 25 Apr 2025 11:27:12 +0200 Subject: [PATCH 2/7] Introduce setting multi-lang helper --- app/forms/settings/form_decorator.rb | 172 ++++++++++++++++++++++ app/forms/settings/form_helper.rb | 90 ++++++++++++ app/forms/settings/multi_lang_form.rb | 69 +++++++++ app/forms/settings_form_decorator.rb | 200 -------------------------- app/helpers/tabs_helper.rb | 17 ++- app/views/common/_tabs.html.erb | 2 +- 6 files changed, 348 insertions(+), 202 deletions(-) create mode 100644 app/forms/settings/form_decorator.rb create mode 100644 app/forms/settings/form_helper.rb create mode 100644 app/forms/settings/multi_lang_form.rb delete mode 100644 app/forms/settings_form_decorator.rb diff --git a/app/forms/settings/form_decorator.rb b/app/forms/settings/form_decorator.rb new file mode 100644 index 00000000000..0192dfcecd9 --- /dev/null +++ b/app/forms/settings/form_decorator.rb @@ -0,0 +1,172 @@ +# 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. +#++ + +# Decorates a form object to provide a more convenient interface for +# rendering settings. +# +# It automatically sets the label, value, and disabled properties from the +# setting name and its definition attributes. +module Settings + class FormDecorator + include ::SettingsHelper + include ::ApplicationHelper + include FormHelper + + attr_reader :form + + # Initializes a new Settings::FormDecorator + # + # @param form [Object] The form object to be decorated + def initialize(form) + @form = form + end + + def method_missing(method, ...) + form.send(method, ...) + end + + def respond_to_missing?(method, include_private = false) + form.respond_to?(method, include_private) + end + + # Creates a text field input for a setting. + # + # The text field label is set from translating the key "setting_". + # + # Any options passed to this method will override the default options. + # + # @param name [Symbol] The name of the setting + # @param options [Hash] Additional options for the text field + # @return [Object] The text field input + def text_field(name:, **options) + options.reverse_merge!( + label: setting_label(name), + value: setting_value(name), + disabled: setting_disabled?(name) + ) + form.text_field(name:, **options) + end + + # Creates a check box input for a setting. + # + # The check box label is set from translating the key "setting_". + # + # Any options passed to this method will override the default options. + # + # @param name [Symbol] The name of the setting + # @param options [Hash] Additional options for the check box + # @return [Object] The check box input + def check_box(name:, **options) + options.reverse_merge!( + label: setting_label(name), + checked: setting_value(name), + disabled: setting_disabled?(name) + ) + form.check_box(name:, **options) + end + + # Creates a radio button group for a setting. + # + # The radio button group label is set from translating the key + # "setting_". The radio button label are set from translating the + # key "setting__". The caption is set from translating the + # key "setting___caption_html", which will be rendered as HTML, + # or "setting___caption", or nothing if none of the above + # are defined. + # + # Any options passed to this method will override the default options. + # + # @param name [Symbol] The name of the setting + # @param values [Array] The values for the radio buttons. Default to the + # setting's allowed values. + # @param disabled [Boolean] Force the radio button group to be disabled when + # true, will be disabled if the setting is not writable when false (default) + # @param button_options [Hash] Options for individual radio buttons + # @param options [Hash] Additional options for the radio button group + # @return [Object] The radio button group + def radio_button_group(name:, values: [], disabled: false, button_options: {}, **options) + values = values.presence || setting_allowed_values(name) + radio_group_options = options.reverse_merge( + label: setting_label(name), + disabled: disabled || setting_disabled?(name) + ) + form.radio_button_group( + name:, + **radio_group_options + ) do |radio_group| + values.each do |value| + radio_group.radio_button( + **button_options.reverse_merge( + value:, + checked: setting_value(name) == value, + autocomplete: "off", + label: setting_label(name, value), + caption: setting_caption(name, value) + ) + ) + end + end + end + + def multi_language_text_select(name:, current_language: I18n.locale.to_s) + # Add select list to switch + form.select_list( + name: :"#{name}_lang", # Should be excluded by settings params + input_width: :small, + id: "lang-for-#{name}", + class: "lang-select-switch", + label: setting_label(name), + caption: setting_caption(name), + include_blank: false + ) do |select| + lang_options_for_select(false).each do |label, value| + select.option( + value:, + label:, + selected: value == current_language + ) + end + end + + form.fields_for(name) do |builder| + MultiLangForm.new(builder, name:, current_language:) + end + end + + # Creates a save button to submit the form + # + # @return [Object] The submit button + def submit + form.submit(name: "submit", + label: I18n.t("button_save"), + scheme: :primary) + end + end +end diff --git a/app/forms/settings/form_helper.rb b/app/forms/settings/form_helper.rb new file mode 100644 index 00000000000..c203e84443e --- /dev/null +++ b/app/forms/settings/form_helper.rb @@ -0,0 +1,90 @@ +# 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 Settings + module FormHelper + # Returns a translated string for a setting name. + # + # The translation key is "setting_". Add additional names to the key + # to allow for translations with more context: + # "setting____...". + # + # @param names [Array] The name(s) of the setting + # @return [String] The translated label + def setting_label(*names) + I18n.t("setting_#{names.join('_')}") + end + + # Generates an HTML-safe caption for a setting. + # + # The translation key is "setting__caption". If not present, it will + # return nil. + # + # The translation will be marked as html_safe automatically if it ends with + # "_html", allowing to have HTML in the caption. + # + # Add additional names to the key to allow for translations with more context: + # "setting___caption_html" for instance. + # + # @param names [Array] The name(s) of the setting + # @return [String] The translated HTML-safe caption + def setting_caption(*names) + I18n.t("setting_#{names.join('_')}_caption_html", default: nil)&.html_safe \ + || I18n.t("setting_#{names.join('_')}_caption", default: nil) + end + + # Retrieves the current value of a setting + # + # @param name [Symbol] The name of the setting + # @return [Object] The value of the setting + def setting_value(name) + Setting[name] + end + + # Retrieves the allowed values for a setting's definition + # + # @param name [Symbol] The name of the setting + # @return [Array] The allowed values for the setting + def setting_allowed_values(name) + Settings::Definition[name].allowed + end + + # Checks if a setting is disabled. + # + # Any non-writable setting set by environment variables will be considered + # disabled. + # + # @param name [Symbol] The name of the setting + # @return [Boolean] `true` if the setting is disabled, `false` otherwise + def setting_disabled?(name) + !Setting.send(:"#{name}_writable?") + end + end +end diff --git a/app/forms/settings/multi_lang_form.rb b/app/forms/settings/multi_lang_form.rb new file mode 100644 index 00000000000..eed7b6caf64 --- /dev/null +++ b/app/forms/settings/multi_lang_form.rb @@ -0,0 +1,69 @@ +# 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 Settings + class MultiLangForm < ApplicationForm + include FormHelper + + attr_reader :name, :current_language + + def initialize(name:, current_language:) + super() + + @name = name + @current_language = current_language + end + + form do |f| + # Add hidden languages + Redmine::I18n.valid_languages.each do |lang| + f.hidden( + name: lang, + value: Setting.send(name)[lang], + id: "lang-for-#{name}-#{lang}" + ) + end + + # Add WYSIWYG + f.rich_text_area( + name: current_language, + value: setting_value(name)[current_language], + label: setting_label(name), + disabled: setting_disabled?(name), + visually_hide_label: true, + rich_text_options: { + text_area_id: "settings-#{name}", + turboMode: true, + showAttachments: false + } + ) + end + end +end diff --git a/app/forms/settings_form_decorator.rb b/app/forms/settings_form_decorator.rb deleted file mode 100644 index d7601d7816f..00000000000 --- a/app/forms/settings_form_decorator.rb +++ /dev/null @@ -1,200 +0,0 @@ -# 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. -#++ - -# Decorates a form object to provide a more convenient interface for -# rendering settings. -# -# It automatically sets the label, value, and disabled properties from the -# setting name and its definition attributes. -class SettingsFormDecorator - attr_reader :form - - # Initializes a new SettingsFormDecorator - # - # @param form [Object] The form object to be decorated - def initialize(form) - @form = form - end - - def method_missing(method, ...) - form.send(method, ...) - end - - def respond_to_missing?(method, include_private = false) - form.respond_to?(method, include_private) - end - - # Creates a text field input for a setting. - # - # The text field label is set from translating the key "setting_". - # - # Any options passed to this method will override the default options. - # - # @param name [Symbol] The name of the setting - # @param options [Hash] Additional options for the text field - # @return [Object] The text field input - def text_field(name:, **options) - options.reverse_merge!( - label: setting_label(name), - value: setting_value(name), - disabled: setting_disabled?(name) - ) - form.text_field(name:, **options) - end - - # Creates a check box input for a setting. - # - # The check box label is set from translating the key "setting_". - # - # Any options passed to this method will override the default options. - # - # @param name [Symbol] The name of the setting - # @param options [Hash] Additional options for the check box - # @return [Object] The check box input - def check_box(name:, **options) - options.reverse_merge!( - label: setting_label(name), - checked: setting_value(name), - disabled: setting_disabled?(name) - ) - form.check_box(name:, **options) - end - - # Creates a radio button group for a setting. - # - # The radio button group label is set from translating the key - # "setting_". The radio button label are set from translating the - # key "setting__". The caption is set from translating the - # key "setting___caption_html", which will be rendered as HTML, - # or "setting___caption", or nothing if none of the above - # are defined. - # - # Any options passed to this method will override the default options. - # - # @param name [Symbol] The name of the setting - # @param values [Array] The values for the radio buttons. Default to the - # setting's allowed values. - # @param disabled [Boolean] Force the radio button group to be disabled when - # true, will be disabled if the setting is not writable when false (default) - # @param button_options [Hash] Options for individual radio buttons - # @param options [Hash] Additional options for the radio button group - # @return [Object] The radio button group - def radio_button_group(name:, values: [], disabled: false, button_options: {}, **options) - values = values.presence || setting_allowed_values(name) - radio_group_options = options.reverse_merge( - label: setting_label(name), - disabled: disabled || setting_disabled?(name) - ) - form.radio_button_group( - name:, - **radio_group_options - ) do |radio_group| - values.each do |value| - radio_group.radio_button( - **button_options.reverse_merge( - value:, - checked: setting_value(name) == value, - autocomplete: "off", - label: setting_label(name, value), - caption: setting_caption(name, value) - ) - ) - end - end - end - - # Creates a save button to submit the form - # - # @return [Object] The submit button - def submit - form.submit(name: "submit", - label: I18n.t("button_save"), - scheme: :primary) - end - - protected - - # Returns a translated string for a setting name. - # - # The translation key is "setting_". Add additional names to the key - # to allow for translations with more context: - # "setting____...". - # - # @param names [Array] The name(s) of the setting - # @return [String] The translated label - def setting_label(*names) - I18n.t("setting_#{names.join('_')}") - end - - # Generates an HTML-safe caption for a setting. - # - # The translation key is "setting__caption". If not present, it will - # return nil. - # - # The translation will be marked as html_safe automatically if it ends with - # "_html", allowing to have HTML in the caption. - # - # Add additional names to the key to allow for translations with more context: - # "setting___caption_html" for instance. - # - # @param names [Array] The name(s) of the setting - # @return [String] The translated HTML-safe caption - def setting_caption(*names) - I18n.t("setting_#{names.join('_')}_caption_html", default: nil)&.html_safe \ - || I18n.t("setting_#{names.join('_')}_caption", default: nil) - end - - # Retrieves the current value of a setting - # - # @param name [Symbol] The name of the setting - # @return [Object] The value of the setting - def setting_value(name) - Setting[name] - end - - # Retrieves the allowed values for a setting's definition - # - # @param name [Symbol] The name of the setting - # @return [Array] The allowed values for the setting - def setting_allowed_values(name) - Settings::Definition[name].allowed - end - - # Checks if a setting is disabled. - # - # Any non-writable setting set by environment variables will be considered - # disabled. - # - # @param name [Symbol] The name of the setting - # @return [Boolean] `true` if the setting is disabled, `false` otherwise - def setting_disabled?(name) - !Setting.send(:"#{name}_writable?") - end -end diff --git a/app/helpers/tabs_helper.rb b/app/helpers/tabs_helper.rb index bbb1ed2e6c6..3772ac008c6 100644 --- a/app/helpers/tabs_helper.rb +++ b/app/helpers/tabs_helper.rb @@ -39,8 +39,23 @@ module TabsHelper end end + def render_tab_header_nav(header, tabs) + header.with_tab_nav(label: nil) do |tab_nav| + tabs.each do |tab| + tab_nav.with_tab(selected: selected_tab(tabs) == tab, href: tab[:path]) do |t| + feature = tab[:enterprise_feature] + + if feature && !EnterpriseToken.allows_to?(feature) + t.with_icon(icon: :"op-enterprise-addons", classes: "upsell-colored") + end + t.with_text { I18n.t(tab[:label]) } + end + end + end + end + def selected_tab(tabs) - tabs.detect { |t| t[:name] == params[:tab] } || tabs.first + tabs.detect { |t| t[:name].to_s == params[:tab].to_s } || tabs.first end def tabs_for_key(key, params = {}) diff --git a/app/views/common/_tabs.html.erb b/app/views/common/_tabs.html.erb index 532d369af58..ade5ac4a876 100644 --- a/app/views/common/_tabs.html.erb +++ b/app/views/common/_tabs.html.erb @@ -33,6 +33,6 @@ See COPYRIGHT and LICENSE files for more details. <% end %> <%= content_tag "div", - render(partial: selected_tab[:partial], locals: { f: f, tab: selected_tab }), + render(partial: selected_tab[:partial], locals: local_assigns.merge(tab: selected_tab)), id: "tab-content-#{selected_tab[:name]}", class: "tab-content" %> From 197e72e75d3922cbb4443e495bef55fb89db6fa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 25 Apr 2025 13:13:15 +0200 Subject: [PATCH 3/7] Replace view spec with request spec --- .../settings/authentication_settings_spec.rb | 71 +++++++++++++++++++ .../authentication/show.html.erb_spec.rb | 61 ---------------- 2 files changed, 71 insertions(+), 61 deletions(-) create mode 100644 spec/requests/admin/settings/authentication_settings_spec.rb delete mode 100644 spec/views/admin/settings/authentication/show.html.erb_spec.rb diff --git a/spec/requests/admin/settings/authentication_settings_spec.rb b/spec/requests/admin/settings/authentication_settings_spec.rb new file mode 100644 index 00000000000..2fddbc476f0 --- /dev/null +++ b/spec/requests/admin/settings/authentication_settings_spec.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. +#++ + +require "spec_helper" + +RSpec.describe "Authentication Settings", + :skip_csrf, + type: :rails_request do + let(:admin) { create(:admin) } + + before do + login_as(admin) + end + + describe "GET /admin/settings/authentication?tab=passwords" do + context "with password login enabled" do + before do + allow(OpenProject::Configuration).to receive(:disable_password_login?).and_return(false) + get "/admin/settings/authentication.html?tab=passwords" + end + + it "shows password settings" do + expect(response).to have_http_status(:success) + + expect(page).to have_field(I18n.t(:setting_lost_password), disabled: false) + expect(page).to have_field(I18n.t(:setting_brute_force_block_after_failed_logins), disabled: false) + end + end + + context "with password login disabled" do + before do + allow(OpenProject::Configuration).to receive(:disable_password_login?).and_return(true) + get "/admin/settings/authentication.html?tab=passwords" + end + + it "disables password settings" do + expect(response).to have_http_status(:success) + + expect(page).to have_field(I18n.t(:setting_lost_password), disabled: true) + expect(page).to have_field(I18n.t(:setting_brute_force_block_after_failed_logins), disabled: true) + end + end + end +end diff --git a/spec/views/admin/settings/authentication/show.html.erb_spec.rb b/spec/views/admin/settings/authentication/show.html.erb_spec.rb deleted file mode 100644 index 13481fd3e8c..00000000000 --- a/spec/views/admin/settings/authentication/show.html.erb_spec.rb +++ /dev/null @@ -1,61 +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 "admin/settings/authentication_settings/show" do - context "with password login enabled" do - before do - allow(OpenProject::Configuration).to receive(:disable_password_login?).and_return(false) - render - end - - it "shows password settings" do - expect(rendered).to have_text I18n.t("label_password_lost") - end - - it "shows automated user blocking options" do - expect(rendered).to have_text I18n.t("settings.brute_force_prevention") - end - end - - context "with password login disabled" do - before do - allow(OpenProject::Configuration).to receive(:disable_password_login?).and_return(true) - render - end - - it "does not show password settings" do - expect(rendered).to have_no_text I18n.t("label_password_lost") - end - - it "does not show automated user blocking options" do - expect(rendered).to have_no_text I18n.t("settings.brute_force_prevention") - end - end -end From 1d0ea180f0a29d32ca84928569ef4f08ec02d13e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 25 Apr 2025 13:55:22 +0200 Subject: [PATCH 4/7] Adapt admin spec --- .../authentication_settings/_passwords.html.erb | 14 +++++++++++--- lib/open_project/passwords.rb | 4 ++++ spec/features/users/password_change_spec.rb | 17 ++++++++--------- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/app/views/admin/settings/authentication_settings/_passwords.html.erb b/app/views/admin/settings/authentication_settings/_passwords.html.erb index dae58d29346..22e4c01f565 100644 --- a/app/views/admin/settings/authentication_settings/_passwords.html.erb +++ b/app/views/admin/settings/authentication_settings/_passwords.html.erb @@ -57,9 +57,14 @@ See COPYRIGHT and LICENSE files for more details. min: 1, input_width: :small - form.check_box_group(disabled:, label: I18n.t(:setting_password_active_rules)) do |group| - OpenProject::Passwords::Evaluator.known_rules.each do |rule| - group.check_box(name: "password_active_rule[]", label: I18n.t("label_password_rule_#{rule}")) + form.check_box_group(name: :password_active_rules, + disabled:, + label: I18n.t(:setting_password_active_rules)) do |group| + OpenProject::Passwords::Evaluator.known_rules.each do |value| + group.check_box(value:, + label: I18n.t("label_password_rule_#{value}"), + checked: OpenProject::Passwords::Evaluator.active_rule?(value), + ) end end @@ -68,6 +73,7 @@ See COPYRIGHT and LICENSE files for more details. disabled:, size: 6, min: 0, + max: 4, input_width: :small) form.text_field(name: :password_days_valid, @@ -104,6 +110,8 @@ See COPYRIGHT and LICENSE files for more details. caption: I18n.t(:text_hint_disable_with_0), trailing_visual: { text: { text: I18n.t(:label_minute_plural) } }, input_width: :small) + + form.submit end end %> diff --git a/lib/open_project/passwords.rb b/lib/open_project/passwords.rb index df62e3a6a75..40602b1a7ef 100644 --- a/lib/open_project/passwords.rb +++ b/lib/open_project/passwords.rb @@ -73,6 +73,10 @@ module OpenProject Setting.password_active_rules end + def self.active_rule?(rule) + Setting.password_active_rules.include?(rule.to_s) + end + # Checks whether password adheres to complexity rules. # Does not check length. def self.password_conforms_to_rules(password) diff --git a/spec/features/users/password_change_spec.rb b/spec/features/users/password_change_spec.rb index 652edbb4c32..38c6133bbc8 100644 --- a/spec/features/users/password_change_spec.rb +++ b/spec/features/users/password_change_spec.rb @@ -113,23 +113,22 @@ RSpec.describe "random password generation", :js do end it "can configure and enforce password rules" do - visit admin_settings_authentication_path - expect_angular_frontend_initialized + visit admin_settings_authentication_path(tab: :passwords) # Enforce rules # 3 of 'lowercase, uppercase, special' - find(".form--check-box[value=uppercase]").set true - find(".form--check-box[value=lowercase]").set true - find(".form--check-box[value=numeric]").set false - find(".form--check-box[value=special]").set true + check "Lowercase" + check "Uppercase" + check "Special" + uncheck "Numeric" # Set min length to 4 - find_by_id("settings_password_min_length").set 4 + fill_in "Minimum length", with: 4 # Set min classes to 3 - find_by_id("settings_password_min_adhered_rules").set 3 + fill_in "Minimum number of required classes", with: 3 - scroll_to_and_click(find(".button", text: "Save")) + click_on "Save" expect_flash(message: "Successful update.") Setting.clear_cache From ff89d259accdf548d1cb136bbe223852d5e301c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Fri, 25 Apr 2025 14:19:31 +0200 Subject: [PATCH 5/7] Use radio group for self register --- app/forms/settings/form_decorator.rb | 32 +++++++++++++------ .../_registration.html.erb | 19 +++-------- config/locales/en.yml | 22 +++++++++++-- 3 files changed, 46 insertions(+), 27 deletions(-) diff --git a/app/forms/settings/form_decorator.rb b/app/forms/settings/form_decorator.rb index 0192dfcecd9..cafe20c91e6 100644 --- a/app/forms/settings/form_decorator.rb +++ b/app/forms/settings/form_decorator.rb @@ -104,8 +104,10 @@ module Settings # Any options passed to this method will override the default options. # # @param name [Symbol] The name of the setting - # @param values [Array] The values for the radio buttons. Default to the + # @param values [Hash|Array] The values for the radio buttons. Default to the # setting's allowed values. + # If a hash is provided, it is assumed it provides a :name (to derive the labels) and a :value key. + # Other keys are used as arguments to the radio_button. # @param disabled [Boolean] Force the radio button group to be disabled when # true, will be disabled if the setting is not writable when false (default) # @param button_options [Hash] Options for individual radio buttons @@ -122,15 +124,25 @@ module Settings **radio_group_options ) do |radio_group| values.each do |value| - radio_group.radio_button( - **button_options.reverse_merge( - value:, - checked: setting_value(name) == value, - autocomplete: "off", - label: setting_label(name, value), - caption: setting_caption(name, value) - ) - ) + args = + if value.is_a?(Hash) + value.reverse_merge( + checked: setting_value(name) == value[:value], + autocomplete: "off", + label: setting_label(name, value[:name]), + caption: setting_caption(name, value[:name]) + ) + else + { + value:, + checked: setting_value(name) == value, + autocomplete: "off", + label: setting_label(name, value), + caption: setting_caption(name, value) + } + end + + radio_group.radio_button(**button_options.reverse_merge(args)) end end end diff --git a/app/views/admin/settings/authentication_settings/_registration.html.erb b/app/views/admin/settings/authentication_settings/_registration.html.erb index 267dee4f2af..50c11d5588c 100644 --- a/app/views/admin/settings/authentication_settings/_registration.html.erb +++ b/app/views/admin/settings/authentication_settings/_registration.html.erb @@ -36,23 +36,14 @@ See COPYRIGHT and LICENSE files for more details. form.check_box( name: :login_required, caption: I18n.t(:setting_login_required_caption), - ) + ) - form.select_list( + form.radio_button_group( name: :self_registration, - input_width: :medium, label: I18n.t(:setting_self_registration), - include_blank: false - ) do |select| - Setting::SelfRegistration::VALUES.each do |key, value| - label = key == :disabled ? I18n.t(:label_disabled) : I18n.t("label_registration_#{key}") - select.option( - value:, - label:, - selected: Setting.self_registration == value - ) - end - end + caption: I18n.t(:setting_self_registration_caption), + values: Setting::SelfRegistration::VALUES.map { |name, value| { name:, value: } } + ) if Setting::SelfRegistration.unsupervised_registration? form.html_content do diff --git a/config/locales/en.yml b/config/locales/en.yml index 22b2e6d980f..8cd7517473d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2974,9 +2974,6 @@ en: label_register: "Create a new account" label_register_with_developer: "Register as developer" label_registered_on: "Registered on" - label_registration_activation_by_email: "account activation by email" - label_registration_automatic_activation: "automatic account activation" - label_registration_manual_activation: "manual account activation" label_related_work_packages: "Related work packages" label_relates: "related to" label_relates_to: "related to" @@ -3923,10 +3920,29 @@ en: setting_repository_truncate_at: "Maximum number of files displayed in the repository browser" setting_rest_api_enabled: "Enable REST web service" setting_self_registration: "Self-registration" + setting_self_registration_caption: > + Choose the self-registration mechanism for users. Be careful with the setting you choose, as some + options allow users to activate their own accounts to this instance. setting_self_registration_warning: > The user will be able to activate their own accounts. Please note that this will give them access to all public projects and their content. Please make sure that no sensitive or private data is exposed in public projects. + setting_self_registration_disabled: "Disabled" + setting_self_registration_disabled_caption: > + No accounts can be registered on their own. Only administrators and users with the global permission + to create new users are able to create new accounts. + setting_self_registration_activation_by_email: "Account activation by email" + setting_self_registration_activation_by_email_caption: > + Users can register on their own and activate their account after confirming their email address. + Administrators have no moderation control over the activation process. + setting_self_registration_automatic_activation: "Automatic account activation" + setting_self_registration_automatic_activation_caption: > + Users can register on their own. Their accounts are immediately active without further action. + Administrators have no moderation control over the activation process. + setting_self_registration_manual_activation: "Manual account activation" + setting_self_registration_manual_activation_caption: > + Users can register on their own. Their accounts are in a pending state until an administrator + or user with the global permission to create or manage users activates them. setting_session_ttl: "Session expiry time after inactivity" setting_session_ttl_hint: "Value below 5 works like disabled" setting_session_ttl_enabled: "Session expires" From 8c7051fbebbd6214d18a99ea61ef8cc76b22fd0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 29 Apr 2025 10:00:29 +0200 Subject: [PATCH 6/7] Add test selector, fix name label association --- app/forms/settings/form_decorator.rb | 2 +- .../ckeditor-augmented-textarea.component.ts | 9 +++++---- .../text_formatting/formats/markdown/helper.rb | 2 +- lib/primer/open_project/forms/rich_text_area.html.erb | 6 ++++-- lib/primer/open_project/forms/rich_text_area.rb | 3 ++- .../projects/settings/general/show_component_spec.rb | 5 +++-- spec/forms/projects/settings/description_form_spec.rb | 3 +-- spec/forms/projects/settings/status_form_spec.rb | 2 +- spec/support/form_fields/primerized/editor_form_field.rb | 2 +- spec/support/pages/admin/system_settings/general.rb | 2 +- 10 files changed, 20 insertions(+), 16 deletions(-) diff --git a/app/forms/settings/form_decorator.rb b/app/forms/settings/form_decorator.rb index cafe20c91e6..74b305a9ac5 100644 --- a/app/forms/settings/form_decorator.rb +++ b/app/forms/settings/form_decorator.rb @@ -113,7 +113,7 @@ module Settings # @param button_options [Hash] Options for individual radio buttons # @param options [Hash] Additional options for the radio button group # @return [Object] The radio button group - def radio_button_group(name:, values: [], disabled: false, button_options: {}, **options) + def radio_button_group(name:, values: [], disabled: false, button_options: {}, **options) # rubocop:disable Metrics/AbcSize values = values.presence || setting_allowed_values(name) radio_group_options = options.reverse_merge( label: setting_label(name), diff --git a/frontend/src/app/shared/components/editor/components/ckeditor-augmented-textarea/ckeditor-augmented-textarea.component.ts b/frontend/src/app/shared/components/editor/components/ckeditor-augmented-textarea/ckeditor-augmented-textarea.component.ts index 0eed7c63d8a..c0e6bfd7429 100644 --- a/frontend/src/app/shared/components/editor/components/ckeditor-augmented-textarea/ckeditor-augmented-textarea.component.ts +++ b/frontend/src/app/shared/components/editor/components/ckeditor-augmented-textarea/ckeditor-augmented-textarea.component.ts @@ -66,7 +66,7 @@ import { uniqueId } from 'lodash'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class CkeditorAugmentedTextareaComponent extends UntilDestroyedMixin implements OnInit { - @Input() public textareaSelector:string; + @Input() public textAreaId:string; @Input() public previewContext:string; @@ -144,7 +144,9 @@ export class CkeditorAugmentedTextareaComponent extends UntilDestroyedMixin impl this.halResource = this.resource ? this.halResourceService.createHalResource(this.resource, true) : undefined; this.formElement = this.element.closest('form') as HTMLFormElement; - this.wrappedTextArea = this.formElement.querySelector(this.textareaSelector) as HTMLTextAreaElement; + + this.wrappedTextArea = document.getElementById(this.textAreaId) as HTMLTextAreaElement; + this.wrappedTextArea.style.display = 'none'; this.wrappedTextArea.required = false; this.initialContent = this.wrappedTextArea.value; @@ -286,8 +288,7 @@ export class CkeditorAugmentedTextareaComponent extends UntilDestroyedMixin impl } private setLabel() { - const textareaId = this.textareaSelector.substring(1); - const label = document.querySelector(`label[for=${textareaId}]`)!; + const label = document.querySelector(`label[for=${this.textAreaId}]`)!; const ckContent = this.element.querySelector('.ck-content')!; diff --git a/lib/open_project/text_formatting/formats/markdown/helper.rb b/lib/open_project/text_formatting/formats/markdown/helper.rb index 90cee1c84ad..46f1cbfaa89 100644 --- a/lib/open_project/text_formatting/formats/markdown/helper.rb +++ b/lib/open_project/text_formatting/formats/markdown/helper.rb @@ -51,7 +51,7 @@ module OpenProject::TextFormatting::Formats resource = context.fetch(:resource, {}) helpers.angular_component_tag "opce-ckeditor-augmented-textarea", inputs: { - textareaSelector: "##{field_id}", + textAreaId: field_id, editorType: context[:editor_type] || "full", previewContext: context[:preview_context], resource:, diff --git a/lib/primer/open_project/forms/rich_text_area.html.erb b/lib/primer/open_project/forms/rich_text_area.html.erb index 56122d61027..0379d716982 100644 --- a/lib/primer/open_project/forms/rich_text_area.html.erb +++ b/lib/primer/open_project/forms/rich_text_area.html.erb @@ -1,11 +1,13 @@ <%= render(FormControl.new(input: @input)) do %> <%= content_tag(:div, hidden: true) do %> - <%= builder.text_area(@input.name, **@input.input_arguments, id: @text_area_id) %> + <%= builder.text_area(@input.name, + id: @text_area_id, + **@input.input_arguments) %> <% end %> <%= angular_component_tag "opce-ckeditor-augmented-textarea", inputs: @rich_text_options.reverse_merge( { - textareaSelector: "##{@text_area_id}", + textAreaId: @text_area_id, macros: false, turboMode: true } diff --git a/lib/primer/open_project/forms/rich_text_area.rb b/lib/primer/open_project/forms/rich_text_area.rb index d5f984d7800..c0276720a62 100644 --- a/lib/primer/open_project/forms/rich_text_area.rb +++ b/lib/primer/open_project/forms/rich_text_area.rb @@ -13,8 +13,9 @@ module Primer super() @input = input @rich_text_data = rich_text_options.delete(:data) { {} } + @rich_text_data[:"test-selector"] ||= "augmented-text-area-#{@input.name}" @rich_text_options = rich_text_options - @text_area_id = rich_text_options[:text_area_id] || SecureRandom.alphanumeric(16) + @text_area_id = rich_text_options.delete(:text_area_id) || builder.field_id(@input.name) end end end diff --git a/spec/components/projects/settings/general/show_component_spec.rb b/spec/components/projects/settings/general/show_component_spec.rb index ce171c8be2c..53f77b7a423 100644 --- a/spec/components/projects/settings/general/show_component_spec.rb +++ b/spec/components/projects/settings/general/show_component_spec.rb @@ -60,7 +60,7 @@ RSpec.describe Projects::Settings::General::ShowComponent, type: :component do it "renders fields" do expect(render_component).to have_field "Name", required: true expect(render_component).to have_element "opce-ckeditor-augmented-textarea", - "data-textarea-selector": "\"#project_description\"" + "data-test-selector": "augmented-text-area-description" end end @@ -70,8 +70,9 @@ RSpec.describe Projects::Settings::General::ShowComponent, type: :component do it "renders fields" do expect(render_component).to have_element "opce-autocompleter", "data-input-name": "\"project[status_code]\"" + expect(render_component).to have_element "opce-ckeditor-augmented-textarea", - "data-textarea-selector": "\"#project_status_explanation\"" + "data-test-selector": "augmented-text-area-description" end end diff --git a/spec/forms/projects/settings/description_form_spec.rb b/spec/forms/projects/settings/description_form_spec.rb index 82616182078..155ad22f706 100644 --- a/spec/forms/projects/settings/description_form_spec.rb +++ b/spec/forms/projects/settings/description_form_spec.rb @@ -38,7 +38,6 @@ RSpec.describe Projects::Settings::DescriptionForm, type: :forms do it "renders field" do expect(page).to have_field "Description", with: "example description", visible: :hidden expect(page).to have_element "opce-ckeditor-augmented-textarea", - "data-textarea-selector": "\"#project_description\"" - expect(page).to have_element "opce-ckeditor-augmented-textarea", "data-qa-field-name": "description" + "data-test-selector": "augmented-text-area-description" end end diff --git a/spec/forms/projects/settings/status_form_spec.rb b/spec/forms/projects/settings/status_form_spec.rb index 363be91a80b..8e0683ffa91 100644 --- a/spec/forms/projects/settings/status_form_spec.rb +++ b/spec/forms/projects/settings/status_form_spec.rb @@ -51,7 +51,7 @@ RSpec.describe Projects::Settings::StatusForm, type: :forms do it "renders status description field" do expect(page).to have_field "Project status description", with: "example status info", visible: :hidden expect(page).to have_element "opce-ckeditor-augmented-textarea", - "data-textarea-selector": "\"#project_status_explanation\"" + "data-test-selector": "augmented-text-area-status_explanation" expect(page).to have_element "opce-ckeditor-augmented-textarea", "data-qa-field-name": "statusExplanation" end end diff --git a/spec/support/form_fields/primerized/editor_form_field.rb b/spec/support/form_fields/primerized/editor_form_field.rb index b8e6c8b362e..bf42ed276b6 100644 --- a/spec/support/form_fields/primerized/editor_form_field.rb +++ b/spec/support/form_fields/primerized/editor_form_field.rb @@ -14,7 +14,7 @@ module FormFields end def field_container - augmented_textarea = page.find("[data-textarea-selector='\"#project_custom_field_values_#{property.id}\"']") + augmented_textarea = page.find("[data-text-area-id='\"project_custom_field_values_#{property.id}\"']") augmented_textarea.first(:xpath, ".//..") end diff --git a/spec/support/pages/admin/system_settings/general.rb b/spec/support/pages/admin/system_settings/general.rb index 011f047ae9c..f598b52b4ea 100644 --- a/spec/support/pages/admin/system_settings/general.rb +++ b/spec/support/pages/admin/system_settings/general.rb @@ -39,7 +39,7 @@ module Pages::Admin::SystemSettings end def welcome_text_selector - 'opce-ckeditor-augmented-textarea[data-textarea-selector="\"#settings_welcome_text\""]' + 'opce-ckeditor-augmented-textarea[data-text-area-id="\"settings_welcome_text\""]' end end end From b30776f9f7367963206b23482f3351b3f625b8f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20G=C3=BCnther?= Date: Tue, 29 Apr 2025 17:28:40 +0200 Subject: [PATCH 7/7] Change label --- .../admin/settings/authentication_settings/show.html.erb | 4 ++-- config/initializers/menus.rb | 2 +- config/locales/en.yml | 4 +++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/views/admin/settings/authentication_settings/show.html.erb b/app/views/admin/settings/authentication_settings/show.html.erb index c4659022926..d99a6730281 100644 --- a/app/views/admin/settings/authentication_settings/show.html.erb +++ b/app/views/admin/settings/authentication_settings/show.html.erb @@ -52,11 +52,11 @@ See COPYRIGHT and LICENSE files for more details. <%= render Primer::OpenProject::PageHeader.new do |header| - header.with_title { t(:label_authentication_settings) } + header.with_title { t("authentication.login_and_registration") } header.with_breadcrumbs( [{ href: admin_index_path, text: t(:label_administration) }, { href: admin_settings_authentication_path, text: t(:label_authentication) }, - t(:label_authentication_settings)] + t("authentication.login_and_registration")] ) render_tab_header_nav(header, tabs) end diff --git a/config/initializers/menus.rb b/config/initializers/menus.rb index 18519a9a37f..497b954f6cf 100644 --- a/config/initializers/menus.rb +++ b/config/initializers/menus.rb @@ -502,7 +502,7 @@ Redmine::MenuManager.map :admin_menu do |menu| menu.push :authentication_settings, { controller: "/admin/settings/authentication_settings", action: :show }, if: ->(_) { User.current.admin? }, - caption: :label_authentication_settings, + caption: :"authentication.login_and_registration", parent: :authentication menu.push :ldap_authentication, diff --git a/config/locales/en.yml b/config/locales/en.yml index 8cd7517473d..44405e749df 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -112,6 +112,9 @@ en: text: "Individual actions of a user (e.g. updating a work package twice) are aggregated into a single action if their age difference is less than the specified timespan. They will be displayed as a single action within the application. This will also delay notifications by the same amount of time reducing the number of emails being sent and will also affect %{webhook_link} delay." link: "webhook" + authentication: + login_and_registration: "Login and registration" + announcements: show_until: Show until is_active: currently displayed @@ -2571,7 +2574,6 @@ en: label_ldap_auth_source_plural: "LDAP connections" label_attribute_expand_text: "The complete text for '%{attribute}'" label_authentication: "Authentication" - label_authentication_settings: "Authentication settings" label_available_custom_fields_projects: "Available custom fields projects" label_available_global_roles: "Available global roles" label_available_project_attributes: "Available project attributes"