[Rt-commit] rt branch, 4.6/configindatabase-themed, created. rt-4.4.4-553-g4117e8957a

? sunnavy sunnavy at bestpractical.com
Mon Nov 25 17:30:28 EST 2019


The branch, 4.6/configindatabase-themed has been created
        at  4117e8957a4f540771d2a9088a41a12498188f9e (commit)

- Log -----------------------------------------------------------------
commit b28dc304678142be889bf8057cb409241275dd90
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Aug 15 16:01:59 2017 +0000

    Allow specifying size for Integer and String widgets

diff --git a/share/html/Widgets/Form/Integer b/share/html/Widgets/Form/Integer
index fa48d6071f..918fe88b0e 100644
--- a/share/html/Widgets/Form/Integer
+++ b/share/html/Widgets/Form/Integer
@@ -78,10 +78,11 @@ $DefaultLabel   => undef
 </%ARGS>
 
 <%METHOD InputOnly>
-<input type="text" name="<% $Name %>" value="<% $CurrentValue %>" class="form-control" />\
+<input type="text" name="<% $Name %>" size="<% $Size %>" value="<% $CurrentValue %>" class="form-control" />\
 <%ARGS>
 $Name
 $CurrentValue => '',
+$Size => 20
 </%ARGS>
 <%INIT>
 $CurrentValue = '' unless defined $CurrentValue;
diff --git a/share/html/Widgets/Form/String b/share/html/Widgets/Form/String
index 89da49761f..165daf2b9d 100644
--- a/share/html/Widgets/Form/String
+++ b/share/html/Widgets/Form/String
@@ -74,11 +74,12 @@ $DefaultLabel   => loc( 'Default: [_1]', $DefaultValue ),
 </%ARGS>
 
 <%METHOD InputOnly>
-<input type="<% $Type %>" name="<% $Name %>" value="<% $CurrentValue || '' %>" class="form-control" />\
+<input type="<% $Type %>" name="<% $Name %>" size="<% $Size %>" value="<% $CurrentValue || '' %>" class="form-control" />\
 <%ARGS>
 $Name
 $CurrentValue => '',
 $Type => 'text'
+$Size => 20
 </%ARGS>
 </%METHOD>
 

commit 94744da2c654ff740b4722e5d6e741fcb47ec95b
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Aug 15 16:02:18 2017 +0000

    Allow String widget to have a value of "0"
    
    Using $CurrentValue || '' means that 0 gets canonicalized to the empty
    string, which means you can't have a default of 0.
    
    This is necessary for RT::Extension::ConfigInDatabase's input field for
    $SetOutgoingMailFrom, which is a string that takes an email address or
    the special case value of "0".

diff --git a/share/html/Widgets/Form/String b/share/html/Widgets/Form/String
index 165daf2b9d..24cada9716 100644
--- a/share/html/Widgets/Form/String
+++ b/share/html/Widgets/Form/String
@@ -74,7 +74,7 @@ $DefaultLabel   => loc( 'Default: [_1]', $DefaultValue ),
 </%ARGS>
 
 <%METHOD InputOnly>
-<input type="<% $Type %>" name="<% $Name %>" size="<% $Size %>" value="<% $CurrentValue || '' %>" class="form-control" />\
+<input type="<% $Type %>" name="<% $Name %>" size="<% $Size %>" value="<% $CurrentValue // '' %>" class="form-control" />\
 <%ARGS>
 $Name
 $CurrentValue => '',

commit 59334314c9ea3c0d82016214999adebf6ae449a8
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Aug 15 16:07:59 2017 +0000

    Add RadioStyle option to Boolean widget
    
    If you use a Boolean widget with no Default, then the Yes/No/(Default)
    radio widget gets automatically downgraded to a checkbox. Instead, allow
    RadioStyle => 1 to present a Yes/No radio widget

diff --git a/share/html/Widgets/Form/Boolean b/share/html/Widgets/Form/Boolean
index e509f94955..786553d663 100644
--- a/share/html/Widgets/Form/Boolean
+++ b/share/html/Widgets/Form/Boolean
@@ -70,10 +70,11 @@ $Name         => undef,
 $Default        => 0,
 $DefaultValue   => 0,
 $DefaultLabel   => loc( 'Use default ([_1])', $DefaultValue? loc('Yes'): loc('No') ),
+$RadioStyle     => 0
 
 $CurrentValue => undef,
 </%ARGS>
-% unless ( $Default ) {
+% if ( !$Default && !$RadioStyle ) {
 <input type="hidden" name="<% $Name %>" value="0" />\
 <div class="custom-control custom-checkbox">
   <input type="checkbox" id="<% $Name %>" name="<% $Name %>" class="custom-control-input" value="1" <% $CurrentValue? ' checked="checked"': '' |n %>>
@@ -94,13 +95,14 @@ $CurrentValue => undef,
       <label class="custom-control-label" for="<% $Name %>-no"><&|/l&>No</&></label>
     </div>
   </div>
-
+% if ($Default) {
   <div class="col-md-auto">
     <div class="custom-control custom-radio">
       <input type="radio" id="<% $Name %>-empty" name="<% $Name %>" class="custom-control-input" value="__empty_value__" <% !defined $CurrentValue? ' checked="checked"': '' |n %>>
       <label class="custom-control-label" for="<% $Name %>-empty"><% $DefaultLabel %></label>
     </div>
   </div>
+% }
 </div>
 % }
 </%METHOD>

commit 534bba3102023c651d9bfc45e31f78e6c0655428
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Aug 15 18:49:11 2017 +0000

    Make booleans with RadioStyle use true/false logic
    
    Rather than tri-value

diff --git a/share/html/Widgets/Form/Boolean b/share/html/Widgets/Form/Boolean
index 786553d663..3b7091b753 100644
--- a/share/html/Widgets/Form/Boolean
+++ b/share/html/Widgets/Form/Boolean
@@ -88,20 +88,26 @@ $CurrentValue => undef,
       <label class="custom-control-label" for="<% $Name %>-yes"><&|/l&>Yes</&></label>
     </div>
   </div>
-
+% if ($Default) {
   <div class="col-md-auto">
     <div class="custom-control custom-radio">
       <input type="radio" id="<% $Name %>-no" name="<% $Name %>" class="custom-control-input" value="0" <% defined $CurrentValue && !$CurrentValue? ' checked="checked"': '' |n %>>
       <label class="custom-control-label" for="<% $Name %>-no"><&|/l&>No</&></label>
     </div>
   </div>
-% if ($Default) {
   <div class="col-md-auto">
     <div class="custom-control custom-radio">
       <input type="radio" id="<% $Name %>-empty" name="<% $Name %>" class="custom-control-input" value="__empty_value__" <% !defined $CurrentValue? ' checked="checked"': '' |n %>>
       <label class="custom-control-label" for="<% $Name %>-empty"><% $DefaultLabel %></label>
     </div>
   </div>
+% } else {
+  <div class="col-md-auto">
+    <div class="custom-control custom-radio">
+      <input type="radio" id="<% $Name %>-no" name="<% $Name %>" class="custom-control-input" value="0" <% !$CurrentValue? ' checked="checked"': '' |n %>>
+      <label class="custom-control-label" for="<% $Name %>-no"><&|/l&>No</&></label>
+    </div>
+  </div>
 % }
 </div>
 % }

commit baf23200cd9c98d5243da9d6f5e7fd4d34e405ad
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Aug 15 18:59:45 2017 +0000

    DatabaseSetting schema updates

diff --git a/etc/acl.Pg b/etc/acl.Pg
index d393a6f5cb..a48373ec6d 100644
--- a/etc/acl.Pg
+++ b/etc/acl.Pg
@@ -66,6 +66,8 @@ sub acl {
         CustomRoles
         objectcustomroles_id_seq
         ObjectCustomRoles
+        databasesettings_id_seq
+        DatabaseSettings
     );
 
     my $db_user = RT->Config->Get('DatabaseUser');
diff --git a/etc/schema.Oracle b/etc/schema.Oracle
index 102f134ddd..b4ce7404e4 100644
--- a/etc/schema.Oracle
+++ b/etc/schema.Oracle
@@ -538,3 +538,20 @@ CREATE TABLE ObjectCustomRoles (
         LastUpdated     DATE
 );
 CREATE UNIQUE INDEX ObjectCustomRoles1 ON ObjectCustomRoles (ObjectId, CustomRole);
+
+CREATE SEQUENCE DatabaseSettings_seq;
+CREATE TABLE DatabaseSettings (
+    id              NUMBER(11,0)    CONSTRAINT DatabaseSettings_key PRIMARY KEY,
+    Name            VARCHAR2(255) CONSTRAINT DatabaseSettings_Name_Unique unique  NOT NULL,
+    Content         CLOB,
+    ContentType     VARCHAR2(80),
+    Disabled        NUMBER(11,0) DEFAULT 0 NOT NULL,
+    Creator         NUMBER(11,0)    DEFAULT 0 NOT NULL,
+    Created         DATE,
+    LastUpdatedBy   NUMBER(11,0)    DEFAULT 0 NOT NULL,
+    LastUpdated     DATE
+);
+
+CREATE UNIQUE INDEX DatabaseSettings1 ON DatabaseSettings (LOWER(Name));
+CREATE INDEX DatabaseSettings2 ON DatabaseSettings (Disabled);
+
diff --git a/etc/schema.Pg b/etc/schema.Pg
index 3836c26670..8c44a8f853 100644
--- a/etc/schema.Pg
+++ b/etc/schema.Pg
@@ -778,3 +778,21 @@ CREATE TABLE ObjectCustomRoles (
 );
 
 CREATE UNIQUE INDEX ObjectCustomRoles1 ON ObjectCustomRoles (ObjectId, CustomRole);
+
+CREATE SEQUENCE databasesettings_id_seq;
+CREATE TABLE DatabaseSettings (
+    id                integer         DEFAULT nextval('databasesettings_id_seq'),
+    Name              varchar(255)    NOT NULL,
+    Content           text            NULL,
+    ContentType       varchar(80)     NULL,
+    Disabled          integer         NOT NULL DEFAULT 0 ,
+    Creator           integer         NOT NULL DEFAULT 0,
+    Created           timestamp                DEFAULT NULL,
+    LastUpdatedBy     integer         NOT NULL DEFAULT 0,
+    LastUpdated       timestamp                DEFAULT NULL,
+    PRIMARY KEY (id)
+);
+
+CREATE UNIQUE INDEX DatabaseSettings1 ON DatabaseSettings (LOWER(Name));
+CREATE INDEX DatabaseSettings2 ON DatabaseSettings (Disabled);
+
diff --git a/etc/schema.SQLite b/etc/schema.SQLite
index b6d2867274..64d395a866 100644
--- a/etc/schema.SQLite
+++ b/etc/schema.SQLite
@@ -569,3 +569,19 @@ CREATE TABLE ObjectCustomRoles (
   PRIMARY KEY (id)
 );
 CREATE UNIQUE INDEX ObjectCustomRoles1 ON ObjectCustomRoles (ObjectId, CustomRole);
+
+CREATE TABLE DatabaseSettings (
+    id                INTEGER PRIMARY KEY,
+    Name              varchar(255)    collate NOCASE NOT NULL,
+    Content           longtext        collate NOCASE NULL,
+    ContentType       varchar(80)     collate NOCASE NULL,
+    Disabled          int2            NOT NULL DEFAULT 0,
+    Creator           int(11)         NOT NULL DEFAULT 0,
+    Created           timestamp                DEFAULT NULL,
+    LastUpdatedBy     int(11)         NOT NULL DEFAULT 0,
+    LastUpdated       timestamp                DEFAULT NULL
+);
+
+CREATE UNIQUE INDEX DatabaseSettings1 ON DatabaseSettings (Name);
+CREATE INDEX DatabaseSettings2 ON DatabaseSettings (Disabled);
+
diff --git a/etc/schema.mysql b/etc/schema.mysql
index cb87d86a3a..f37469e89a 100644
--- a/etc/schema.mysql
+++ b/etc/schema.mysql
@@ -559,3 +559,20 @@ CREATE TABLE ObjectCustomRoles (
 ) ENGINE=InnoDB CHARACTER SET utf8;
 
 CREATE UNIQUE INDEX ObjectCustomRoles1 ON ObjectCustomRoles (ObjectId, CustomRole);
+
+CREATE TABLE DatabaseSettings (
+    id                int(11)         NOT NULL AUTO_INCREMENT,
+    Name              varchar(255)    NOT NULL,
+    Content           longblob        NULL,
+    ContentType       varchar(80)     CHARACTER SET ascii NULL,
+    Disabled          int2            NOT NULL DEFAULT 0,
+    Creator           int(11)         NOT NULL DEFAULT 0,
+    Created           datetime                 DEFAULT NULL,
+    LastUpdatedBy     int(11)         NOT NULL DEFAULT 0,
+    LastUpdated       datetime                 DEFAULT NULL,
+    PRIMARY KEY (id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+CREATE UNIQUE INDEX DatabaseSettings1 ON DatabaseSettings (Name);
+CREATE INDEX DatabaseSettings2 ON DatabaseSettings (Disabled);
+
diff --git a/etc/upgrade/4.5.0/acl.Pg b/etc/upgrade/4.5.0/acl.Pg
new file mode 100644
index 0000000000..6a73f0cd45
--- /dev/null
+++ b/etc/upgrade/4.5.0/acl.Pg
@@ -0,0 +1,30 @@
+sub acl {
+    my $dbh = shift;
+
+    my @acls;
+    my @tables = qw (
+        databasesettings_id_seq
+        DatabaseSettings
+    );
+
+    my $db_user = RT->Config->Get('DatabaseUser');
+
+    my $sequence_right
+        = ( $dbh->{pg_server_version} >= 80200 )
+        ? "USAGE, SELECT, UPDATE"
+        : "SELECT, UPDATE";
+
+    foreach my $table (@tables) {
+        # Tables are upper-case, sequences are lowercase in @tables
+        if ( $table =~ /^[a-z]/ ) {
+            push @acls, "GRANT $sequence_right ON $table TO \"$db_user\";"
+        }
+        else {
+            push @acls, "GRANT SELECT, INSERT, UPDATE, DELETE ON $table TO \"$db_user\";"
+        }
+    }
+    return (@acls);
+}
+
+1;
+
diff --git a/etc/upgrade/4.5.0/schema.Oracle b/etc/upgrade/4.5.0/schema.Oracle
index b18ad9c979..17fed3dc1c 100644
--- a/etc/upgrade/4.5.0/schema.Oracle
+++ b/etc/upgrade/4.5.0/schema.Oracle
@@ -1 +1,16 @@
 ALTER TABLE Classes DROP( HotList );
+CREATE SEQUENCE DatabaseSettings_seq;
+CREATE TABLE DatabaseSettings (
+    id              NUMBER(11,0)    CONSTRAINT DatabaseSettings_key PRIMARY KEY,
+    Name            VARCHAR2(255) CONSTRAINT DatabaseSettings_Name_Unique unique  NOT NULL,
+    Content         CLOB,
+    ContentType     VARCHAR2(80),
+    Disabled        NUMBER(11,0) DEFAULT 0 NOT NULL,
+    Creator         NUMBER(11,0)    DEFAULT 0 NOT NULL,
+    Created         DATE,
+    LastUpdatedBy   NUMBER(11,0)    DEFAULT 0 NOT NULL,
+    LastUpdated     DATE
+);
+
+CREATE UNIQUE INDEX DatabaseSettings1 ON DatabaseSettings (LOWER(Name));
+CREATE INDEX DatabaseSettings2 ON DatabaseSettings (Disabled);
diff --git a/etc/upgrade/4.5.0/schema.Pg b/etc/upgrade/4.5.0/schema.Pg
index f7563efc51..0efa1411d0 100644
--- a/etc/upgrade/4.5.0/schema.Pg
+++ b/etc/upgrade/4.5.0/schema.Pg
@@ -1 +1,17 @@
 ALTER TABLE Classes DROP COLUMN HotList;
+CREATE SEQUENCE databasesettings_id_seq;
+CREATE TABLE DatabaseSettings (
+    id                integer         DEFAULT nextval('databasesettings_id_seq'),
+    Name              varchar(255)    NOT NULL,
+    Content           text            NULL,
+    ContentType       varchar(80)     NULL,
+    Disabled          integer         NOT NULL DEFAULT 0 ,
+    Creator           integer         NOT NULL DEFAULT 0,
+    Created           timestamp                DEFAULT NULL,
+    LastUpdatedBy     integer         NOT NULL DEFAULT 0,
+    LastUpdated       timestamp                DEFAULT NULL,
+    PRIMARY KEY (id)
+);
+
+CREATE UNIQUE INDEX DatabaseSettings1 ON DatabaseSettings (LOWER(Name));
+CREATE INDEX DatabaseSettings2 ON DatabaseSettings (Disabled);
diff --git a/etc/upgrade/4.5.0/schema.SQLite b/etc/upgrade/4.5.0/schema.SQLite
new file mode 100644
index 0000000000..a25a8c9b49
--- /dev/null
+++ b/etc/upgrade/4.5.0/schema.SQLite
@@ -0,0 +1,15 @@
+CREATE TABLE DatabaseSettings (
+    id                INTEGER PRIMARY KEY,
+    Name              varchar(255)    collate NOCASE NOT NULL,
+    Content           longtext        collate NOCASE NULL,
+    ContentType       varchar(80)     collate NOCASE NULL,
+    Disabled          int2            NOT NULL DEFAULT 0,
+    Creator           int(11)         NOT NULL DEFAULT 0,
+    Created           timestamp                DEFAULT NULL,
+    LastUpdatedBy     int(11)         NOT NULL DEFAULT 0,
+    LastUpdated       timestamp                DEFAULT NULL
+);
+
+CREATE UNIQUE INDEX DatabaseSettings1 ON DatabaseSettings (Name);
+CREATE INDEX DatabaseSettings2 ON DatabaseSettings (Disabled);
+
diff --git a/etc/upgrade/4.5.0/schema.mysql b/etc/upgrade/4.5.0/schema.mysql
index f7563efc51..444678cfc4 100644
--- a/etc/upgrade/4.5.0/schema.mysql
+++ b/etc/upgrade/4.5.0/schema.mysql
@@ -1 +1,16 @@
 ALTER TABLE Classes DROP COLUMN HotList;
+CREATE TABLE DatabaseSettings (
+    id                int(11)         NOT NULL AUTO_INCREMENT,
+    Name              varchar(255)    NOT NULL,
+    Content           longblob        NULL,
+    ContentType       varchar(80)     CHARACTER SET ascii NULL,
+    Disabled          int2            NOT NULL DEFAULT 0,
+    Creator           int(11)         NOT NULL DEFAULT 0,
+    Created           datetime                 DEFAULT NULL,
+    LastUpdatedBy     int(11)         NOT NULL DEFAULT 0,
+    LastUpdated       datetime                 DEFAULT NULL,
+    PRIMARY KEY (id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+CREATE UNIQUE INDEX DatabaseSettings1 ON DatabaseSettings (Name);
+CREATE INDEX DatabaseSettings2 ON DatabaseSettings (Disabled);

commit a888aa6cb0fd3cf29102dc595b1d94ec026be3c1
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Aug 15 19:01:37 2017 +0000

    Add ORM classes for DatabaseSettings

diff --git a/lib/RT/DatabaseSetting.pm b/lib/RT/DatabaseSetting.pm
new file mode 100644
index 0000000000..185aae01f8
--- /dev/null
+++ b/lib/RT/DatabaseSetting.pm
@@ -0,0 +1,417 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2016 Best Practical Solutions, LLC
+#                                          <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work 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 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+
+use strict;
+use warnings;
+use 5.10.1;
+
+package RT::DatabaseSetting;
+use base 'RT::Record';
+
+use Storable ();
+use MIME::Base64;
+use JSON ();
+
+=head1 NAME
+
+RT::DatabaseSetting - Represents a config setting
+
+=cut
+
+=head1 METHODS
+
+=head2 Create PARAMHASH
+
+Create takes a hash of values and creates a row in the database.  Available
+keys are:
+
+=over 4
+
+=item Name
+
+Must be unique.
+
+=item Content
+
+If you provide a reference, we will automatically serialize the data structure
+using L<Storable>. Otherwise any string is passed through as-is.
+
+=item ContentType
+
+Currently handles C<storable> or C<application/json>.
+
+=back
+
+Returns a tuple of (status, msg) on failure and (id, msg) on success.
+Also automatically propagates this config change to all server processes.
+
+=cut
+
+sub Create {
+    my $self = shift;
+    my %args = (
+        Name => '',
+        Content => '',
+        ContentType => '',
+        @_,
+    );
+
+    return (0, $self->loc("Permission Denied"))
+        unless $self->CurrentUserHasRight('SuperUser');
+
+    unless ( $args{'Name'} ) {
+        return ( 0, $self->loc("Must specify 'Name' attribute") );
+    }
+
+    my ( $id, $msg ) = $self->ValidateName( $args{'Name'} );
+    return ( 0, $msg ) unless $id;
+
+    my $meta = RT->Config->Meta($args{'Name'});
+    if ($meta->{Immutable}) {
+        return ( 0, $self->loc("You cannot update [_1] using database config; you must edit your site config", $args{'Name'}) );
+    }
+
+    if (ref ($args{'Content'}) ) {
+        ($args{'Content'}, my $error) = $self->_SerializeContent($args{'Content'}, $args{'Name'});
+        if ($error) {
+            return (0, $error);
+        }
+        $args{'ContentType'} = 'storable';
+    }
+
+    my $old_value = RT->Config->Get($args{Name});
+    unless (defined($old_value) && length($old_value)) {
+        $old_value = $self->loc('(no value)');
+    }
+
+    ( $id, $msg ) = $self->SUPER::Create(
+        map { $_ => $args{$_} } grep {exists $args{$_}}
+            qw(Name Content ContentType),
+    );
+    unless ($id) {
+        return (0, $self->loc("Setting [_1] to [_2] failed: [_3]", $args{Name}, $args{Content}, $msg));
+    }
+
+    RT->Config->ApplyConfigChangeToAllServerProcesses;
+
+    my ($content, $error) = $self->Content;
+    unless (defined($content) && length($content)) {
+        $content = $self->loc('(no value)');
+    }
+
+    if (!ref($content) && !ref($old_value)) {
+        RT->Logger->info($self->CurrentUser->Name . " changed " . $self->Name . " from " . $old_value . " to " . $content);
+        return ($id, $self->loc('[_1] changed from "[_2]" to "[_3]"', $self->Name, $old_value, $content));
+    }
+    else {
+        RT->Logger->info($self->CurrentUser->Name . " changed " . $self->Name);
+        return ($id, $self->loc("[_1] changed", $self->Name));
+    }
+}
+
+=head2 CurrentUserCanSee
+
+Returns true if the current user can see the database setting
+
+=cut
+
+sub CurrentUserCanSee {
+    my $self = shift;
+
+    return $self->CurrentUserHasRight('SuperUser');
+}
+
+=head2 Load
+
+Load a setting from the database. Takes a single argument. If the
+argument is numerical, load by the column 'id'. Otherwise, load by the
+"Name" column.
+
+=cut
+
+sub Load {
+    my $self = shift;
+    my $identifier = shift || return undef;
+
+    if ( $identifier !~ /\D/ ) {
+        return $self->SUPER::LoadById( $identifier );
+    } else {
+        return $self->LoadByCol( "Name", $identifier );
+    }
+}
+
+=head2 SetName
+
+Not permitted
+
+=cut
+
+sub SetName {
+    my $self = shift;
+    return (0, $self->loc("Permission Denied"));
+}
+
+=head2 ValidateName
+
+Returns either (0, "failure reason") or 1 depending on whether the given
+name is valid.
+
+=cut
+
+sub ValidateName {
+    my $self = shift;
+    my $name = shift;
+
+    return ( 0, $self->loc('empty name') ) unless defined $name && length $name;
+
+    my $TempSetting = RT::DatabaseSetting->new( RT->SystemUser );
+    $TempSetting->Load($name);
+
+    if ( $TempSetting->id && ( !$self->id || $TempSetting->id != $self->id ) ) {
+        return ( 0, $self->loc('Name in use') );
+    }
+    else {
+        return 1;
+    }
+}
+
+=head2 Delete
+
+Checks ACL, and on success propagates this config change to all server
+processes.
+
+=cut
+
+sub Delete {
+    my $self = shift;
+    return (0, $self->loc("Permission Denied")) unless $self->CurrentUserCanSee;
+    my ($ok, $msg) = $self->SUPER::Delete(@_);
+    return ($ok, $msg) if !$ok;
+    RT->Config->ApplyConfigChangeToAllServerProcesses;
+    RT->Logger->info($self->CurrentUser->Name . " removed database setting for " . $self->Name);
+    return ($ok, $self->loc("Database setting removed."));
+}
+
+=head2 DecodedContent
+
+Returns a pair of this setting's content and any error.
+
+=cut
+
+sub DecodedContent {
+    my $self = shift;
+
+    # Here we call _Value to run the ACL check.
+    my $content = $self->_Value('Content');
+
+    my $type = $self->__Value('ContentType') || '';
+
+    if ($type eq 'storable') {
+        return $self->_DeserializeContent($content);
+    }
+    elsif ($type eq 'application/json') {
+        return $self->_DeJSONContent($content);
+    }
+
+    return ($content, "");
+}
+
+=head2 SetContent
+
+=cut
+
+sub SetContent {
+    my $self         = shift;
+    my $value        = shift;
+    my $content_type = shift || '';
+
+    return (0, $self->loc("Permission Denied")) unless $self->CurrentUserCanSee;
+
+    my ($old_value, $error) = $self->Content;
+    unless (defined($old_value) && length($old_value)) {
+        $old_value = $self->loc('(no value)');
+    }
+
+    if (ref $value) {
+        ($value, my $error) = $self->_SerializeContent($value);
+        if ($error) {
+            return (0, $error);
+        }
+        $content_type = 'storable';
+    }
+
+    $RT::Handle->BeginTransaction;
+
+    my ($ok, $msg) = $self->_Set( Field => 'Content', Value => $value );
+    if (!$ok) {
+        $RT::Handle->Rollback;
+        return ($ok, $self->loc("Unable to update [_1]: [_2]", $self->Name, $msg));
+    }
+
+    if ($self->ContentType ne $content_type) {
+        ($ok, $msg) = $self->_Set( Field => 'ContentType', Value => $content_type );
+        if (!$ok) {
+            $RT::Handle->Rollback;
+            return ($ok, $self->loc("Unable to update [_1]: [_2]", $self->Name, $msg));
+        }
+    }
+
+    $RT::Handle->Commit;
+    RT->Config->ApplyConfigChangeToAllServerProcesses;
+
+    unless (defined($value) && length($value)) {
+        $value = $self->loc('(no value)');
+    }
+
+    if (!ref($value) && !ref($old_value)) {
+        RT->Logger->info($self->CurrentUser->Name . " changed " . $self->Name . " from " . $old_value . " to " . $value);
+        return ($ok, $self->loc('[_1] changed from "[_2]" to "[_3]"', $self->Name, $old_value, $value));
+    } else {
+        RT->Logger->info($self->CurrentUser->Name . " changed " . $self->Name);
+        return ($ok, $self->loc("[_1] changed", $self->Name));
+    }
+}
+
+=head1 PRIVATE METHODS
+
+Documented for internal use only, do not call these from outside
+RT::DatabaseSetting itself.
+
+=head2 _Set
+
+Checks if the current user has I<SuperUser> before calling
+C<SUPER::_Set>, and then propagates this config change to all server processes.
+
+=cut
+
+sub _Set {
+    my $self = shift;
+    my %args = (
+        Field => undef,
+        Value => undef,
+        @_
+    );
+
+    return (0, $self->loc("Permission Denied"))
+        unless $self->CurrentUserCanSee;
+
+    my ($ok, $msg) = $self->SUPER::_Set(@_);
+    RT->Config->ApplyConfigChangeToAllServerProcesses;
+    return ($ok, $msg);
+}
+
+=head2 _Value
+
+Checks L</CurrentUserCanSee> before calling C<SUPER::_Value>.
+
+=cut
+
+sub _Value {
+    my $self = shift;
+    return unless $self->CurrentUserCanSee;
+    return $self->SUPER::_Value(@_);
+}
+
+sub _SerializeContent {
+    my $self = shift;
+    my $content = shift;
+    my $name = shift || $self->Name;
+    my $frozen = eval { encode_base64(Storable::nfreeze($content)) };
+
+    if (my $error = $@) {
+        $RT::Logger->error("Storable serialization of database setting $name failed: $error");
+        return (undef, $self->loc("Storable serialization of database setting [_1] failed: [_2]", $name, $error));
+    }
+
+    return $frozen;
+}
+
+sub _DeserializeContent {
+    my $self = shift;
+    my $content = shift;
+
+    my $thawed = eval { Storable::thaw(decode_base64($content)) };
+    if (my $error = $@) {
+        $RT::Logger->error("Storable deserialization of database setting " . $self->Name . " failed: $error");
+        return (undef, $self->loc("Storable deserialization of database setting [_1] failed: [_2]", $self->Name, $error));
+    }
+
+    return $thawed;
+}
+
+sub _DeJSONContent {
+    my $self = shift;
+    my $content = shift;
+
+    my $thawed = eval { JSON::from_json($content) };
+    if (my $error = $@) {
+        $RT::Logger->error("JSON deserialization of database setting " . $self->Name . " failed: $error");
+        return (undef, $self->loc("JSON deserialization of database setting [_1] failed: [_2]", $self->Name, $error));
+    }
+
+    return $thawed;
+}
+
+sub Table { "DatabaseSettings" }
+
+sub _CoreAccessible {
+    {
+        id            => { read => 1, type => 'int(11)',        default => '' },
+        Name          => { read => 1, sql_type => 12, length => 255,  is_blob => 0,  is_numeric => 0,  type => 'varchar(255)', default => ''},
+        Content       => { read => 1, write => 1, sql_type => -4, length => 0,  is_blob => 1,  is_numeric => 0,  type => 'blob', default => ''},
+        ContentType   => { read => 1, write => 1, sql_type => 12, length => 16,  is_blob => 0,  is_numeric => 0,  type => 'varchar(16)', default => ''},
+        Disabled      => { read => 1, write => 1, sql_type => 5, length => 6,  is_blob => 0,  is_numeric => 1,  type => 'smallint(6)', default => '0'},
+        Creator       => { read => 1, type => 'int(11)',        default => '0', auto => 1 },
+        Created       => { read => 1, type => 'datetime',       default => '',  auto => 1 },
+        LastUpdatedBy => { read => 1, type => 'int(11)',        default => '0', auto => 1 },
+        LastUpdated   => { read => 1, type => 'datetime',       default => '',  auto => 1 },
+    }
+}
+
+1;
+
diff --git a/lib/RT/DatabaseSettings.pm b/lib/RT/DatabaseSettings.pm
new file mode 100644
index 0000000000..3236bad2c0
--- /dev/null
+++ b/lib/RT/DatabaseSettings.pm
@@ -0,0 +1,84 @@
+# BEGIN BPS TAGGED BLOCK {{{
+#
+# COPYRIGHT:
+#
+# This software is Copyright (c) 1996-2016 Best Practical Solutions, LLC
+#                                          <sales at bestpractical.com>
+#
+# (Except where explicitly superseded by other copyright notices)
+#
+#
+# LICENSE:
+#
+# This work is made available to you under the terms of Version 2 of
+# the GNU General Public License. A copy of that license should have
+# been provided with this software, but in any event can be snarfed
+# from www.gnu.org.
+#
+# This work 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 or visit their web page on the internet at
+# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+#
+#
+# CONTRIBUTION SUBMISSION POLICY:
+#
+# (The following paragraph is not intended to limit the rights granted
+# to you to modify and distribute this software under the terms of
+# the GNU General Public License and is only of importance to you if
+# you choose to contribute your changes and enhancements to the
+# community by submitting them to Best Practical Solutions, LLC.)
+#
+# By intentionally submitting any modifications, corrections or
+# derivatives to this work, or any other work intended for use with
+# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+# you are the copyright holder for those contributions and you grant
+# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+# royalty-free, perpetual, license to use, copy, create derivative
+# works based on those contributions, and sublicense and distribute
+# those contributions and any derivatives thereof.
+#
+# END BPS TAGGED BLOCK }}}
+
+use strict;
+use warnings;
+
+package RT::DatabaseSettings;
+use base 'RT::SearchBuilder';
+
+=head1 NAME
+
+RT::DatabaseSettings - a collection of L<RT::DatabaseSettings> objects
+
+=cut
+
+sub NewItem {
+    my $self = shift;
+    return RT::DatabaseSetting->new( $self->CurrentUser );
+}
+
+=head2 _Init
+
+Sets default ordering by id ascending.
+
+=cut
+
+sub _Init {
+    my $self = shift;
+
+    $self->{'with_disabled_column'} = 1;
+
+    $self->OrderBy( FIELD => 'id', ORDER => 'ASC' );
+    return $self->SUPER::_Init( @_ );
+}
+
+sub Table { "DatabaseSettings" }
+
+1;
+

commit 05db0b9b3797b8bce45f4c42f9f6af94fa93d721
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Aug 15 19:15:16 2017 +0000

    Port database config loading and refreshing from extension

diff --git a/lib/RT.pm b/lib/RT.pm
index 52fa299189..c53cbb5e38 100644
--- a/lib/RT.pm
+++ b/lib/RT.pm
@@ -199,6 +199,7 @@ sub Init {
     ConnectToDatabase();
     InitSystemObjects();
     InitClasses(%args);
+    RT->Config->LoadConfigFromDatabase();
     InitLogging();
     ProcessPreInitMessages();
     InitPlugins();
@@ -503,6 +504,8 @@ sub InitClasses {
     require RT::Asset;
     require RT::Assets;
     require RT::CustomFieldValues::Canonicalizer;
+    require RT::DatabaseSetting;
+    require RT::DatabaseSettings;
 
     _BuildTableAttributes();
 
diff --git a/lib/RT/Config.pm b/lib/RT/Config.pm
index ab42501fdf..4e2a243d4f 100644
--- a/lib/RT/Config.pm
+++ b/lib/RT/Config.pm
@@ -55,6 +55,7 @@ use 5.010;
 use File::Spec ();
 use Symbol::Global::Name;
 use List::MoreUtils 'uniq';
+use Storable;
 
 # Store log messages generated before RT::Logger is available
 our @PreInitLoggerMessages;
@@ -1859,6 +1860,125 @@ sub EnableExternalAuth {
     return;
 }
 
+my $database_config_cache_time = 0;
+my %original_setting_from_files;
+my $in_config_change_txn = 0;
+
+sub BeginDatabaseConfigChanges {
+    $in_config_change_txn = $in_config_change_txn + 1;
+}
+
+sub EndDatabaseConfigChanges {
+    $in_config_change_txn = $in_config_change_txn - 1;
+    if (!$in_config_change_txn) {
+        shift->ApplyConfigChangeToAllServerProcesses();
+    }
+}
+
+sub ApplyConfigChangeToAllServerProcesses {
+    my $self = shift;
+
+    return if $in_config_change_txn;
+
+    # first apply locally
+    $self->LoadConfigFromDatabase();
+
+    # then notify other servers
+    RT->System->ConfigCacheNeedsUpdate($database_config_cache_time);
+}
+
+sub RefreshConfigFromDatabase {
+    my $self = shift;
+    if ($in_config_change_txn) {
+        RT->Logger->error("It appears that there were unbalanced calls to BeginDatabaseConfigChanges with EndDatabaseConfigChanges; this indicates a software fault");
+        $in_config_change_txn = 0;
+    }
+
+    my $needs_update = RT->System->ConfigCacheNeedsUpdate;
+    if ($needs_update > $database_config_cache_time) {
+        $self->LoadConfigFromDatabase();
+        $database_config_cache_time = $needs_update;
+    }
+}
+
+sub LoadConfigFromDatabase {
+    my $self = shift;
+
+    $database_config_cache_time = time;
+
+    my $settings = RT::DatabaseSettings->new(RT->SystemUser);
+    $settings->UnLimit;
+
+    my %seen;
+
+    while (my $setting = $settings->Next) {
+        my $name = $setting->Name;
+        my ($value, $error) = $setting->DecodedContent;
+        next if $error;
+
+        if (!exists $original_setting_from_files{$name}) {
+            $original_setting_from_files{$name} = [
+                scalar($self->Get($name)),
+                Storable::dclone(scalar($self->Meta($name))),
+            ];
+        }
+
+        $seen{$name}++;
+
+        # are we inadvertantly overriding RT_SiteConfig.pm?
+        my $meta = $META{$name};
+        if ($meta->{'Source'}) {
+            my %source = %{ $meta->{'Source'} };
+            if ($source{'SiteConfig'} && $source{'File'} ne 'database') {
+                warn("Change of config option '$name' at $source{File} line $source{Line} has been overridden by the config setting from the database. Please remove it from $source{File} or from the database to avoid confusion.");
+            }
+        }
+
+        my $type = $meta->{Type} || 'SCALAR';
+
+        # hashes combine, but we don't want that behavior because the previous
+        # config settings will shadow any change that the database config makes
+        if ($type eq 'HASH') {
+            $self->Set($name, ());
+        }
+
+        my $val = $type eq 'ARRAY' ? $value
+                : $type eq 'HASH'  ? [ %$value ]
+                                   : [ $value ];
+
+        $self->SetFromConfig(
+            Option     => \$name,
+            Value      => $val,
+            Package    => 'N/A',
+            File       => 'database',
+            Line       => 'N/A',
+            SiteConfig => 1,
+        );
+    }
+
+    # anything that wasn't loaded from the database but has been set in
+    # %original_setting_from_files must have been disabled from the database,
+    # so we want to restore the original setting
+    for my $name (keys %original_setting_from_files) {
+        next if $seen{$name};
+
+        my ($value, $meta) = @{ $original_setting_from_files{$name} };
+        my $type = $meta->{Type} || 'SCALAR';
+
+        if ($type eq 'ARRAY') {
+            $self->Set($name, @$value);
+        }
+        elsif ($type eq 'HASH') {
+            $self->Set($name, %$value);
+        }
+        else {
+            $self->Set($name, $value);
+        }
+
+        %{ $META{$name} } = %$meta;
+    }
+}
+
 RT::Base->_ImportOverlays();
 
 1;
diff --git a/lib/RT/Interface/Web.pm b/lib/RT/Interface/Web.pm
index d147ae8f8e..39eebb07e8 100644
--- a/lib/RT/Interface/Web.pm
+++ b/lib/RT/Interface/Web.pm
@@ -272,6 +272,8 @@ sub HandleRequest {
         Module::Refresh->refresh;
     }
 
+    RT->Config->RefreshConfigFromDatabase();
+
     $HTML::Mason::Commands::r->content_type("text/html; charset=utf-8");
 
     $HTML::Mason::Commands::m->{'rt_base_time'} = [ Time::HiRes::gettimeofday() ];
diff --git a/lib/RT/System.pm b/lib/RT/System.pm
index 7670b91fb3..e7b761eda1 100644
--- a/lib/RT/System.pm
+++ b/lib/RT/System.pm
@@ -236,6 +236,27 @@ sub CustomRoleCacheNeedsUpdate {
     }
 }
 
+=head2 ConfigCacheNeedsUpdate ( 1 )
+
+Attribute to decide when we need to flush the database settings
+and re-register any changes.  Set when settings are created, enabled/disabled, etc.
+
+If passed a true value, will update the attribute to be the current time.
+
+=cut
+
+sub ConfigCacheNeedsUpdate {
+    my $self = shift;
+    my $time = shift;
+
+    if ($time) {
+        return $self->SetAttribute(Name => 'ConfigCacheNeedsUpdate', Content => $time);
+    } else {
+        my $cache = $self->FirstAttribute('ConfigCacheNeedsUpdate');
+        return (defined $cache ? $cache->Content : 0 );
+    }
+}
+
 =head2 AddUpgradeHistory package, data
 
 Adds an entry to the upgrade history database. The package can be either C<RT>

commit 7648dd10f1ac6c2ba6413415a850de4fe6f6f467
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Aug 15 19:17:05 2017 +0000

    Add Code and MultilineString widgets

diff --git a/share/html/Widgets/Form/Code b/share/html/Widgets/Form/Code
new file mode 100644
index 0000000000..1c346d6c18
--- /dev/null
+++ b/share/html/Widgets/Form/Code
@@ -0,0 +1,57 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2016 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work 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 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%DOC>
+see docs/extending/using_forms_widgets.pod
+</%DOC>
+<& /Widgets/Form/MultilineString, Class => 'code', %ARGS &>
+<%METHOD InputOnly>
+<& /Widgets/Form/MultilineString:InputOnly, %ARGS &>
+</%METHOD>
+<%METHOD Process>
+<& /Widgets/Form/MultilineString:Process, %ARGS &>
+</%METHOD>
diff --git a/share/html/Widgets/Form/MultilineString b/share/html/Widgets/Form/MultilineString
new file mode 100644
index 0000000000..fa6a74758d
--- /dev/null
+++ b/share/html/Widgets/Form/MultilineString
@@ -0,0 +1,102 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2016 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work 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 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%DOC>
+see docs/extending/using_forms_widgets.pod
+</%DOC>
+<div id="form-box-<% lc $Name %>" class="widget <% $Class %>">
+<span class="description label"><% $Description %></span>
+<span class="value"><& SELF:InputOnly, %ARGS &></span>
+% if ( $Default ) {
+<span class="comment"><% $DefaultLabel %></span>
+% }
+<span class="hints"><% $Hints %></span>
+</div>
+<%ARGS>
+$Name
+
+$Class        => ''
+$Description  => undef,
+$Hints        => ''
+
+$CurrentValue => '',
+
+$Default        => 0,
+$DefaultValue   => '',
+$DefaultLabel   => loc( 'Default: [_1]', $DefaultValue ),
+</%ARGS>
+
+<%METHOD InputOnly>
+<textarea name="<% $Name %>" cols="<% $Cols %>" rows="<% $Rows %>"><% $CurrentValue %></textarea>
+<%ARGS>
+$Name
+$Cols => 80
+$Rows => 6
+$CurrentValue => '',
+</%ARGS>
+</%METHOD>
+
+<%METHOD Process>
+<%ARGS>
+$Name
+
+$Arguments    => {},
+
+$Default      => 0,
+$DefaultValue => '',
+</%ARGS>
+<%INIT>
+my $value = $Arguments->{ $Name };
+$value = '' unless defined $value;
+
+if ( $value eq '' ) {
+    return $DefaultValue unless $Default;
+    return undef;
+}
+return $value;
+</%INIT>
+</%METHOD>

commit 21f6abfb8cc62adb883f9045121439afb170d740
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Aug 15 19:23:14 2017 +0000

    Port EditConfig page from extension

diff --git a/lib/RT/Interface/Web/MenuBuilder.pm b/lib/RT/Interface/Web/MenuBuilder.pm
index 0e1d7b9c36..df2dd9be5a 100644
--- a/lib/RT/Interface/Web/MenuBuilder.pm
+++ b/lib/RT/Interface/Web/MenuBuilder.pm
@@ -983,6 +983,11 @@ sub _BuildAdminMenu {
         description => loc('Detailed information about your RT setup'),
         path        => '/Admin/Tools/Configuration.html',
     );
+    $admin_tools->child( edit_config =>
+        title       => loc('Edit Configuration'),
+        description => loc('Update your RT setup'),
+        path        => '/Admin/Tools/EditConfig.html',
+    );
     $admin_tools->child( theme =>
         title       => loc('Theme'),
         description => loc('Customize the look of your RT'),
diff --git a/share/html/Admin/Tools/EditConfig.html b/share/html/Admin/Tools/EditConfig.html
new file mode 100644
index 0000000000..c3185edf47
--- /dev/null
+++ b/share/html/Admin/Tools/EditConfig.html
@@ -0,0 +1,270 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2016 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work 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 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+<%INIT>
+my $title = loc('System Configuration');
+unless ($session{'CurrentUser'}->HasRight( Object=> $RT::System, Right => 'SuperUser')) {
+ Abort(loc('This feature is only available to system administrators'));
+}
+
+my $has_execute_code = $session{CurrentUser}->HasRight(Right => 'ExecuteCode', Object => RT->System);
+
+my @results;
+
+my $doc_version = $RT::VERSION;
+$doc_version =~ s/rc\d+//; # 4.4.2rc1 -> 4.4.2
+$doc_version =~ s/\.\d+-\d+-g\w+$//;  # 4.4.3-1-g123 -> 4.4
+
+use Data::Dumper;
+my $stringify = sub {
+    my $value = shift;
+    return "" if !defined($value);
+
+    local $Data::Dumper::Terse = 1;
+    local $Data::Dumper::Indent = 2;
+    local $Data::Dumper::Sortkeys = 1;
+    my $output = Dumper $value;
+    chomp $output;
+    return $output;
+};
+
+if (delete $ARGS{Update}) {
+    RT->Config->BeginDatabaseConfigChanges;
+    $RT::Handle->BeginTransaction;
+    my $has_error;
+
+    eval {
+        for my $key (keys %ARGS) {
+            next if $key =~ /-Current$/;
+
+            my $meta = RT->Config->Meta( $key );
+            my $widget = $meta->{Widget} || '/Widgets/Form/Code';
+            my $is_code = $widget eq '/Widgets/Form/Code';
+
+            my $val = $ARGS{$key};
+            $val = '' if $val eq '__empty_value__';
+            my $prev = $ARGS{$key . '-Current'};
+            next if $val eq $prev;
+
+            # for bools, check for truthiness since 0, '', and undef are equivalent
+            if ($widget eq '/Widgets/Form/Boolean') {
+                next if !!$val eq !!$prev;
+            }
+
+            if ( $meta->{Immutable} || $meta->{Obfuscate} || ($key =~ /Password/i and $key !~ /MinimumPasswordLength|AllowLoginPasswordAutoComplete/ )) {
+                push @results, loc("Cannot change [_1]: Permission Denied", $key);
+                $has_error++;
+                next;
+            }
+
+            if ($is_code) {
+                if (!$has_execute_code) {
+                    push @results, loc("Cannot change [_1]: Permission Denied", $key);
+                    $has_error++;
+                    next;
+                }
+
+                my $code = $val;
+                my $coderef;
+                # similar to RT::Scrip::CompileCheck
+                do {
+                    no strict 'vars';
+                    $coderef = eval "sub { $code \n }";
+                };
+                if ($@) {
+                    my $error = $@;
+                    push @results, loc("Couldn't compile [_1] codeblock '[_2]': [_3]", $key, $code, $error);
+                    $has_error++;
+                    next;
+                }
+
+                if ($coderef) {
+                    $val = eval { $coderef->() };
+                    if ($@) {
+                        my $error = $@;
+                        push @results, loc("Couldn't execute [_1] codeblock '[_2]': [_3]", $key, $code, $error);
+                        $has_error++;
+                        next;
+                    }
+                }
+            }
+
+            my $setting = RT::DatabaseSetting->new($session{CurrentUser});
+            $setting->Load($key);
+            if ($setting->Id) {
+                if ($setting->Disabled) {
+                    $setting->SetDisabled(0);
+                }
+
+                my ($ok, $msg) = $setting->SetContent($val);
+                push @results, $msg;
+                $has_error++ if !$ok;
+            }
+            else {
+                my ($ok, $msg) = $setting->Create(
+                    Name    => $key,
+                    Content => $val,
+                );
+                push @results, $msg;
+                $has_error++ if !$ok;
+            }
+        }
+    };
+
+    if ($@) {
+        push @results, $@;
+        $has_error++;
+    }
+
+    if ($has_error) {
+        push @results, loc("No changes made.");
+        $RT::Handle->Rollback;
+    }
+    else {
+        $RT::Handle->Commit;
+    }
+    RT->Config->EndDatabaseConfigChanges;
+}
+
+</%INIT>
+<& /Admin/Elements/Header, Title => $title &>
+<& /Elements/Tabs &>
+<& /Elements/ListActions, actions => \@results &>
+
+<form id="EditConfig" method="post" action="EditConfig.html">
+<input type="hidden" name="Update" value=1></input>
+
+<&|/Widgets/TitleBox, title => loc("RT Configuration") &>
+<table border="0" cellspacing="0" cellpadding="5" width="100%" class="collection">
+<tr class="collection-as-table">
+<th class="collection-as-table"><&|/l&>Option</&></th>
+<th class="collection-as-table"><&|/l&>Value</&></th>
+</tr>
+<%PERL>
+my $index_conf;
+foreach my $key ( RT->Config->Options( Overridable => undef, Sorted => 0 ) ) {
+    my $meta = RT->Config->Meta( $key );
+
+    next if $meta->{Invisible};
+
+    my $raw_value = RT->Config->Get( $key );
+    my $val = $stringify->($raw_value);
+
+    $index_conf++;
+
+    my $doc_url = "https://docs.bestpractical.com/rt/$doc_version/RT_Config.html#$key";
+
+    my $widget = $meta->{'Widget'} || '/Widgets/Form/Code';
+    my $is_code = $widget eq '/Widgets/Form/Code';
+    my $is_password = ($key =~ /Password/i and $key !~ /MinimumPasswordLength|AllowLoginPasswordAutoComplete/ );
+    my $is_immutable = $meta->{Immutable}
+                    || $meta->{Obfuscate}
+                    || ($is_code && $val =~ s/sub { "DUMMY" }/sub { ... }/g)
+                    || ($is_code && !$has_execute_code);
+
+    my $current_value = $is_code ? $val : $raw_value;
+    my $args   = $meta->{'WidgetArguments'} || {};
+
+    if ($widget eq '/Widgets/Form/Boolean') {
+        %$args = (
+            Default => 0,
+            RadioStyle => 1,
+            %$args,
+        );
+    }
+    elsif ($widget eq '/Widgets/Form/String' || $widget eq '/Widgets/Form/Integer') {
+        %$args = (
+            Size => 60,
+            %$args,
+        );
+    }
+
+</%PERL>
+<tr class="<% $key %> <% $index_conf%2 ? 'oddline' : 'evenline'%>">
+<td class="collection-as-table"><a href="<% $doc_url %>" target="_blank"><% $key %></a></td>
+<td class="collection-as-table">
+
+% if ( $meta->{EditLink} ) {
+<&|/l_unsafe, "<a href=\"$meta->{EditLink}\">", loc($meta->{EditLinkLabel}), "</a>" &>Visit [_1][_2][_3] to manage this setting</&>
+% } elsif ( $key =~ /Plugins/) {
+<ul class="plugins">
+% for my $plugin (RT->Config->Get($key)) {
+<li><a href="https://metacpan.org/search?q=<% $plugin |u %>" target="_blank"><% $plugin %></a></li>
+% }
+</ul>
+<br><em><% loc('Must modify in config file' ) %></em>
+% } elsif ( $is_password ) {
+<em><% loc('Must modify in config file' ) %></em>
+% } elsif ( $is_immutable ) {
+% if ($widget eq '/Widgets/Form/MultilineString' || $widget eq '/Widgets/Form/Code') {
+<textarea disabled class="<% $is_code ? 'code' : '' %>" rows="6" cols="80"><% $current_value %></textarea>
+% } else {
+<input type="text" disabled width="80" value="<% $current_value %>"></input>
+% }
+<br><em><% loc('Must modify in config file' ) %></em>
+% } else {
+  <& $widget,
+    Default      => 1,
+    DefaultValue => '',
+    DefaultLabel => '(no value)',
+
+    %{ $m->comp('/Widgets/FinalizeWidgetArguments', WidgetArguments => $args ) },
+    Name         => $key,
+    CurrentValue => $current_value,
+    Description  => '',
+    Hints        => '',
+  &>
+<textarea class="hidden" name="<% $key %>-Current"><% $current_value %></textarea>
+% }
+</td>
+</tr>
+% }
+</table>
+</&>
+<& /Elements/Submit, Label => loc('Save Changes') &>
+</form>
+
diff --git a/share/static/css/elevator-light/forms.css b/share/static/css/elevator-light/forms.css
index 7c2c122669..2554a2afa9 100644
--- a/share/static/css/elevator-light/forms.css
+++ b/share/static/css/elevator-light/forms.css
@@ -363,3 +363,23 @@ ul li .dropdown-item.active span,
 ul li .dropdown-item:active span {
   color: #fff;
 }
+
+/* remove unnecessary left padding for radio options */
+#EditConfig div.widget .label {
+    width: auto;
+    float: none;
+}
+
+#EditConfig textarea:disabled,
+#EditConfig input:disabled {
+    background-color: #EEE;
+}
+
+.widget.code textarea,
+textarea.code {
+    font-family: Consolas, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New, monospace;
+}
+
+#EditConfig ul.plugins {
+    margin: 0;
+}

commit 165422a396141d9f2ac4fb83bef0c5acef37545b
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Aug 15 19:33:14 2017 +0000

    Annotate Immutable options
    
    These options cannot be changed in database settings

diff --git a/lib/RT/Config.pm b/lib/RT/Config.pm
index 4e2a243d4f..30c81ee72b 100644
--- a/lib/RT/Config.pm
+++ b/lib/RT/Config.pm
@@ -638,6 +638,7 @@ our %META;
     # this tends to break extensions that stash links in ticket update pages
     Organization => {
         Type            => 'SCALAR',
+        Immutable       => 1,
         PostLoadCheck   => sub {
             my ($self,$value) = @_;
             $RT::Logger->error("your \$Organization setting ($value) appears to contain whitespace.  Please fix this.")
@@ -645,9 +646,38 @@ our %META;
         },
     },
 
+    rtname => {
+        Immutable => 1,
+    },
+
     # Internal config options
     DatabaseExtraDSN => {
-        Type => 'HASH',
+        Type      => 'HASH',
+        Immutable => 1,
+    },
+    DatabaseAdmin => {
+        Immutable => 1,
+    },
+    DatabaseHost => {
+        Immutable => 1,
+    },
+    DatabaseName => {
+        Immutable => 1,
+    },
+    DatabasePassword => {
+        Immutable => 1,
+    },
+    DatabasePort => {
+        Immutable => 1,
+    },
+    DatabaseRTHost => {
+        Immutable => 1,
+    },
+    DatabaseType => {
+        Immutable => 1,
+    },
+    DatabaseUser => {
+        Immutable => 1,
     },
 
     FullTextSearch => {
@@ -741,8 +771,24 @@ our %META;
         Type => 'SCALAR',
         PostLoadCheck => sub { RT::Interface::Email->_HTMLFormatter },
     },
+    Plugins => {
+        Immutable => 1,
+    },
+    RecordBaseClass => {
+        Immutable => 1,
+    },
+    WebSessionClass => {
+        Immutable => 1,
+    },
+    DevelMode => {
+        Immutable => 1,
+    },
+    DisallowExecuteCode => {
+        Immutable => 1,
+    },
     MailPlugins  => {
         Type => 'ARRAY',
+        Immutable     => 1,
         PostLoadCheck => sub {
             my $self = shift;
 
@@ -868,6 +914,7 @@ our %META;
     EmailDashboardLanguageOrder  => { Type => 'ARRAY' },
     CustomFieldValuesCanonicalizers => { Type => 'ARRAY' },
     WebPath => {
+        Immutable     => 1,
         PostLoadCheck => sub {
             my $self  = shift;
             my $value = shift;
@@ -894,6 +941,7 @@ our %META;
         },
     },
     WebDomain => {
+        Immutable => 1,
         PostLoadCheck => sub {
             my $self  = shift;
             my $value = shift;
@@ -920,6 +968,7 @@ our %META;
         },
     },
     WebPort => {
+        Immutable     => 1,
         PostLoadCheck => sub {
             my $self  = shift;
             my $value = shift;
@@ -935,6 +984,7 @@ our %META;
         },
     },
     WebBaseURL => {
+        Immutable     => 1,
         PostLoadCheck => sub {
             my $self  = shift;
             my $value = shift;
@@ -958,6 +1008,7 @@ our %META;
         },
     },
     WebURL => {
+        Immutable     => 1,
         PostLoadCheck => sub {
             my $self  = shift;
             my $value = shift;
@@ -1103,7 +1154,12 @@ our %META;
         },
     },
 
+    ExternalAuth => {
+        Immutable => 1,
+    },
+
     ExternalSettings => {
+        Immutable     => 1,
         Obfuscate => sub {
             # Ensure passwords are obfuscated on the System Configuration page
             my ($config, $sources, $user) = @_;
@@ -1167,6 +1223,7 @@ our %META;
     },
 
     ExternalAuthPriority => {
+        Immutable     => 1,
         PostLoadCheck => sub {
             my $self = shift;
             my @values = @{ shift || [] };
@@ -1195,6 +1252,7 @@ our %META;
     },
 
     ExternalInfoPriority => {
+        Immutable     => 1,
         PostLoadCheck => sub {
             my $self = shift;
             my @values = @{ shift || [] };

commit f2f0b772d772f212355e3d7df644c2f30efefe59
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Aug 15 19:40:29 2017 +0000

    Add widget metadata for config options

diff --git a/lib/RT/Config.pm b/lib/RT/Config.pm
index 30c81ee72b..5d365fec6a 100644
--- a/lib/RT/Config.pm
+++ b/lib/RT/Config.pm
@@ -639,6 +639,7 @@ our %META;
     Organization => {
         Type            => 'SCALAR',
         Immutable       => 1,
+        Widget          => '/Widgets/Form/String',
         PostLoadCheck   => sub {
             my ($self,$value) = @_;
             $RT::Logger->error("your \$Organization setting ($value) appears to contain whitespace.  Please fix this.")
@@ -648,6 +649,7 @@ our %META;
 
     rtname => {
         Immutable => 1,
+        Widget    => '/Widgets/Form/String',
     },
 
     # Internal config options
@@ -657,27 +659,35 @@ our %META;
     },
     DatabaseAdmin => {
         Immutable => 1,
+        Widget    => '/Widgets/Form/String',
     },
     DatabaseHost => {
         Immutable => 1,
+        Widget    => '/Widgets/Form/String',
     },
     DatabaseName => {
         Immutable => 1,
+        Widget    => '/Widgets/Form/String',
     },
     DatabasePassword => {
         Immutable => 1,
+        Widget    => '/Widgets/Form/String',
     },
     DatabasePort => {
         Immutable => 1,
+        Widget    => '/Widgets/Form/Integer',
     },
     DatabaseRTHost => {
         Immutable => 1,
+        Widget    => '/Widgets/Form/String',
     },
     DatabaseType => {
         Immutable => 1,
+        Widget    => '/Widgets/Form/String',
     },
     DatabaseUser => {
         Immutable => 1,
+        Widget    => '/Widgets/Form/String',
     },
 
     FullTextSearch => {
@@ -736,6 +746,7 @@ our %META;
     },
     DisableGraphViz => {
         Type            => 'SCALAR',
+        Widget          => '/Widgets/Form/Boolean',
         PostLoadCheck   => sub {
             my $self  = shift;
             my $value = shift;
@@ -747,6 +758,7 @@ our %META;
     },
     DisableGD => {
         Type            => 'SCALAR',
+        Widget          => '/Widgets/Form/Boolean',
         PostLoadCheck   => sub {
             my $self  = shift;
             my $value = shift;
@@ -758,6 +770,7 @@ our %META;
     },
     MailCommand => {
         Type    => 'SCALAR',
+        Widget  => '/Widgets/Form/String',
         PostLoadCheck => sub {
             my $self = shift;
             my $value = $self->Get('MailCommand');
@@ -769,6 +782,7 @@ our %META;
     },
     HTMLFormatter => {
         Type => 'SCALAR',
+        Widget => '/Widgets/Form/String',
         PostLoadCheck => sub { RT::Interface::Email->_HTMLFormatter },
     },
     Plugins => {
@@ -776,15 +790,19 @@ our %META;
     },
     RecordBaseClass => {
         Immutable => 1,
+        Widget    => '/Widgets/Form/String',
     },
     WebSessionClass => {
         Immutable => 1,
+        Widget    => '/Widgets/Form/String',
     },
     DevelMode => {
         Immutable => 1,
+        Widget    => '/Widgets/Form/Boolean',
     },
     DisallowExecuteCode => {
         Immutable => 1,
+        Widget    => '/Widgets/Form/Boolean',
     },
     MailPlugins  => {
         Type => 'ARRAY',
@@ -915,6 +933,7 @@ our %META;
     CustomFieldValuesCanonicalizers => { Type => 'ARRAY' },
     WebPath => {
         Immutable     => 1,
+        Widget        => '/Widgets/Form/String',
         PostLoadCheck => sub {
             my $self  = shift;
             my $value = shift;
@@ -941,7 +960,8 @@ our %META;
         },
     },
     WebDomain => {
-        Immutable => 1,
+        Immutable     => 1,
+        Widget        => '/Widgets/Form/String',
         PostLoadCheck => sub {
             my $self  = shift;
             my $value = shift;
@@ -969,6 +989,7 @@ our %META;
     },
     WebPort => {
         Immutable     => 1,
+        Widget        => '/Widgets/Form/Integer',
         PostLoadCheck => sub {
             my $self  = shift;
             my $value = shift;
@@ -985,6 +1006,7 @@ our %META;
     },
     WebBaseURL => {
         Immutable     => 1,
+        Widget        => '/Widgets/Form/String',
         PostLoadCheck => sub {
             my $self  = shift;
             my $value = shift;
@@ -1009,6 +1031,7 @@ our %META;
     },
     WebURL => {
         Immutable     => 1,
+        Widget => '/Widgets/Form/String',
         PostLoadCheck => sub {
             my $self  = shift;
             my $value = shift;
@@ -1156,6 +1179,7 @@ our %META;
 
     ExternalAuth => {
         Immutable => 1,
+        Widget    => '/Widgets/Form/Boolean',
     },
 
     ExternalSettings => {
@@ -1286,7 +1310,6 @@ our %META;
             $self->Set( 'ExternalInfoPriority', \@values );
         },
     },
-
     ServiceBusinessHours => {
         Type => 'HASH',
         PostLoadCheck   => sub {
@@ -1299,10 +1322,369 @@ our %META;
             }
         },
     },
-
     ServiceAgreements => {
         Type => 'HASH',
     },
+    AllowUserAutocompleteForUnprivileged => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    AlwaysDownloadAttachments => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    AmbiguousDayInFuture => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    AmbiguousDayInPast => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    ApprovalRejectionNotes => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    ArticleOnTicketCreate => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    AutocompleteOwnersForSearch => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    CanonicalizeRedirectURLs => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    CanonicalizeURLsInFeeds => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    ChartsTimezonesInDB => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    CheckMoreMSMailHeaders => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    DateDayBeforeMonth => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    DontSearchFileAttachments => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    DropLongAttachments => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    EditCustomFieldsSingleColumn => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    EnableReminders => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    ExternalStorageDirectLink => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    ForceApprovalsView => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    ForwardFromUser => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    Framebusting => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    HideArticleSearchOnReplyCreate => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    HideResolveActionsWithDependencies => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    HideTimeFieldsFromUnprivilegedUsers => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    LoopsToRTOwner => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    MessageBoxIncludeSignature => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    MessageBoxIncludeSignatureOnComment => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    OnlySearchActiveTicketsInSimpleSearch => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    ParseNewMessageForTicketCcs => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    PreferDateTimeFormatNatural => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    PreviewScripMessages => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    RecordOutgoingEmail => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    RestrictLoginReferrer => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    RestrictReferrer => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    SearchResultsAutoRedirect => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    ShowBccHeader => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    ShowMoreAboutPrivilegedUsers => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    ShowRTPortal => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    ShowRemoteImages => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    ShowTransactionImages => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    StoreLoops => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    StrictLinkACL => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    SuppressInlineTextFiles => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    TruncateLongAttachments => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    TrustHTMLAttachments => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    UseFriendlyFromLine => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    UseFriendlyToLine => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    UseOriginatorHeader => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    UseSQLForACLChecks => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    UseTransactionBatch => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    ValidateUserEmailAddresses => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    VERPPrefix => {
+        Widget => '/Widgets/Form/String',
+        WidgetArguments => { Hints  => 'rt-', },
+    },
+    VERPDomain => {
+        Widget => '/Widgets/Form/String',
+        WidgetArguments => {
+            Callback => sub {  return { Hints => RT->Config->Get( 'Organization') } },
+        },
+    },
+    WebFallbackToRTLogin => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    WebFlushDbCacheEveryRequest => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    WebHttpOnlyCookies => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    WebRemoteUserAuth => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    WebRemoteUserAutocreate => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    WebRemoteUserContinuous => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    WebRemoteUserGecos => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    WebSecureCookies => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    WikiImplicitLinks => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    HideOneTimeSuggestions => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    LinkArticlesOnInclude => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    SelfServiceCorrespondenceOnly => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+    ShowSearchResultCount => {
+        Widget => '/Widgets/Form/Boolean',
+    },
+
+    AttachmentListCount => {
+        Widget => '/Widgets/Form/Integer',
+    },
+    AutoLogoff => {
+        Widget => '/Widgets/Form/Integer',
+    },
+    BcryptCost => {
+        Widget => '/Widgets/Form/Integer',
+    },
+    DefaultSummaryRows => {
+        Widget => '/Widgets/Form/Integer',
+    },
+    ExternalStorageCutoffSize => {
+        Widget => '/Widgets/Form/Integer',
+    },
+    LogoutRefresh => {
+        Widget => '/Widgets/Form/Integer',
+    },
+    MaxAttachmentSize => {
+        Widget => '/Widgets/Form/Integer',
+    },
+    MaxFulltextAttachmentSize => {
+        Widget => '/Widgets/Form/Integer',
+    },
+    MinimumPasswordLength => {
+        Widget => '/Widgets/Form/Integer',
+    },
+    MoreAboutRequestorGroupsLimit => {
+        Widget => '/Widgets/Form/Integer',
+    },
+    TicketsItemMapSize => {
+        Widget => '/Widgets/Form/Integer',
+    },
+
+    CommentAddress => {
+        Widget => '/Widgets/Form/String',
+    },
+    CorrespondAddress => {
+        Widget => '/Widgets/Form/String',
+    },
+    DashboardAddress => {
+        Widget => '/Widgets/Form/String',
+    },
+    DashboardSubject => {
+        Widget => '/Widgets/Form/String',
+    },
+    DefaultErrorMailPrecedence => {
+        Widget => '/Widgets/Form/String',
+    },
+    DefaultMailPrecedence => {
+        Widget => '/Widgets/Form/String',
+    },
+    DefaultSearchResultOrderBy => {
+        Widget => '/Widgets/Form/String',
+    },
+    EmailOutputEncoding => {
+        Widget => '/Widgets/Form/String',
+    },
+    FriendlyFromLineFormat => {
+        Widget => '/Widgets/Form/String',
+    },
+    FriendlyToLineFormat => {
+        Widget => '/Widgets/Form/String',
+    },
+    LogDir => {
+        Widget => '/Widgets/Form/String',
+    },
+    LogToFileNamed => {
+        Widget => '/Widgets/Form/String',
+    },
+    LogoAltText => {
+        Widget => '/Widgets/Form/String',
+    },
+    LogoLinkURL => {
+        Widget => '/Widgets/Form/String',
+    },
+    LogoURL => {
+        Widget => '/Widgets/Form/String',
+    },
+    OwnerEmail => {
+        Widget => '/Widgets/Form/String',
+    },
+    RedistributeAutoGeneratedMessages => {
+        Widget => '/Widgets/Form/String',
+    },
+    SendmailArguments => {
+        Widget => '/Widgets/Form/String',
+    },
+    SendmailBounceArguments => {
+        Widget => '/Widgets/Form/String',
+    },
+    SendmailPath => {
+        Widget => '/Widgets/Form/String',
+    },
+    SetOutgoingMailFrom => {
+        Widget => '/Widgets/Form/String',
+    },
+    Timezone => {
+        Widget => '/Widgets/Form/String',
+    },
+    WebImagesURL => {
+        Widget => '/Widgets/Form/String',
+    },
+
+    AssetSearchFormat => {
+        Widget => '/Widgets/Form/MultilineString',
+    },
+    AssetSummaryFormat => {
+        Widget => '/Widgets/Form/MultilineString',
+    },
+    AssetSummaryRelatedTicketsFormat => {
+        Widget => '/Widgets/Form/MultilineString',
+    },
+    DefaultSearchResultFormat => {
+        Widget => '/Widgets/Form/MultilineString',
+    },
+    DefaultSelfServiceSearchResultFormat => {
+        Widget => '/Widgets/Form/MultilineString',
+    },
+    MoreAboutRequestorExtraInfo => {
+        Widget => '/Widgets/Form/MultilineString',
+    },
+    MoreAboutRequestorTicketListFormat => {
+        Widget => '/Widgets/Form/MultilineString',
+    },
+    UserSearchResultFormat => {
+        Widget => '/Widgets/Form/MultilineString',
+    },
+    UserSummaryExtraInfo => {
+        Widget => '/Widgets/Form/MultilineString',
+    },
+    UserSummaryTicketListFormat => {
+        Widget => '/Widgets/Form/MultilineString',
+    },
+
+    LogToSyslog => {
+        Widget => '/Widgets/Form/Select',
+        WidgetArguments => { Values => [qw(debug info notice warning error critical alert emergency)] },
+    },
+    LogToSTDERR => {
+        Widget => '/Widgets/Form/Select',
+        WidgetArguments => { Values => [qw(debug info notice warning error critical alert emergency)] },
+    },
+    LogToFile => {
+        Widget => '/Widgets/Form/Select',
+        WidgetArguments => { Values => [qw(debug info notice warning error critical alert emergency)] },
+    },
+    LogStackTraces => {
+        Widget => '/Widgets/Form/Select',
+        WidgetArguments => { Values => [qw(debug info notice warning error critical alert emergency)] },
+    },
+    StatementLog => {
+        Widget => '/Widgets/Form/Select',
+        WidgetArguments => { Values => [qw(debug info notice warning error critical alert emergency)] },
+    },
+
+    DefaultSearchResultOrder => {
+        Widget => '/Widgets/Form/Select',
+        WidgetArguments => { Values => [qw(ASC DESC)] },
+    },
 );
 my %OPTIONS = ();
 my @LOADED_CONFIGS = ();

commit 5803325e5fb9f8956167427659a10a079c3608c3
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Aug 15 19:54:10 2017 +0000

    Hide deprecated options

diff --git a/share/html/Admin/Tools/EditConfig.html b/share/html/Admin/Tools/EditConfig.html
index c3185edf47..c606d5e0d8 100644
--- a/share/html/Admin/Tools/EditConfig.html
+++ b/share/html/Admin/Tools/EditConfig.html
@@ -189,7 +189,7 @@ my $index_conf;
 foreach my $key ( RT->Config->Options( Overridable => undef, Sorted => 0 ) ) {
     my $meta = RT->Config->Meta( $key );
 
-    next if $meta->{Invisible};
+    next if $meta->{Invisible} || $meta->{Deprecated};
 
     my $raw_value = RT->Config->Get( $key );
     my $val = $stringify->($raw_value);

commit b878fb9786a89b2e390114178f82c1b0a4d80e2c
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Aug 15 20:08:02 2017 +0000

    List Database as source of configuration on Sys Config page

diff --git a/lib/RT/Config.pm b/lib/RT/Config.pm
index 5d365fec6a..4252fbcb74 100644
--- a/lib/RT/Config.pm
+++ b/lib/RT/Config.pm
@@ -2142,7 +2142,7 @@ sub SetFromConfig {
     }
 
     $META{$name}->{'Type'} = $type;
-    foreach (qw(Package File Line SiteConfig Extension)) {
+    foreach (qw(Package File Line SiteConfig Extension Database)) {
         $META{$name}->{'Source'}->{$_} = $args{$_};
     }
     $self->Set( $name, @{ $args{'Value'} } );
@@ -2392,6 +2392,7 @@ sub LoadConfigFromDatabase {
             Package    => 'N/A',
             File       => 'database',
             Line       => 'N/A',
+            Database   => 1,
             SiteConfig => 1,
         );
     }
diff --git a/share/html/Admin/Tools/Configuration.html b/share/html/Admin/Tools/Configuration.html
index baf0abf027..0f34470a5b 100644
--- a/share/html/Admin/Tools/Configuration.html
+++ b/share/html/Admin/Tools/Configuration.html
@@ -70,7 +70,10 @@ foreach my $key ( RT->Config->Options( Overridable => undef, Sorted => 0 ) ) {
 
     my $meta = RT->Config->Meta( $key );
     my $description = '';
-    if ( $meta->{'Source'}{'Extension'} && $meta->{'Source'}{'SiteConfig'} ) {
+    if ( $meta->{'Source'}{'Database'}) {
+        $description = loc("database");
+    }
+    elsif ( $meta->{'Source'}{'Extension'} && $meta->{'Source'}{'SiteConfig'} ) {
         $description = loc("[_1] site config", $meta->{'Source'}{'Extension'});
     }
     elsif ( $meta->{'Source'}{'Extension'} ) {

commit 02b6a1a9fb1afbcb06e694c642dd4dbf256a62b4
Author: Shawn M Moore <shawn at bestpractical.com>
Date:   Tue Sep 5 14:56:57 2017 -0400

    Render config with EditLink as readonly
    
    That way you can still easily export the settings to another RT

diff --git a/share/html/Admin/Tools/EditConfig.html b/share/html/Admin/Tools/EditConfig.html
index c606d5e0d8..62adc78734 100644
--- a/share/html/Admin/Tools/EditConfig.html
+++ b/share/html/Admin/Tools/EditConfig.html
@@ -229,6 +229,11 @@ foreach my $key ( RT->Config->Options( Overridable => undef, Sorted => 0 ) ) {
 <td class="collection-as-table">
 
 % if ( $meta->{EditLink} ) {
+% if ($widget eq '/Widgets/Form/MultilineString' || $widget eq '/Widgets/Form/Code') {
+<textarea disabled class="<% $is_code ? 'code' : '' %>" rows="6" cols="80"><% $current_value %></textarea><br>
+% } else {
+<input type="text" disabled width="80" value="<% $current_value %>"></input><br>
+% }
 <&|/l_unsafe, "<a href=\"$meta->{EditLink}\">", loc($meta->{EditLinkLabel}), "</a>" &>Visit [_1][_2][_3] to manage this setting</&>
 % } elsif ( $key =~ /Plugins/) {
 <ul class="plugins">

commit 94cbaf03d29977a212883a978ce33d7aa6f0a624
Author: michel <michel at bestpractical.com>
Date:   Thu Oct 24 18:40:45 2019 +0200

    Switch from Storable::dclone to Clone::clone to handle code/regex

diff --git a/lib/RT/Config.pm b/lib/RT/Config.pm
index 4252fbcb74..08eee2a89b 100644
--- a/lib/RT/Config.pm
+++ b/lib/RT/Config.pm
@@ -55,7 +55,7 @@ use 5.010;
 use File::Spec ();
 use Symbol::Global::Name;
 use List::MoreUtils 'uniq';
-use Storable;
+use Clone ();
 
 # Store log messages generated before RT::Logger is available
 our @PreInitLoggerMessages;
@@ -2004,7 +2004,6 @@ sub GetObfuscated {
 
     return $self->Get(@_) unless $obfuscate;
 
-    require Clone;
     my $res = Clone::clone( $self->Get( @_ ) );
     $res = $obfuscate->( $self, $res, $user );
     return $self->_ReturnValue( $res, $META{$name}->{'Type'} || 'SCALAR' );
@@ -2359,7 +2358,7 @@ sub LoadConfigFromDatabase {
         if (!exists $original_setting_from_files{$name}) {
             $original_setting_from_files{$name} = [
                 scalar($self->Get($name)),
-                Storable::dclone(scalar($self->Meta($name))),
+                Clone::clone(scalar($self->Meta($name))),
             ];
         }
 

commit c0d5683bfb5117a7da125bd9f06864d37a3f855c
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Mon Nov 18 21:54:28 2019 +0800

    Migrate MultilineString to new themes

diff --git a/share/html/Widgets/Form/MultilineString b/share/html/Widgets/Form/MultilineString
index fa6a74758d..12f7356138 100644
--- a/share/html/Widgets/Form/MultilineString
+++ b/share/html/Widgets/Form/MultilineString
@@ -48,13 +48,16 @@
 <%DOC>
 see docs/extending/using_forms_widgets.pod
 </%DOC>
-<div id="form-box-<% lc $Name %>" class="widget <% $Class %>">
-<span class="description label"><% $Description %></span>
-<span class="value"><& SELF:InputOnly, %ARGS &></span>
+<div id="form-box-<% lc $Name %>" class="widget form-row <% $Class %>">
+  <div class="col-md-3 label">
+    <% $Description %>
+  </div>
+  <div class="col-md-9 value"><& SELF:InputOnly, %ARGS &>
 % if ( $Default ) {
-<span class="comment"><% $DefaultLabel %></span>
+    <span class="comment"><% $DefaultLabel %></span>
 % }
-<span class="hints"><% $Hints %></span>
+    <span class="hints"><% $Hints %></span>
+  </div>
 </div>
 <%ARGS>
 $Name
@@ -71,7 +74,7 @@ $DefaultLabel   => loc( 'Default: [_1]', $DefaultValue ),
 </%ARGS>
 
 <%METHOD InputOnly>
-<textarea name="<% $Name %>" cols="<% $Cols %>" rows="<% $Rows %>"><% $CurrentValue %></textarea>
+<textarea name="<% $Name %>" class="form-control" cols="<% $Cols %>" rows="<% $Rows %>"><% $CurrentValue %></textarea>
 <%ARGS>
 $Name
 $Cols => 80

commit 9fcde4980af72e5a5d13a45419a5091e1e7014c6
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Mon Nov 18 21:52:00 2019 +0800

    Add LabelLink support for form widgets

diff --git a/share/html/Widgets/Form/Boolean b/share/html/Widgets/Form/Boolean
index 3b7091b753..c965537d29 100644
--- a/share/html/Widgets/Form/Boolean
+++ b/share/html/Widgets/Form/Boolean
@@ -50,7 +50,11 @@ see docs/extending/using_forms_widgets.pod
 </%DOC>
 <div id="form-box-<% lc $Name %>" class="widget form-row">
   <div class="col-md-3 label">
-    <% $Description // '' %>
+% if( $LabelLink ) {
+    <a href="<% $LabelLink %>"><% $Description %></a>
+% } else {
+    <% $Description %>
+% }
   </div>
   <div class="col-md-9 value">
     <& SELF:InputOnly, %ARGS &>
@@ -61,6 +65,7 @@ see docs/extending/using_forms_widgets.pod
 $Name         => undef,
 $Description  => undef,
 $Hints        => ''
+$LabelLink    => ''
 </%ARGS>
 
 <%METHOD InputOnly>
diff --git a/share/html/Widgets/Form/Integer b/share/html/Widgets/Form/Integer
index 918fe88b0e..82d6206cc9 100644
--- a/share/html/Widgets/Form/Integer
+++ b/share/html/Widgets/Form/Integer
@@ -50,7 +50,11 @@ see docs/extending/using_forms_widgets.pod
 </%DOC>
 <div id="form-box-<% lc $Name %>" class="widget form-row">
   <div class="col-md-3 label">
-    <% $Description // '' %>
+% if( $LabelLink ) {
+    <a href="<% $LabelLink %>"><% $Description %></a>
+% } else {
+    <% $Description %>
+% }
   </div>
   <div class="col-md-9 value">
     <& SELF:InputOnly, %ARGS &>
@@ -75,6 +79,7 @@ $CurrentValue => '',
 $Default        => 0,
 $DefaultValue   => 0,
 $DefaultLabel   => undef
+$LabelLink      => ''
 </%ARGS>
 
 <%METHOD InputOnly>
diff --git a/share/html/Widgets/Form/MultilineString b/share/html/Widgets/Form/MultilineString
index 12f7356138..89106cdb39 100644
--- a/share/html/Widgets/Form/MultilineString
+++ b/share/html/Widgets/Form/MultilineString
@@ -50,7 +50,11 @@ see docs/extending/using_forms_widgets.pod
 </%DOC>
 <div id="form-box-<% lc $Name %>" class="widget form-row <% $Class %>">
   <div class="col-md-3 label">
+% if( $LabelLink ) {
+    <a href="<% $LabelLink %>"><% $Description %></a>
+% } else {
     <% $Description %>
+% }
   </div>
   <div class="col-md-9 value"><& SELF:InputOnly, %ARGS &>
 % if ( $Default ) {
@@ -71,6 +75,7 @@ $CurrentValue => '',
 $Default        => 0,
 $DefaultValue   => '',
 $DefaultLabel   => loc( 'Default: [_1]', $DefaultValue ),
+$LabelLink      => ''
 </%ARGS>
 
 <%METHOD InputOnly>
diff --git a/share/html/Widgets/Form/Select b/share/html/Widgets/Form/Select
index 5f8e3e6147..0711a50cf2 100644
--- a/share/html/Widgets/Form/Select
+++ b/share/html/Widgets/Form/Select
@@ -50,7 +50,11 @@ see docs/extending/using_forms_widgets.pod
 </%DOC>
 <div id="form-box-<% lc $Name %>" class="widget form-row">
   <div class="col-md-3 label">
-    <% $Description // '' %>
+% if( $LabelLink ) {
+    <a href="<% $LabelLink %>"><% $Description %></a>
+% } else {
+    <% $Description %>
+% }
   </div>
   <div class="col-md-9 value">
     <& SELF:InputOnly, %ARGS &>
@@ -61,6 +65,7 @@ see docs/extending/using_forms_widgets.pod
 $Name
 $Description      => undef,
 $Hints            => ''
+$LabelLink        => ''
 </%ARGS>
 
 <%METHOD InputOnly>
diff --git a/share/html/Widgets/Form/String b/share/html/Widgets/Form/String
index 24cada9716..dc2c7514ca 100644
--- a/share/html/Widgets/Form/String
+++ b/share/html/Widgets/Form/String
@@ -50,7 +50,11 @@ see docs/extending/using_forms_widgets.pod
 </%DOC>
 <div id="form-box-<% lc $Name %>" class="widget form-row">
   <div class="col-md-3 label">
+% if( $LabelLink ) {
+    <a href="<% $LabelLink %>"><% $Description %></a>
+% } else {
     <% $Description // '' %>
+% }
   </div>
   <div class="col-md-9 value">
     <& SELF:InputOnly, %ARGS &>
@@ -71,6 +75,7 @@ $CurrentValue => '',
 $Default        => 0,
 $DefaultValue   => '',
 $DefaultLabel   => loc( 'Default: [_1]', $DefaultValue ),
+$LabelLink      => '',
 </%ARGS>
 
 <%METHOD InputOnly>

commit 1eee9b8f5580699d6ea70bf14017f0bbc1b25c74
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Mon Nov 18 22:13:07 2019 +0800

    Vertically align boolean label/value

diff --git a/share/html/Widgets/Form/Boolean b/share/html/Widgets/Form/Boolean
index c965537d29..f54b6bcf16 100644
--- a/share/html/Widgets/Form/Boolean
+++ b/share/html/Widgets/Form/Boolean
@@ -48,7 +48,7 @@
 <%DOC>
 see docs/extending/using_forms_widgets.pod
 </%DOC>
-<div id="form-box-<% lc $Name %>" class="widget form-row">
+<div id="form-box-<% lc $Name %>" class="widget form-row boolean">
   <div class="col-md-3 label">
 % if( $LabelLink ) {
     <a href="<% $LabelLink %>"><% $Description %></a>
diff --git a/share/static/css/elevator-light/forms.css b/share/static/css/elevator-light/forms.css
index 2554a2afa9..e6c8943ff2 100644
--- a/share/static/css/elevator-light/forms.css
+++ b/share/static/css/elevator-light/forms.css
@@ -303,6 +303,10 @@ button {
   margin-top: 0;
 }
 
+.form-row.boolean > .value .form-row {
+  margin-top: 5px;
+}
+
 .col-auto .datepicker {
   width: 17em;
 }

commit fb5f04660ac015cb761ade82b438a4110c6ca322
Author: michel <michel at bestpractical.com>
Date:   Thu Nov 7 17:11:47 2019 +0100

    Add tabs to the Configuration in DB feature
    
    The tab/section structure is set in Config.pm, subsections
    are defined by parsing RT_Config.pm.
    
    The whole page now uses bootstrap with 4.6 styles.

diff --git a/lib/RT/Config.pm b/lib/RT/Config.pm
index 08eee2a89b..2f961041ef 100644
--- a/lib/RT/Config.pm
+++ b/lib/RT/Config.pm
@@ -166,6 +166,7 @@ our %META;
         Widget          => '/Widgets/Form/Select',
         WidgetArguments => {
             Description => 'Default queue',    #loc
+            Default     => 1, # allow user to unset it on EditConfig.html
             Callback    => sub {
                 my $ret = { Values => [], ValuesLabel => {}};
                 my $q = RT::Queues->new($HTML::Mason::Commands::session{'CurrentUser'});
@@ -1886,6 +1887,133 @@ sub PostLoadCheck {
     }
 }
 
+=head2 SectionMap
+
+A data structure used to breakup the option list into tabs/sections/subsections/options
+This is done by parsing RT_Config.pm and extracting the section names and level
+
+=cut
+
+# initial data, manually created to give the tab structure and the order of the sections
+# the result of the parsing of RT_Config.pm will be added to this
+# sections will have content: a list of subsections with a Name and a Content
+our $SectionMap= [
+    { Name    => 'System', # loc
+      Content => [
+          { Name => 'Base configuration' }, # loc
+          { Name => 'Database connection' }, # loc
+          { Name => 'Logging' }, # loc
+          { Name => 'Incoming mail gateway' }, # loc
+          { Name => 'Outgoing mail' }, # loc
+          { Name => 'Application logic' }, # loc
+          { Name => 'Extra security' }, # loc
+          { Name => 'Internationalization' }, # loc
+          { Name => 'Date and time handling' }, # loc
+          { Name => 'Initialdata Formats' }, # loc
+          { Name => 'Development options' }, # loc
+      ],
+    },
+    { Name => 'Web UI', # loc
+      Content => [
+          { Name => 'Web interface' }, # loc
+      ],
+    },
+    { Name => 'Features', # loc
+      Content => [
+          { Name => 'Assets' }, # loc
+          { Name => 'Cryptography' }, # loc
+          { Name => 'External storage' }, # loc
+          { Name => 'SLA' }, # loc
+          { Name => 'Administrative interface' }, # loc
+      ],
+    },
+    { Name => 'User Auth', # loc
+      Content => [
+          { Name => 'Authorization and user configuration' }, # loc
+      ],
+    },
+];
+
+our $SectionMapLoaded = 0;    # so we only load it once
+
+sub LoadSectionMap {
+    my $self = shift;
+
+    if ($SectionMapLoaded) {
+        return $SectionMap;
+    }
+
+    # create a hash <section> => <tab> / Content so we know in which tab to look for a section
+    my %SectionIndex;
+    foreach my $Tab (@$SectionMap) {
+        my $TabName = $Tab->{Name};
+        foreach my $section ( @{ $Tab->{Content} } ) {
+            $section->{Content} = [];
+            $SectionIndex{ $section->{Name} } = { Tab => $TabName, Content => $section->{Content} };
+        }
+    }
+
+    my $ConfigFile = "$RT::EtcPath/RT_Config.pm";
+    require Pod::Simple::HTML;
+    my $PodParser  = Pod::Simple::HTML->new();
+
+    my $html;
+    $PodParser->output_string( \$html );
+    $PodParser->parse_file($ConfigFile);
+
+    my $CurrentTabName;
+    my $CurrentSectionName;
+    my $CurrentSubSectionName;
+    my $CurrentSectionContent;
+
+    while ( $html =~ m{<(h[12]|dt)\b[^>]*>(.*?)</\1>}sg ) {
+        my ( $tag, $content ) = ( $1, $2 );
+        if ( $tag eq 'h1' ) {
+            my ( $id, $title ) = $content =~ m{<a class='u'\s*name="([^"]*)"\s*>([^<]*)</a>};
+            next if $title eq 'NAME';
+            $CurrentSectionName = $title;
+            if ( $SectionIndex{$CurrentSectionName}->{Tab} ) {
+                $CurrentTabName = $SectionIndex{$CurrentSectionName}->{Tab};
+
+                # create a sub section with no name, for section level options
+                push @{ $SectionIndex{$CurrentSectionName}->{Content} }, { Name => '', Content => [] };
+                $CurrentSectionContent = $SectionIndex{$CurrentSectionName}->{Content};
+                $CurrentSubSectionName = '';
+            }
+            else {
+                RT->Logger->debug("section $CurrentSectionName not found in SectionMap");
+            }
+        }
+        elsif ( $tag eq 'h2' ) {
+            my ( $id, $title ) = $content =~ m{<a class='u'\s*name="([^"]*)"\s*>([^<]*)</a>};
+            $CurrentSubSectionName = $title;
+            push @$CurrentSectionContent, { Name => $CurrentSubSectionName, Content => [] };
+        }
+        else {
+            # tag is 'dt'
+            my @options;
+
+            # a single item (dt) can document several options, in separate <code> elements
+            my ($name) = $content =~ m{name=".([^"]*)"};
+            $name =~ s{,_.}{-}g;
+            while ( $content =~ m{<code>(.)([^<]*)</code>}sg ) {
+                my ( $sigil, $option ) = ( $1, $2 );
+                next unless $sigil =~ m{[\@\%\$]};    # no sigil => this is a value for a select option
+                if ( $META{$option} ) {
+                    my $LastSubSectionContent = $CurrentSectionContent->[-1]->{Content};
+                    push @$LastSubSectionContent, { Name => $option, Help => $name };
+                }
+                else {
+                    my $TabName = $SectionIndex{$CurrentSectionName}->{Name};
+                    RT->Logger->debug("missing META info for option [$option]");
+                }
+            }
+        }
+    }
+    $SectionMapLoaded = 1;
+    return $SectionMap;
+}
+
 =head2 Configs
 
 Returns list of config files found in local etc, plugins' etc
@@ -2333,6 +2461,7 @@ sub RefreshConfigFromDatabase {
         $in_config_change_txn = 0;
     }
 
+    if( RT->InstallMode ) { return; } # RT can't load the config in the DB if the DB is not there!
     my $needs_update = RT->System->ConfigCacheNeedsUpdate;
     if ($needs_update > $database_config_cache_time) {
         $self->LoadConfigFromDatabase();
diff --git a/share/html/Admin/Tools/Config/Elements/Option b/share/html/Admin/Tools/Config/Elements/Option
new file mode 100644
index 0000000000..ff216fd323
--- /dev/null
+++ b/share/html/Admin/Tools/Config/Elements/Option
@@ -0,0 +1,155 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work 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 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+
+<%PERL>
+
+use Data::Dumper;
+my $stringify = sub {
+    my $value = shift;
+    return "" if !defined($value);
+
+    local $Data::Dumper::Terse = 1;
+    local $Data::Dumper::Indent = 2;
+    local $Data::Dumper::Sortkeys = 1;
+    my $output = Dumper $value;
+    chomp $output;
+    return $output;
+};
+
+my $doc_version = $RT::VERSION;
+$doc_version =~ s/\.\d+-\d+-g\w+$//;  # 4.4.3-1-g123 -> 4.4
+
+my $name = $option->{Name};
+my $meta = RT->Config->Meta( $name );
+return if $meta->{Invisible} || $meta->{Deprecated};
+
+my $has_execute_code = $session{CurrentUser}->HasRight(Right => 'ExecuteCode', Object => RT->System);
+
+my $raw_value = RT->Config->Get( $name );
+my $val = $stringify->($raw_value);
+my $doc_url = "https://docs.bestpractical.com/rt/$doc_version/RT_Config.html#$option->{Help}";
+my $widget = $meta->{'Widget'} || '/Widgets/Form/Code';
+my $is_code = $widget eq '/Widgets/Form/Code';
+my $is_password = ($name =~ /Password/i and $name !~ /MinimumPasswordLength|AllowLoginPasswordAutoComplete/ );
+my $is_immutable = $meta->{Immutable}
+                || $meta->{Obfuscate}
+                || ($is_code && $val =~ s/sub \{ "DUMMY" \}/sub { ... }/g)
+                || ($is_code && !$has_execute_code);
+
+my $current_value = $is_code ? $val : $raw_value;
+my $args   = $meta->{'WidgetArguments'} || {};
+if ($widget eq '/Widgets/Form/Boolean') {
+    %$args = (
+        RadioStyle => 1,
+        %$args,
+    );
+}
+elsif ($widget eq '/Widgets/Form/String' || $widget eq '/Widgets/Form/Integer') {
+    %$args = (
+        Size => 60,
+        %$args,
+    );
+}
+elsif ($widget eq '/Widgets/Form/Select') {
+    %$args = (
+        $args->{Default} ? ( DefaultLabel => loc('(no value)') ) : (),
+        %$args,
+    );
+}
+my $row_start = qq{<div class="widget form-row">
+  <div class="col-md-3 label"><a href="$doc_url" target="_blank">$name</a></div>
+  <div class="col-md-9 value">
+};
+my $row_end = qq{</div></div>};
+
+</%PERL>
+
+<!-- start option <% $name %> -->
+% if ( $meta->{EditLink} ) {
+% if ($widget eq '/Widgets/Form/MultilineString' || $widget eq '/Widgets/Form/Code') {
+<% $row_start |n %><textarea disabled class="<% $is_code ? 'code' : '' %> form-control" rows="6" cols="80"><% $current_value %></textarea><br />
+% } else {
+<% $row_start |n %><input type="text" disabled width="80" value="<% $current_value %>" class="form-control" /><br/>
+% }
+<&|/l_unsafe, "<a href=\"$meta->{EditLink}\">", loc($meta->{EditLinkLabel}), "</a>" &>Visit [_1][_2][_3] to manage this setting</&>
+% } elsif ( $name =~ /Plugins/) {
+<% $row_start |n %><ul class="plugins">
+% for my $plugin (RT->Config->Get($name)) {
+<li><a href="https://metacpan.org/search?q=<% $plugin |u %>" target="_blank"><% $plugin %></a></li>
+% }
+</ul>
+<br /><em><% loc('Must modify in config file' ) %></em>
+<% $row_end |n%>
+% } elsif ( $is_password ) {
+<em><% loc('Must modify in config file' ) %></em><br />
+% } elsif ( $is_immutable ) {
+% if ($widget eq '/Widgets/Form/MultilineString' || $widget eq '/Widgets/Form/Code') {
+<% $row_start |n %><textarea disabled class="<% $is_code ? 'code' : '' %> form-control" rows="6" cols="80"><% $current_value %></textarea>
+% } else {
+<% $row_start |n %><input type="text" disabled width="80" value="<% $current_value %>" class="form-control" />
+% }
+<br /><em><% loc('Must modify in config file' ) %></em>
+<% $row_end |n %>
+% } else {
+  <& $widget,
+    Default      => 0,
+    Name         => $name,
+    LabelLink    => $doc_url,
+    CurrentValue => $current_value,
+    Description  => $name,
+    Hints        => $meta->{WidgetArguments}->{Hints} || '',
+    %$args,
+    %{ $m->comp('/Widgets/FinalizeWidgetArguments', WidgetArguments =>
+            $meta->{'WidgetArguments'} ) },
+  &>
+<textarea class="hidden" name="<% $name %>-Current"><% $current_value %></textarea>
+% }
+<!-- end option <% $name %> -->
+<%ARGS>
+$option
+</%ARGS>
diff --git a/share/html/Admin/Tools/Config/Elements/Section b/share/html/Admin/Tools/Config/Elements/Section
new file mode 100644
index 0000000000..f1454f8ed5
--- /dev/null
+++ b/share/html/Admin/Tools/Config/Elements/Section
@@ -0,0 +1,60 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work 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 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+
+% my $section_id = CSSClass( $section->{Name} );
+
+% foreach my $subsection ( @{$section->{Content}} ) {
+%     $current_context->{subsection} = CSSClass( $subsection->{Name});
+  <& /Admin/Tools/Config/Elements/SubSection, subsection => $subsection, active_context => $active_context, current_context => $current_context &>
+  <!-- end subsection <% $subsection->{Name} %> -->
+% }
+<%ARGS>
+$section
+$active_context
+$current_context
+</%ARGS>
diff --git a/share/html/Admin/Tools/Config/Elements/SubSection b/share/html/Admin/Tools/Config/Elements/SubSection
new file mode 100644
index 0000000000..5e407ea9c5
--- /dev/null
+++ b/share/html/Admin/Tools/Config/Elements/SubSection
@@ -0,0 +1,77 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work 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 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+
+% if( @{$subsection->{Content}}) {
+% my $id = join '-', 'form', $current_context->{tab}, $current_context->{section}, $current_context->{subsection} || ();
+<form id="<% $id %>" name="EditConfig" method="post" action="EditConfig.html#<% $id %>">
+    <input type="hidden" name="Update" value="1" />
+% my $complete_title =
+%     join ' - ', map { s{_}{ }g; loc( $_ ) }
+%     grep { $_ }
+%     map { $current_context->{$_} }
+%     (qw( tab section subsection ) );
+<&|/Widgets/TitleBox, title => $complete_title &>
+% foreach my $option ( @{$subsection->{Content}} ) {
+  <& /Admin/Tools/Config/Elements/Option, option => $option &>
+% }
+<input type="hidden" name="tab" value="<% $current_context->{tab} %>" />
+<input type="hidden" name="section" value="<% $current_context->{section} %>" />
+<input type="hidden" name="subsection" value="<% $current_context->{subsection} %>" />
+<div class="form-row">
+  <span class="col-md-12">
+    <& /Elements/Submit, Label => loc('Save Changes') &>
+  </span>
+</div>
+</&>
+</form>
+% }
+<%ARGS>
+$subsection
+$active_context
+$current_context
+</%ARGS>
diff --git a/share/html/Admin/Tools/Config/Elements/Tab b/share/html/Admin/Tools/Config/Elements/Tab
new file mode 100644
index 0000000000..39e2ecf0c8
--- /dev/null
+++ b/share/html/Admin/Tools/Config/Elements/Tab
@@ -0,0 +1,95 @@
+%# BEGIN BPS TAGGED BLOCK {{{
+%#
+%# COPYRIGHT:
+%#
+%# This software is Copyright (c) 1996-2019 Best Practical Solutions, LLC
+%#                                          <sales at bestpractical.com>
+%#
+%# (Except where explicitly superseded by other copyright notices)
+%#
+%#
+%# LICENSE:
+%#
+%# This work is made available to you under the terms of Version 2 of
+%# the GNU General Public License. A copy of that license should have
+%# been provided with this software, but in any event can be snarfed
+%# from www.gnu.org.
+%#
+%# This work 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 or visit their web page on the internet at
+%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
+%#
+%#
+%# CONTRIBUTION SUBMISSION POLICY:
+%#
+%# (The following paragraph is not intended to limit the rights granted
+%# to you to modify and distribute this software under the terms of
+%# the GNU General Public License and is only of importance to you if
+%# you choose to contribute your changes and enhancements to the
+%# community by submitting them to Best Practical Solutions, LLC.)
+%#
+%# By intentionally submitting any modifications, corrections or
+%# derivatives to this work, or any other work intended for use with
+%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
+%# you are the copyright holder for those contributions and you grant
+%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
+%# royalty-free, perpetual, license to use, copy, create derivative
+%# works based on those contributions, and sublicense and distribute
+%# those contributions and any derivatives thereof.
+%#
+%# END BPS TAGGED BLOCK }}}
+
+% my $nav_type = 'pill'; # 'tab' or 'pill'
+% my $tab_id = CSSClass( $tab->{Name} );
+<div class="row">
+  <div class="col-3">
+% my @section_names = map { $_->{Name} } @{$tab->{Content}};
+    <ul class="nav nav-<% $nav_type %>s flex-column navbar-fixed-top" id="config-sections-<% $tab_id %>" aria-orientation="vertical">
+% my $first_section = 1;
+% foreach my $section_name (@section_names) {
+%     $current_context->{section} = CSSClass( $section_name );
+%     my $active = $current_context->{tab} eq $active_context->{tab} ?
+%       $current_context->{section} eq $active_context->{section} :
+%       $first_section;
+%     $first_section = 0;
+%     my( $active_class, $aria_selected) = $active ? ('active', 'true') : ('', 'false');
+%     my $nav_id = join '-', 'nav', $current_context->{tab}, $current_context->{section};
+%     my $content_id = join '-', 'content', $current_context->{tab}, $current_context->{section};
+      <li class="nav-item">
+        <a class="nav-link <% $active_class %>" id="<% $nav_id %>" data-toggle="<% $nav_type %>" href="#<% $content_id %>" role="<% $nav_type %>" aria-controls="<% $nav_id %>" aria-selected="<% $aria_selected %>"><% $section_name %></a>
+      </li>
+% }
+    </ul>
+  </div>
+  <div class="col-9">
+    <div class="tab-content" id="tab-content-<% $tab_id %>" >
+
+% $first_section = 1;
+% foreach my $section ( @{$tab->{Content}} ) {
+%     $current_context->{section} = CSSClass( $section->{Name} );
+%     my $active = $current_context->{tab} eq $active_context->{tab} ?
+%       $current_context->{section} eq $active_context->{section} :
+%       $first_section;
+%     my $active_class = $active ? 'active show' : '';
+%     $first_section = 0;
+%     my $nav_id = join '-', 'nav', $current_context->{tab}, $current_context->{section};
+%     my $content_id = join '-', 'content', $current_context->{tab}, $current_context->{section};
+      <div class="tab-pane fade <% $active_class %>" role="tabpanel" id="<% $content_id %>" aria-labelledby="<% $nav_id %>">
+        <& /Admin/Tools/Config/Elements/Section, section => $section, current_context => $current_context, active_context => $active_context &>
+      </div><!-- end section <% $content_id %> -->
+% }
+    </div><!-- end of tab tab-content-<% $tab_id %> -->
+  </div>
+</div>
+<%ARGS>
+$tab
+$active_context
+$current_context
+</%ARGS>
diff --git a/share/html/Admin/Tools/EditConfig.html b/share/html/Admin/Tools/EditConfig.html
index 62adc78734..677e70c359 100644
--- a/share/html/Admin/Tools/EditConfig.html
+++ b/share/html/Admin/Tools/EditConfig.html
@@ -53,11 +53,14 @@ unless ($session{'CurrentUser'}->HasRight( Object=> $RT::System, Right => 'Super
 
 my $has_execute_code = $session{CurrentUser}->HasRight(Right => 'ExecuteCode', Object => RT->System);
 
-my @results;
+my $options = RT->Config->LoadSectionMap();
+my $active_context = {
+    tab        => CSSClass( $ARGS{tab}        || $options->[0]->{Name}) ,
+    section    => CSSClass( $ARGS{section}    || $options->[0]->{Content}->[0]->{Name}) ,
+    subsection => CSSClass( $ARGS{subsection} || $options->[0]->{Content}->[0]->{Content}->[0]->{Name}) ,
+};
 
-my $doc_version = $RT::VERSION;
-$doc_version =~ s/rc\d+//; # 4.4.2rc1 -> 4.4.2
-$doc_version =~ s/\.\d+-\d+-g\w+$//;  # 4.4.3-1-g123 -> 4.4
+my @results;
 
 use Data::Dumper;
 my $stringify = sub {
@@ -80,6 +83,7 @@ if (delete $ARGS{Update}) {
     eval {
         for my $key (keys %ARGS) {
             next if $key =~ /-Current$/;
+            next if $key eq 'tab' || $key eq 'section' || $key eq 'subsection';
 
             my $meta = RT->Config->Meta( $key );
             my $widget = $meta->{Widget} || '/Widgets/Form/Code';
@@ -137,7 +141,11 @@ if (delete $ARGS{Update}) {
             $setting->Load($key);
             if ($setting->Id) {
                 if ($setting->Disabled) {
-                    $setting->SetDisabled(0);
+                    my ($ok, $msg) = $setting->SetDisabled(0);
+                    if (!$ok) {
+                        push @results, $msg;
+                        $has_error++;
+                    }
                 }
 
                 my ($ok, $msg) = $setting->SetContent($val);
@@ -170,106 +178,38 @@ if (delete $ARGS{Update}) {
     RT->Config->EndDatabaseConfigChanges;
 }
 
+my $nav_type='tab'; # 'tab' or 'pill'
+
 </%INIT>
 <& /Admin/Elements/Header, Title => $title &>
 <& /Elements/Tabs &>
 <& /Elements/ListActions, actions => \@results &>
 
-<form id="EditConfig" method="post" action="EditConfig.html">
-<input type="hidden" name="Update" value=1></input>
-
-<&|/Widgets/TitleBox, title => loc("RT Configuration") &>
-<table border="0" cellspacing="0" cellpadding="5" width="100%" class="collection">
-<tr class="collection-as-table">
-<th class="collection-as-table"><&|/l&>Option</&></th>
-<th class="collection-as-table"><&|/l&>Value</&></th>
-</tr>
-<%PERL>
-my $index_conf;
-foreach my $key ( RT->Config->Options( Overridable => undef, Sorted => 0 ) ) {
-    my $meta = RT->Config->Meta( $key );
-
-    next if $meta->{Invisible} || $meta->{Deprecated};
-
-    my $raw_value = RT->Config->Get( $key );
-    my $val = $stringify->($raw_value);
-
-    $index_conf++;
-
-    my $doc_url = "https://docs.bestpractical.com/rt/$doc_version/RT_Config.html#$key";
-
-    my $widget = $meta->{'Widget'} || '/Widgets/Form/Code';
-    my $is_code = $widget eq '/Widgets/Form/Code';
-    my $is_password = ($key =~ /Password/i and $key !~ /MinimumPasswordLength|AllowLoginPasswordAutoComplete/ );
-    my $is_immutable = $meta->{Immutable}
-                    || $meta->{Obfuscate}
-                    || ($is_code && $val =~ s/sub { "DUMMY" }/sub { ... }/g)
-                    || ($is_code && !$has_execute_code);
-
-    my $current_value = $is_code ? $val : $raw_value;
-    my $args   = $meta->{'WidgetArguments'} || {};
-
-    if ($widget eq '/Widgets/Form/Boolean') {
-        %$args = (
-            Default => 0,
-            RadioStyle => 1,
-            %$args,
-        );
-    }
-    elsif ($widget eq '/Widgets/Form/String' || $widget eq '/Widgets/Form/Integer') {
-        %$args = (
-            Size => 60,
-            %$args,
-        );
-    }
-
-</%PERL>
-<tr class="<% $key %> <% $index_conf%2 ? 'oddline' : 'evenline'%>">
-<td class="collection-as-table"><a href="<% $doc_url %>" target="_blank"><% $key %></a></td>
-<td class="collection-as-table">
-
-% if ( $meta->{EditLink} ) {
-% if ($widget eq '/Widgets/Form/MultilineString' || $widget eq '/Widgets/Form/Code') {
-<textarea disabled class="<% $is_code ? 'code' : '' %>" rows="6" cols="80"><% $current_value %></textarea><br>
-% } else {
-<input type="text" disabled width="80" value="<% $current_value %>"></input><br>
+<div class="titlebox-content">
+% my @tab_names = map { $_->{Name} } @$options;
+  <ul class="nav nav-<% $nav_type %>s" id="config-tabs">
+% my $current_context = {};
+% foreach my $tab_name (@tab_names) {
+%     my $tab_id = CSSClass( $tab_name );
+%     $current_context->{tab} = $tab_id;
+%     my( $active, $aria_selected) = $tab_id eq $active_context->{tab} ? ('active', 'true') : ('', 'false');
+%     my $nav_id = join '-', 'nav', $current_context->{tab};
+%     my $content_id = join '-', 'content', $current_context->{tab};
+    <li class="nav-item">
+      <a class="nav-link <% $active %>" id="<% $nav_id %>" data-toggle="<% $nav_type %>" href="#<% $content_id %>" role="<% $nav_type %>" aria-controls="<% $content_id %>" aria-selected="<% $aria_selected %>"><% $tab_name %></a>
+    </li>
 % }
-<&|/l_unsafe, "<a href=\"$meta->{EditLink}\">", loc($meta->{EditLinkLabel}), "</a>" &>Visit [_1][_2][_3] to manage this setting</&>
-% } elsif ( $key =~ /Plugins/) {
-<ul class="plugins">
-% for my $plugin (RT->Config->Get($key)) {
-<li><a href="https://metacpan.org/search?q=<% $plugin |u %>" target="_blank"><% $plugin %></a></li>
+  </ul>
+  <div class="tab-content" id="content-all" >
+% foreach my $tab ( @$options) {
+%     my $tab_id = CSSClass( $tab->{Name} );
+%     $current_context->{tab} = $tab_id;
+%     my $active = $tab_id eq $active_context->{tab} ? ' show active' : '';
+%     my $nav_id = join '-', 'nav', $current_context->{tab};
+%     my $content_id = join '-', 'content', $current_context->{tab};
+    <div class="tab-pane fade<% $active %>" role="tabpanel" id="<% $content_id %>" aria-labelledby="<% $nav_id %>">
+      <& /Admin/Tools/Config/Elements/Tab, tab => $tab, active_context => $active_context, current_context => $current_context &>
+    </div><!-- <% $content_id %> -->
 % }
-</ul>
-<br><em><% loc('Must modify in config file' ) %></em>
-% } elsif ( $is_password ) {
-<em><% loc('Must modify in config file' ) %></em>
-% } elsif ( $is_immutable ) {
-% if ($widget eq '/Widgets/Form/MultilineString' || $widget eq '/Widgets/Form/Code') {
-<textarea disabled class="<% $is_code ? 'code' : '' %>" rows="6" cols="80"><% $current_value %></textarea>
-% } else {
-<input type="text" disabled width="80" value="<% $current_value %>"></input>
-% }
-<br><em><% loc('Must modify in config file' ) %></em>
-% } else {
-  <& $widget,
-    Default      => 1,
-    DefaultValue => '',
-    DefaultLabel => '(no value)',
-
-    %{ $m->comp('/Widgets/FinalizeWidgetArguments', WidgetArguments => $args ) },
-    Name         => $key,
-    CurrentValue => $current_value,
-    Description  => '',
-    Hints        => '',
-  &>
-<textarea class="hidden" name="<% $key %>-Current"><% $current_value %></textarea>
-% }
-</td>
-</tr>
-% }
-</table>
-</&>
-<& /Elements/Submit, Label => loc('Save Changes') &>
-</form>
-
+  </div><!-- content-all -->
+</div><!-- titlebox-content -->

commit 21ff2fa0c2e3fc8853a70d1eff9812597c19df55
Author: michel <michel at bestpractical.com>
Date:   Thu Nov 7 17:17:14 2019 +0100

    Add missing config options to %META
    
    Previously config options did not have to be added to %META, config
    in UI now requires it to generate the input form.

diff --git a/lib/RT/Config.pm b/lib/RT/Config.pm
index 2f961041ef..50ecd6f601 100644
--- a/lib/RT/Config.pm
+++ b/lib/RT/Config.pm
@@ -609,6 +609,21 @@ our %META;
             }
         },
     },
+    CanonicalizeEmailAddressMatch => {
+        Section         => 'Mail',                                     #loc
+        Type            => 'SCALAR',
+        Widget          => '/Widgets/Form/String',
+    },
+    CanonicalizeEmailAddressReplace => {
+        Section         => 'Mail',                                     #loc
+        Type            => 'SCALAR',
+        Widget          => '/Widgets/Form/String',
+    },
+    EmailSubjectTagRegex => {
+        Section         => 'Mail',                                     #loc
+        Type            => 'SCALAR',
+        Widget          => '/Widgets/Form/String',
+    },
     # User overridable mail options
     EmailFrequency => {
         Section         => 'Mail',                                     #loc
@@ -1323,6 +1338,12 @@ our %META;
             }
         },
     },
+    UserAutocreateDefaultsOnLogin => {
+        Type => 'HASH',
+    },
+    AutoCreateNonExternalUsers => {
+        Widget => '/Widgets/Form/Boolean',
+    },
     ServiceAgreements => {
         Type => 'HASH',
     },
@@ -1629,7 +1650,18 @@ our %META;
     WebImagesURL => {
         Widget => '/Widgets/Form/String',
     },
-
+    AssetQueues => {
+        Type => 'ARRAY',
+        Hints  => '',
+    },
+    AssetBasicCustomFieldsOnCreate => {
+        Type => 'ARRAY',
+        Hints  => '[ "foo", "bar"]',
+    },
+    DefaultCatalog => {
+        Widget => '/Widgets/Form/String',
+        Hints  => 'General assets',
+    },
     AssetSearchFormat => {
         Widget => '/Widgets/Form/MultilineString',
     },

commit 5523105073afa2c99ece9b90c03fc591ad21cfcb
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Tue Nov 19 08:33:42 2019 +0800

    Rename DatabaseSetting to Configuration
    
    The meaning of "DatabaseSetting" is a bit confusing.

diff --git a/etc/acl.Pg b/etc/acl.Pg
index a48373ec6d..41a44b16c1 100644
--- a/etc/acl.Pg
+++ b/etc/acl.Pg
@@ -66,8 +66,8 @@ sub acl {
         CustomRoles
         objectcustomroles_id_seq
         ObjectCustomRoles
-        databasesettings_id_seq
-        DatabaseSettings
+        configurations_id_seq
+        Configurations
     );
 
     my $db_user = RT->Config->Get('DatabaseUser');
diff --git a/etc/schema.Oracle b/etc/schema.Oracle
index b4ce7404e4..b71b41d9d4 100644
--- a/etc/schema.Oracle
+++ b/etc/schema.Oracle
@@ -539,10 +539,10 @@ CREATE TABLE ObjectCustomRoles (
 );
 CREATE UNIQUE INDEX ObjectCustomRoles1 ON ObjectCustomRoles (ObjectId, CustomRole);
 
-CREATE SEQUENCE DatabaseSettings_seq;
-CREATE TABLE DatabaseSettings (
-    id              NUMBER(11,0)    CONSTRAINT DatabaseSettings_key PRIMARY KEY,
-    Name            VARCHAR2(255) CONSTRAINT DatabaseSettings_Name_Unique unique  NOT NULL,
+CREATE SEQUENCE Configurations_seq;
+CREATE TABLE Configurations (
+    id              NUMBER(11,0)    CONSTRAINT Configurations_key PRIMARY KEY,
+    Name            VARCHAR2(255) CONSTRAINT Configurations_Name_Unique unique  NOT NULL,
     Content         CLOB,
     ContentType     VARCHAR2(80),
     Disabled        NUMBER(11,0) DEFAULT 0 NOT NULL,
@@ -552,6 +552,6 @@ CREATE TABLE DatabaseSettings (
     LastUpdated     DATE
 );
 
-CREATE UNIQUE INDEX DatabaseSettings1 ON DatabaseSettings (LOWER(Name));
-CREATE INDEX DatabaseSettings2 ON DatabaseSettings (Disabled);
+CREATE UNIQUE INDEX Configurations1 ON Configurations (LOWER(Name));
+CREATE INDEX Configurations2 ON Configurations (Disabled);
 
diff --git a/etc/schema.Pg b/etc/schema.Pg
index 8c44a8f853..eda93108df 100644
--- a/etc/schema.Pg
+++ b/etc/schema.Pg
@@ -779,9 +779,9 @@ CREATE TABLE ObjectCustomRoles (
 
 CREATE UNIQUE INDEX ObjectCustomRoles1 ON ObjectCustomRoles (ObjectId, CustomRole);
 
-CREATE SEQUENCE databasesettings_id_seq;
-CREATE TABLE DatabaseSettings (
-    id                integer         DEFAULT nextval('databasesettings_id_seq'),
+CREATE SEQUENCE configurations_id_seq;
+CREATE TABLE Configurations (
+    id                integer         DEFAULT nextval('configurations_id_seq'),
     Name              varchar(255)    NOT NULL,
     Content           text            NULL,
     ContentType       varchar(80)     NULL,
@@ -793,6 +793,6 @@ CREATE TABLE DatabaseSettings (
     PRIMARY KEY (id)
 );
 
-CREATE UNIQUE INDEX DatabaseSettings1 ON DatabaseSettings (LOWER(Name));
-CREATE INDEX DatabaseSettings2 ON DatabaseSettings (Disabled);
+CREATE UNIQUE INDEX Configurations1 ON Configurations (LOWER(Name));
+CREATE INDEX Configurations2 ON Configurations (Disabled);
 
diff --git a/etc/schema.SQLite b/etc/schema.SQLite
index 64d395a866..b6e0166345 100644
--- a/etc/schema.SQLite
+++ b/etc/schema.SQLite
@@ -570,7 +570,7 @@ CREATE TABLE ObjectCustomRoles (
 );
 CREATE UNIQUE INDEX ObjectCustomRoles1 ON ObjectCustomRoles (ObjectId, CustomRole);
 
-CREATE TABLE DatabaseSettings (
+CREATE TABLE Configurations (
     id                INTEGER PRIMARY KEY,
     Name              varchar(255)    collate NOCASE NOT NULL,
     Content           longtext        collate NOCASE NULL,
@@ -582,6 +582,6 @@ CREATE TABLE DatabaseSettings (
     LastUpdated       timestamp                DEFAULT NULL
 );
 
-CREATE UNIQUE INDEX DatabaseSettings1 ON DatabaseSettings (Name);
-CREATE INDEX DatabaseSettings2 ON DatabaseSettings (Disabled);
+CREATE UNIQUE INDEX Configurations1 ON Configurations (Name);
+CREATE INDEX Configurations2 ON Configurations (Disabled);
 
diff --git a/etc/schema.mysql b/etc/schema.mysql
index f37469e89a..1d6da82c59 100644
--- a/etc/schema.mysql
+++ b/etc/schema.mysql
@@ -560,7 +560,7 @@ CREATE TABLE ObjectCustomRoles (
 
 CREATE UNIQUE INDEX ObjectCustomRoles1 ON ObjectCustomRoles (ObjectId, CustomRole);
 
-CREATE TABLE DatabaseSettings (
+CREATE TABLE Configurations (
     id                int(11)         NOT NULL AUTO_INCREMENT,
     Name              varchar(255)    NOT NULL,
     Content           longblob        NULL,
@@ -573,6 +573,6 @@ CREATE TABLE DatabaseSettings (
     PRIMARY KEY (id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
 
-CREATE UNIQUE INDEX DatabaseSettings1 ON DatabaseSettings (Name);
-CREATE INDEX DatabaseSettings2 ON DatabaseSettings (Disabled);
+CREATE UNIQUE INDEX Configurations1 ON Configurations (Name);
+CREATE INDEX Configurations2 ON Configurations (Disabled);
 
diff --git a/etc/upgrade/4.5.0/acl.Pg b/etc/upgrade/4.5.0/acl.Pg
index 6a73f0cd45..05e01ab640 100644
--- a/etc/upgrade/4.5.0/acl.Pg
+++ b/etc/upgrade/4.5.0/acl.Pg
@@ -3,8 +3,8 @@ sub acl {
 
     my @acls;
     my @tables = qw (
-        databasesettings_id_seq
-        DatabaseSettings
+        configurations_id_seq
+        Configurations
     );
 
     my $db_user = RT->Config->Get('DatabaseUser');
diff --git a/etc/upgrade/4.5.0/schema.Oracle b/etc/upgrade/4.5.0/schema.Oracle
index 17fed3dc1c..81df05cd0f 100644
--- a/etc/upgrade/4.5.0/schema.Oracle
+++ b/etc/upgrade/4.5.0/schema.Oracle
@@ -1,8 +1,8 @@
 ALTER TABLE Classes DROP( HotList );
-CREATE SEQUENCE DatabaseSettings_seq;
-CREATE TABLE DatabaseSettings (
-    id              NUMBER(11,0)    CONSTRAINT DatabaseSettings_key PRIMARY KEY,
-    Name            VARCHAR2(255) CONSTRAINT DatabaseSettings_Name_Unique unique  NOT NULL,
+CREATE SEQUENCE Configurations_seq;
+CREATE TABLE Configurations (
+    id              NUMBER(11,0)    CONSTRAINT Configurations_key PRIMARY KEY,
+    Name            VARCHAR2(255) CONSTRAINT Configurations_Name_Unique unique  NOT NULL,
     Content         CLOB,
     ContentType     VARCHAR2(80),
     Disabled        NUMBER(11,0) DEFAULT 0 NOT NULL,
@@ -12,5 +12,5 @@ CREATE TABLE DatabaseSettings (
     LastUpdated     DATE
 );
 
-CREATE UNIQUE INDEX DatabaseSettings1 ON DatabaseSettings (LOWER(Name));
-CREATE INDEX DatabaseSettings2 ON DatabaseSettings (Disabled);
+CREATE UNIQUE INDEX Configurations1 ON Configurations (LOWER(Name));
+CREATE INDEX Configurations2 ON Configurations (Disabled);
diff --git a/etc/upgrade/4.5.0/schema.Pg b/etc/upgrade/4.5.0/schema.Pg
index 0efa1411d0..fca1a417c8 100644
--- a/etc/upgrade/4.5.0/schema.Pg
+++ b/etc/upgrade/4.5.0/schema.Pg
@@ -1,7 +1,7 @@
 ALTER TABLE Classes DROP COLUMN HotList;
-CREATE SEQUENCE databasesettings_id_seq;
-CREATE TABLE DatabaseSettings (
-    id                integer         DEFAULT nextval('databasesettings_id_seq'),
+CREATE SEQUENCE configurations_id_seq;
+CREATE TABLE Configurations (
+    id                integer         DEFAULT nextval('configurations_id_seq'),
     Name              varchar(255)    NOT NULL,
     Content           text            NULL,
     ContentType       varchar(80)     NULL,
@@ -13,5 +13,5 @@ CREATE TABLE DatabaseSettings (
     PRIMARY KEY (id)
 );
 
-CREATE UNIQUE INDEX DatabaseSettings1 ON DatabaseSettings (LOWER(Name));
-CREATE INDEX DatabaseSettings2 ON DatabaseSettings (Disabled);
+CREATE UNIQUE INDEX Configurations1 ON Configurations (LOWER(Name));
+CREATE INDEX Configurations2 ON Configurations (Disabled);
diff --git a/etc/upgrade/4.5.0/schema.SQLite b/etc/upgrade/4.5.0/schema.SQLite
index a25a8c9b49..a8f280d334 100644
--- a/etc/upgrade/4.5.0/schema.SQLite
+++ b/etc/upgrade/4.5.0/schema.SQLite
@@ -1,4 +1,4 @@
-CREATE TABLE DatabaseSettings (
+CREATE TABLE Configurations (
     id                INTEGER PRIMARY KEY,
     Name              varchar(255)    collate NOCASE NOT NULL,
     Content           longtext        collate NOCASE NULL,
@@ -10,6 +10,6 @@ CREATE TABLE DatabaseSettings (
     LastUpdated       timestamp                DEFAULT NULL
 );
 
-CREATE UNIQUE INDEX DatabaseSettings1 ON DatabaseSettings (Name);
-CREATE INDEX DatabaseSettings2 ON DatabaseSettings (Disabled);
+CREATE UNIQUE INDEX Configurations1 ON Configurations (Name);
+CREATE INDEX Configurations2 ON Configurations (Disabled);
 
diff --git a/etc/upgrade/4.5.0/schema.mysql b/etc/upgrade/4.5.0/schema.mysql
index 444678cfc4..60a55b54cc 100644
--- a/etc/upgrade/4.5.0/schema.mysql
+++ b/etc/upgrade/4.5.0/schema.mysql
@@ -1,5 +1,5 @@
 ALTER TABLE Classes DROP COLUMN HotList;
-CREATE TABLE DatabaseSettings (
+CREATE TABLE Configurations (
     id                int(11)         NOT NULL AUTO_INCREMENT,
     Name              varchar(255)    NOT NULL,
     Content           longblob        NULL,
@@ -12,5 +12,5 @@ CREATE TABLE DatabaseSettings (
     PRIMARY KEY (id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
 
-CREATE UNIQUE INDEX DatabaseSettings1 ON DatabaseSettings (Name);
-CREATE INDEX DatabaseSettings2 ON DatabaseSettings (Disabled);
+CREATE UNIQUE INDEX Configurations1 ON Configurations (Name);
+CREATE INDEX Configurations2 ON Configurations (Disabled);
diff --git a/lib/RT.pm b/lib/RT.pm
index c53cbb5e38..63169ffdc4 100644
--- a/lib/RT.pm
+++ b/lib/RT.pm
@@ -504,8 +504,8 @@ sub InitClasses {
     require RT::Asset;
     require RT::Assets;
     require RT::CustomFieldValues::Canonicalizer;
-    require RT::DatabaseSetting;
-    require RT::DatabaseSettings;
+    require RT::Configuration;
+    require RT::Configurations;
 
     _BuildTableAttributes();
 
diff --git a/lib/RT/Config.pm b/lib/RT/Config.pm
index 50ecd6f601..405b31dbcf 100644
--- a/lib/RT/Config.pm
+++ b/lib/RT/Config.pm
@@ -2506,7 +2506,7 @@ sub LoadConfigFromDatabase {
 
     $database_config_cache_time = time;
 
-    my $settings = RT::DatabaseSettings->new(RT->SystemUser);
+    my $settings = RT::Configurations->new(RT->SystemUser);
     $settings->UnLimit;
 
     my %seen;
diff --git a/lib/RT/DatabaseSetting.pm b/lib/RT/Configuration.pm
similarity index 98%
rename from lib/RT/DatabaseSetting.pm
rename to lib/RT/Configuration.pm
index 185aae01f8..82886798b2 100644
--- a/lib/RT/DatabaseSetting.pm
+++ b/lib/RT/Configuration.pm
@@ -50,7 +50,7 @@ use strict;
 use warnings;
 use 5.10.1;
 
-package RT::DatabaseSetting;
+package RT::Configuration;
 use base 'RT::Record';
 
 use Storable ();
@@ -59,7 +59,7 @@ use JSON ();
 
 =head1 NAME
 
-RT::DatabaseSetting - Represents a config setting
+RT::Configuration - Represents a config setting
 
 =cut
 
@@ -209,7 +209,7 @@ sub ValidateName {
 
     return ( 0, $self->loc('empty name') ) unless defined $name && length $name;
 
-    my $TempSetting = RT::DatabaseSetting->new( RT->SystemUser );
+    my $TempSetting = RT::Configuration->new( RT->SystemUser );
     $TempSetting->Load($name);
 
     if ( $TempSetting->id && ( !$self->id || $TempSetting->id != $self->id ) ) {
@@ -320,7 +320,7 @@ sub SetContent {
 =head1 PRIVATE METHODS
 
 Documented for internal use only, do not call these from outside
-RT::DatabaseSetting itself.
+RT::Configuration itself.
 
 =head2 _Set
 
@@ -397,7 +397,7 @@ sub _DeJSONContent {
     return $thawed;
 }
 
-sub Table { "DatabaseSettings" }
+sub Table { "Configurations" }
 
 sub _CoreAccessible {
     {
diff --git a/lib/RT/DatabaseSettings.pm b/lib/RT/Configurations.pm
similarity index 92%
rename from lib/RT/DatabaseSettings.pm
rename to lib/RT/Configurations.pm
index 3236bad2c0..36ff2622d7 100644
--- a/lib/RT/DatabaseSettings.pm
+++ b/lib/RT/Configurations.pm
@@ -49,18 +49,18 @@
 use strict;
 use warnings;
 
-package RT::DatabaseSettings;
+package RT::Configurations;
 use base 'RT::SearchBuilder';
 
 =head1 NAME
 
-RT::DatabaseSettings - a collection of L<RT::DatabaseSettings> objects
+RT::Configurations - a collection of L<RT::Configurations> objects
 
 =cut
 
 sub NewItem {
     my $self = shift;
-    return RT::DatabaseSetting->new( $self->CurrentUser );
+    return RT::Configuration->new( $self->CurrentUser );
 }
 
 =head2 _Init
@@ -78,7 +78,7 @@ sub _Init {
     return $self->SUPER::_Init( @_ );
 }
 
-sub Table { "DatabaseSettings" }
+sub Table { "Configurations" }
 
 1;
 
diff --git a/share/html/Admin/Tools/EditConfig.html b/share/html/Admin/Tools/EditConfig.html
index 677e70c359..e9d3d63536 100644
--- a/share/html/Admin/Tools/EditConfig.html
+++ b/share/html/Admin/Tools/EditConfig.html
@@ -137,7 +137,7 @@ if (delete $ARGS{Update}) {
                 }
             }
 
-            my $setting = RT::DatabaseSetting->new($session{CurrentUser});
+            my $setting = RT::Configuration->new($session{CurrentUser});
             $setting->Load($key);
             if ($setting->Id) {
                 if ($setting->Disabled) {

commit 001d08162d7879ef592965740709f81c91b22eb8
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Tue Nov 19 09:08:10 2019 +0800

    Validate Content of Configurations
    
    Before saving to database, we need to make sure the config type is
    correct, otherwise server could die with errors like:
    
        Can't use string ("foo") as an ARRAY ref

diff --git a/lib/RT/Configuration.pm b/lib/RT/Configuration.pm
index 82886798b2..0cdfd5fcb3 100644
--- a/lib/RT/Configuration.pm
+++ b/lib/RT/Configuration.pm
@@ -116,6 +116,9 @@ sub Create {
         return ( 0, $self->loc("You cannot update [_1] using database config; you must edit your site config", $args{'Name'}) );
     }
 
+    ( $id, $msg ) = $self->ValidateContent( Name => $args{'Name'}, Content => $args{'Content'} );
+    return ( 0, $msg ) unless $id;
+
     if (ref ($args{'Content'}) ) {
         ($args{'Content'}, my $error) = $self->_SerializeContent($args{'Content'}, $args{'Name'});
         if ($error) {
@@ -272,6 +275,9 @@ sub SetContent {
 
     return (0, $self->loc("Permission Denied")) unless $self->CurrentUserCanSee;
 
+    my ( $ok, $msg ) = $self->ValidateContent( Content => $value );
+    return ( 0, $msg ) unless $ok;
+
     my ($old_value, $error) = $self->Content;
     unless (defined($old_value) && length($old_value)) {
         $old_value = $self->loc('(no value)');
@@ -287,7 +293,7 @@ sub SetContent {
 
     $RT::Handle->BeginTransaction;
 
-    my ($ok, $msg) = $self->_Set( Field => 'Content', Value => $value );
+    ($ok, $msg) = $self->_Set( Field => 'Content', Value => $value );
     if (!$ok) {
         $RT::Handle->Rollback;
         return ($ok, $self->loc("Unable to update [_1]: [_2]", $self->Name, $msg));
@@ -317,6 +323,35 @@ sub SetContent {
     }
 }
 
+=head2 ValidateContent
+
+Returns either (0, "failure reason") or 1 depending on whether the given
+content is valid.
+
+=cut
+
+sub ValidateContent {
+    my $self = shift;
+    my %args = @_ == 1 ? ( Content => @_ ) : @_;
+    $args{Name} ||= $self->Name;
+
+    # Validate methods are automatically called on Create by RT::Record.
+    # Sadly we have to skip that because it doesn't pass other field values,
+    # which we need here, as content type depends on the config name.
+    # We need to explicitly call Validate ourselves instead.
+    return 1 unless $args{Name};
+
+    my $meta = RT->Config->Meta( $args{Name} );
+    if ( my $type = $meta->{Type} ) {
+        if (   ( $type eq 'ARRAY' && ref $args{Content} ne 'ARRAY' )
+            || ( $type eq 'HASH' && ref $args{Content} ne 'HASH' ) )
+        {
+            return ( 0, $self->loc( 'Invalid value for [_1], should be of type [_2]', $args{Name}, $type ) );
+        }
+    }
+    return ( 1, $self->loc('Content valid') );
+}
+
 =head1 PRIVATE METHODS
 
 Documented for internal use only, do not call these from outside

commit 018f6a442e374bcb1e3950fb8358d77a193519a9
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Wed Nov 20 04:33:56 2019 +0800

    Use Data::Dumper instead in Configuration to support regex
    
    And because of this change, we can more easily show detailed changes,
    instead of vague messages like "Foo changed".

diff --git a/lib/RT/Configuration.pm b/lib/RT/Configuration.pm
index 0cdfd5fcb3..0d7c3ddf68 100644
--- a/lib/RT/Configuration.pm
+++ b/lib/RT/Configuration.pm
@@ -83,7 +83,7 @@ using L<Storable>. Otherwise any string is passed through as-is.
 
 =item ContentType
 
-Currently handles C<storable> or C<application/json>.
+Currently handles C<perl> or C<application/json>.
 
 =back
 
@@ -124,7 +124,7 @@ sub Create {
         if ($error) {
             return (0, $error);
         }
-        $args{'ContentType'} = 'storable';
+        $args{'ContentType'} = 'perl';
     }
 
     my $old_value = RT->Config->Get($args{Name});
@@ -147,14 +147,20 @@ sub Create {
         $content = $self->loc('(no value)');
     }
 
-    if (!ref($content) && !ref($old_value)) {
-        RT->Logger->info($self->CurrentUser->Name . " changed " . $self->Name . " from " . $old_value . " to " . $content);
-        return ($id, $self->loc('[_1] changed from "[_2]" to "[_3]"', $self->Name, $old_value, $content));
-    }
-    else {
-        RT->Logger->info($self->CurrentUser->Name . " changed " . $self->Name);
-        return ($id, $self->loc("[_1] changed", $self->Name));
+    if ( ref $old_value ) {
+        $old_value = $self->_SerializeContent($old_value);
     }
+
+    RT->Logger->info(
+        sprintf(
+            '%s changed %s from "%s" to "%s"',
+            $self->CurrentUser->Name,
+            $self->Name,
+            $old_value // '',
+            $content // ''
+        )
+    );
+    return ( $id, $self->loc( '[_1] changed from "[_2]" to "[_3]"', $self->Name, $old_value // '', $content // '' ) );
 }
 
 =head2 CurrentUserCanSee
@@ -254,7 +260,7 @@ sub DecodedContent {
 
     my $type = $self->__Value('ContentType') || '';
 
-    if ($type eq 'storable') {
+    if ($type eq 'perl') {
         return $self->_DeserializeContent($content);
     }
     elsif ($type eq 'application/json') {
@@ -288,7 +294,7 @@ sub SetContent {
         if ($error) {
             return (0, $error);
         }
-        $content_type = 'storable';
+        $content_type = 'perl';
     }
 
     $RT::Handle->BeginTransaction;
@@ -395,14 +401,10 @@ sub _Value {
 sub _SerializeContent {
     my $self = shift;
     my $content = shift;
-    my $name = shift || $self->Name;
-    my $frozen = eval { encode_base64(Storable::nfreeze($content)) };
-
-    if (my $error = $@) {
-        $RT::Logger->error("Storable serialization of database setting $name failed: $error");
-        return (undef, $self->loc("Storable serialization of database setting [_1] failed: [_2]", $name, $error));
-    }
-
+    require Data::Dumper;
+    local $Data::Dumper::Terse = 1;
+    my $frozen = Data::Dumper::Dumper($content);
+    chomp $frozen;
     return $frozen;
 }
 
@@ -410,10 +412,10 @@ sub _DeserializeContent {
     my $self = shift;
     my $content = shift;
 
-    my $thawed = eval { Storable::thaw(decode_base64($content)) };
+    my $thawed = eval "$content";
     if (my $error = $@) {
-        $RT::Logger->error("Storable deserialization of database setting " . $self->Name . " failed: $error");
-        return (undef, $self->loc("Storable deserialization of database setting [_1] failed: [_2]", $self->Name, $error));
+        $RT::Logger->error("Perl deserialization of database setting " . $self->Name . " failed: $error");
+        return (undef, $self->loc("Perl deserialization of database setting [_1] failed: [_2]", $self->Name, $error));
     }
 
     return $thawed;

commit 5048fa42708b8ec366885e2d205643c20c74ecf5
Author: sunnavy <sunnavy at bestpractical.com>
Date:   Wed Nov 20 04:52:56 2019 +0800

    Note RT::Extension::ConfigInDatabase is cored and the main backend change

diff --git a/devel/docs/UPGRADING-4.6 b/devel/docs/UPGRADING-4.6
index 77a8d82480..2b25f206ed 100644
--- a/devel/docs/UPGRADING-4.6
+++ b/devel/docs/UPGRADING-4.6
@@ -20,6 +20,12 @@ page. If you previously used this callback to add to the bottom of the SelfServi
 page, a new callback C<AfterMyGroupRequests> is now available below the new group
 ticket listing.
 
+=item *
+
+When we cored RT::Extension::ConfigInDatabase, we renamed table name to
+Configurations and also changed internal implementation to support storing
+regex there.
+
 =back
 
 =cut
diff --git a/lib/RT.pm b/lib/RT.pm
index 63169ffdc4..d8116cf04d 100644
--- a/lib/RT.pm
+++ b/lib/RT.pm
@@ -766,6 +766,7 @@ our %CORED_PLUGINS = (
     'RT::Extension::FutureMailgate' => '4.4',
     'RT::Extension::AdminConditionsAndActions' => '4.4.2',
     'RT::Extension::RightsInspector' => '4.6',
+    'RT::Extension::ConfigInDatabase' => '4.6',
 );
 
 sub InitPlugins {

commit 4117e8957a4f540771d2a9088a41a12498188f9e
Author: Blaine Motsinger <blaine at bestpractical.com>
Date:   Fri Nov 22 09:44:36 2019 -0600

    Add test for /Admin/Tools/EditConfig.html
    
    This test changes and verifies one of each of the data types through
    the UI.

diff --git a/t/web/admin_tools_editconfig.t b/t/web/admin_tools_editconfig.t
new file mode 100644
index 0000000000..729ff6e8b8
--- /dev/null
+++ b/t/web/admin_tools_editconfig.t
@@ -0,0 +1,90 @@
+use strict;
+use warnings;
+
+use Test::Deep;
+use Data::Dumper ();
+
+use RT::Test tests => undef;
+
+my ( $url, $m ) = RT::Test->started_ok;
+ok( $m->login(), 'logged in' );
+
+$m->follow_link_ok( { text => 'Edit Configuration' }, 'followed link to "Edit Configuration"' );
+
+my $tests = [
+    {
+        name      => 'change a string value',
+        form_id   => 'form-System-Base_configuration',
+        setting   => 'CorrespondAddress',
+        new_value => 'rt-correspond-edited at example.com',
+    },
+    {
+        name      => 'change a boolean value',
+        form_id   => 'form-System-Outgoing_mail',
+        setting   => 'NotifyActor',
+        new_value => 1,
+    },
+    {
+        name      => 'change an arrayref value',
+        form_id   => 'form-System-Extra_security',
+        setting   => 'ReferrerWhitelist',
+        new_value => ['www.example.com:443', 'www3.example.com:80'],
+    },
+    {
+        name      => 'change a hashref value',
+        form_id   => 'form-System-Outgoing_mail',
+        setting   => 'OverrideOutgoingMailFrom',
+        new_value => { 1 => 'new-outgoing-from at example.com' },
+    },
+];
+
+run_test( %{$_} ) for @{$tests};
+
+sub run_test {
+    my %args = @_;
+
+    diag $args{name} if $ENV{TEST_VERBOSE};
+
+    $m->submit_form_ok(
+        {
+            form_id => $args{form_id},
+            fields  => {
+                $args{setting} => stringify( $args{new_value} ),
+            },
+        },
+        'form was submitted successfully'
+    );
+
+    # RT::Config in the test is not running in the same process as the one in the test server.
+    # ensure the config object in the test is up to date with the changes.
+    RT->Config->LoadConfigFromDatabase();
+
+    $m->content_like( qr/$args{setting} changed from/, 'UI indicated the value was changed' );
+
+    # RT::Configuration->Content returns the value as string.
+    # in the test below we need to also ensure the new value is string.
+    my $rt_configuration = RT::Configuration->new( RT->SystemUser );
+    $rt_configuration->Load( $args{setting} );
+    my $rt_configuration_value = $rt_configuration->Content;
+
+    my $rt_config_value = RT->Config->Get( $args{setting} );
+
+    cmp_deeply( $rt_configuration_value, stringify($args{new_value}), 'value from RT::Configuration->Load matches new value' );
+    cmp_deeply( $rt_config_value, $args{new_value}, 'value from RT->Config->Get matches new value' );
+}
+
+sub stringify {
+    my $value = shift;
+
+    return $value unless ref $value;
+
+    local $Data::Dumper::Terse = 1;
+    local $Data::Dumper::Indent = 2;
+    local $Data::Dumper::Sortkeys = 1;
+
+    my $output = Data::Dumper::Dumper $value;
+    chomp $output;
+    return $output;
+}
+
+done_testing;

-----------------------------------------------------------------------


More information about the rt-commit mailing list