Merge pull request #18734 from opf/chore/primerize-auth-settings

Primerize authentication settings page
This commit is contained in:
Oliver Günther
2025-04-30 09:27:00 +02:00
committed by GitHub
31 changed files with 800 additions and 434 deletions
@@ -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
%>
+1 -1
View File
@@ -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
+184
View File
@@ -0,0 +1,184 @@
# 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_<name>".
#
# 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_<name>".
#
# 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_<name>". The radio button label are set from translating the
# key "setting_<name>_<value>". The caption is set from translating the
# key "setting_<name>_<value>_caption_html", which will be rendered as HTML,
# or "setting_<name>_<value>_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 [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
# @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) # rubocop:disable Metrics/AbcSize
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|
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
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
+90
View File
@@ -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_<name>". Add additional names to the key
# to allow for translations with more context:
# "setting_<name>_<param2>_<param3>_...".
#
# @param names [Array<String | Symbol>] 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_<name>_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_<name>_<context>_caption_html" for instance.
#
# @param names [Array<Symbol>] 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
@@ -1,4 +1,6 @@
#-- copyright
# frozen_string_literal: true
# -- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
@@ -24,38 +26,44 @@
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++
# ++
require "spec_helper"
module Settings
class MultiLangForm < ApplicationForm
include FormHelper
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
attr_reader :name, :current_language
def initialize(name:, current_language:)
super()
@name = name
@current_language = current_language
end
it "shows password settings" do
expect(rendered).to have_text I18n.t("label_password_lost")
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
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")
# 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
-200
View File
@@ -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_<name>".
#
# 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_<name>".
#
# 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_<name>". The radio button label are set from translating the
# key "setting_<name>_<value>". The caption is set from translating the
# key "setting_<name>_<value>_caption_html", which will be rendered as HTML,
# or "setting_<name>_<value>_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_<name>". Add additional names to the key
# to allow for translations with more context:
# "setting_<name>_<param2>_<param3>_...".
#
# @param names [Array<String | Symbol>] 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_<name>_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_<name>_<context>_caption_html" for instance.
#
# @param names [Array<Symbol>] 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
+16 -1
View File
@@ -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 = {})
+8 -4
View File
@@ -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?
@@ -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
%>
@@ -0,0 +1,117 @@
<%#-- 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(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
form.text_field(name: :password_min_adhered_rules,
type: :number,
disabled:,
size: 6,
min: 0,
max: 4,
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)
form.submit
end
end
%>
@@ -0,0 +1,75 @@
<%#-- 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.radio_button_group(
name: :self_registration,
label: I18n.t(:setting_self_registration),
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
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
%>
@@ -28,167 +28,41 @@ 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) }
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
%>
<%= styled_form_tag(admin_settings_authentication_path, method: :patch) do %>
<section class="form--section">
<fieldset class="form--fieldset">
<legend class="form--fieldset-legend"><%= I18n.t("settings.general") %></legend>
<div class="form--field"><%= setting_check_box :login_required %></div>
<div class="form--field">
<%= 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" %>
<div class="form--field-instructions">
<% 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 %>
</div>
</div>
<div class="form--field">
<%= setting_check_box :email_login, title: I18n.t("tooltip.setting_email_login") %>
</div>
<%= render Settings::NumericSettingComponent.new("invitation_expiration_days", unit: "days") %>
</fieldset>
<fieldset class="form--fieldset">
<legend class="form--fieldset-legend"><%= I18n.t(:"settings.authentication.single_sign_on") %></legend>
<div class="form--field">
<% 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" %>
<span class="form--field-instructions">
<%= t(
"settings.authentication.omniauth_direct_login_hint_html",
internal_path: internal_signin_url
) %>
</span>
</div>
</fieldset>
<fieldset class="form--fieldset">
<fieldset id="registration_footer" class="form--fieldset">
<legend class="form--fieldset-legend"><%= I18n.t(:setting_registration_footer) %></legend>
<%= render Settings::TextSettingComponent.new(I18n.locale, name: "registration_footer") %>
</fieldset>
</fieldset>
<fieldset class="form--fieldset">
<legend class="form--fieldset-legend"><%= I18n.t("settings.passwords") %></legend>
<% if !OpenProject::Configuration.disable_password_login? %>
<div class="form--field -wide-label"><%= setting_text_field :password_min_length, size: 6, container_class: "-xslim" %></div>
<div class="form--field -wide-label">
<% rules = OpenProject::Passwords::Evaluator.known_rules.map do |rule|
[t("label_password_rule_#{rule}"), rule]
end %>
<%= setting_multiselect :password_active_rules, rules %>
</div>
<div class="form--field -wide-label"><%= setting_text_field :password_min_adhered_rules, size: 6, container_class: "-xslim" %></div>
<div class="form--field -wide-label"><%= setting_text_field :password_days_valid, size: 6, container_class: "-xslim" %>
<span class="form--field-instructions">
<%= t(:text_hint_disable_with_0) %>
</span>
</div>
<div class="form--field -wide-label"><%= setting_text_field :password_count_former_banned, size: 6, container_class: "-xslim" %></div>
<div class="form--field -wide-label"><%= setting_check_box :lost_password, label: :label_password_lost %></div>
<% else %>
<div class="form--field -wide-label">
<label><b><%= I18n.t :note %>: </b>
<%=
url = "https://www.openproject.org/docs/installation-and-operations/configuration/#disable-password-login"
explanation = I18n.t :note_password_login_disabled,
configuration: "<a target=\"_blank\" href=\"#{url}\"> #{I18n.t('label_configuration')}</a>"
explanation.html_safe
%>
</label>
</div>
<% end %>
</fieldset>
<% unless OpenProject::Configuration.disable_password_login? %>
<fieldset class="form--fieldset">
<legend class="form--fieldset-legend"><%= I18n.t("settings.brute_force_prevention") %></legend>
<div class="form--field -wide-label"><%= setting_text_field :brute_force_block_after_failed_logins, container_class: "-xslim" %>
<span class="form--field-instructions">
<%= t(:text_hint_disable_with_0) %>
</span>
</div>
<div class="form--field -wide-label"><%= setting_text_field :brute_force_block_minutes, unit: t(:label_minute_plural), container_class: "-xslim" %></div>
</fieldset>
<% end %>
<fieldset class="form--fieldset">
<legend class="form--fieldset-legend"><%= I18n.t("settings.session") %></legend>
<div class="form--field -wide-label"><%= 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" %>
</div>
<div class="form--field -wide-label"><%= setting_check_box :session_ttl_enabled %></div>
<div class="form--field -wide-label" id="settings_session_ttl_container" style="display:none;">
<%= setting_text_field :session_ttl, unit: t(:label_minute_plural), container_class: "-xslim" %>
<span class="form--field-instructions">
<%= I18n.t("setting_session_ttl_hint") %>
</span>
</div>
</fieldset>
<fieldset class="form--fieldset">
<legend class="form--fieldset-legend"><%= I18n.t("settings.other") %></legend>
<div class="form--field -wide-label"><%= setting_check_box :log_requesting_user %></div>
<div class="form--field -wide-label">
<%= setting_text_field :after_first_login_redirect_url, container_class: "-middle" %>
<span class="form--field-instructions">
<%= t(:setting_after_first_login_redirect_url_text_html) %>
</span>
</div>
<div class="form--field -wide-label">
<%= setting_text_field :after_login_default_redirect_url, container_class: "-middle" %>
<span class="form--field-instructions">
<%= t(:setting_after_login_default_redirect_url_text_html) %>
</span>
</div>
</fieldset>
</section>
<% unless OpenProject::Configuration.disable_password_login? %>
<div style="float:right;">
<%= link_to t(:label_ldap_authentication), { controller: "/ldap_auth_sources", action: "index" }, class: "icon icon-server-key" %>
</div>
<% 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 }
%>
+1 -1
View File
@@ -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" %>
+2 -1
View File
@@ -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",
+1 -1
View File
@@ -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,
+29 -4
View File
@@ -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"
@@ -2974,9 +2976,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"
@@ -3864,6 +3863,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 +3883,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 +3911,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"
@@ -3918,10 +3922,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"
@@ -3951,6 +3974,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.
+2
View File
@@ -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
@@ -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,
@@ -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<HTMLFormElement>('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<HTMLLabelElement>(`label[for=${textareaId}]`)!;
const label = document.querySelector<HTMLLabelElement>(`label[for=${this.textAreaId}]`)!;
const ckContent = this.element.querySelector<HTMLElement>('.ck-content')!;
+4
View File
@@ -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)
@@ -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:,
@@ -1,11 +1,13 @@
<%= 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,
id: @text_area_id,
**@input.input_arguments) %>
<% end %>
<%= angular_component_tag "opce-ckeditor-augmented-textarea",
inputs: @rich_text_options.reverse_merge(
{
textareaSelector: "##{builder.field_id(@input.name)}",
textAreaId: @text_area_id,
macros: false,
turboMode: true
}
@@ -13,7 +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.delete(:text_area_id) || builder.field_id(@input.name)
end
end
end
+1 -1
View File
@@ -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.
@@ -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
+8 -9
View File
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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