You are viewing a plain text version of this content. The canonical link for it is here.
Posted to issues@solr.apache.org by GitBox <gi...@apache.org> on 2021/10/22 20:51:47 UTC

[GitHub] [solr] sonatype-lift[bot] commented on a change in pull request #43: SOLR-15278: allow the flush parameter to be passed when doing a DELETE

sonatype-lift[bot] commented on a change in pull request #43:
URL: https://github.com/apache/solr/pull/43#discussion_r734836218



##########
File path: solr/webapp/web/js/angular/controllers/schema-designer.js
##########
@@ -0,0 +1,2014 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $cookies, $window, Constants, SchemaDesigner, Luke) {
+  $scope.resetMenu("schema-designer", Constants.IS_ROOT_PAGE);
+
+  $scope.onWarning = function (warnMsg, warnDetails) {
+    $scope.updateWorking = false;
+    delete $scope.updateStatusMessage;
+    $scope.apiWarning = warnMsg;
+    $scope.apiWarningDetails = warnDetails;
+  };
+  
+  $scope.onError = function (errorMsg, errorCode, errorDetails) {
+    $scope.updateWorking = false;
+    delete $scope.updateStatusMessage;
+    $scope.designerAPIError = errorMsg;
+    if (errorDetails) {
+      var errorDetailsStr = "";
+      if (errorDetails["error"]) {
+        errorDetailsStr = errorDetails["error"];
+      } else {
+        for (var id in errorDetails) {
+          var msg = errorDetails[id];
+          var at = msg.indexOf("ERROR: ");
+          if (at !== -1) {
+            msg = msg.substring(at+7);
+          }
+          if (!msg.includes(id)) {
+            msg = id+": "+msg;
+          }
+          errorDetailsStr += msg+"\n\n";
+        }
+      }
+      $scope.designerAPIErrorDetails = errorDetailsStr;
+    } else {
+      delete $scope.designerAPIErrorDetails;
+    }
+
+    if (errorCode === 409) {
+      $scope.schemaVersion = -1; // reset to get the latest
+      $scope.isVersionMismatch = true;
+      $scope.errorButton = "Reload Schema";
+    } else if (errorCode < 500) {
+      $scope.isVersionMismatch = false;
+      $scope.errorButton = "OK";
+    } // else 500 errors get the top-level error message
+  };
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error) {
+      $scope.onError(error.msg, error.code, e.data.errorDetails);
+    } else {
+      // when a timeout occurs, the error details are sparse so just give the user a hint that something was off
+      var path = e.config && e.config.url ? e.config.url : "/api/schema-designer";
+      var reloadMsg = "";
+      if (path.includes("/analyze")) {
+        reloadMsg = " Re-try analyzing your sample docs by clicking on 'Analyze Documents' again."
+      }
+      $scope.onError("Request to "+path+" failed!", 408,
+          {"error":"Most likely the request timed out; check server log for more details."+reloadMsg});
+    }
+  };
+
+  $scope.closeWarnDialog = function () {
+    delete $scope.apiWarning;
+    delete $scope.apiWarningDetails;
+  };
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.designerAPIError;
+    delete $scope.designerAPIErrorDetails;
+    if ($scope.isVersionMismatch) {
+      $scope.isVersionMismatch = false;
+      var nodeId = "/";
+      if ($scope.selectedNode) {
+        nodeId = $scope.selectedNode.href;
+      }
+      $scope.doAnalyze(nodeId);
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.isSchemaDesignerEnabled = true;
+
+    delete $scope.helpId;
+
+    $scope.updateStatusMessage = "";
+    $scope.analysisVerbose = false;
+    $scope.updateWorking = false;
+    $scope.currentSchema = "";
+
+    delete $scope.hasDocsOnServer;
+    delete $scope.queryResultsTree;
+
+    $scope.languages = ["*"];
+    $scope.copyFrom = "_default";
+    delete $scope.sampleMessage;
+    delete $scope.sampleDocuments;
+
+    $scope.schemaVersion = -1;
+    $scope.schemaTree = {};
+    $scope.showSchemaActions = false;
+    $scope.sampleDocIds = [];
+    $scope.isSchemaRoot = false;
+
+    delete $scope.enableNestedDocs;
+    delete $scope.enableDynamicFields;
+    delete $scope.enableFieldGuessing;
+
+    // schema editor
+    $scope.showFieldDetails = false;
+    $scope.selectedNode = null;
+    $scope.selectedUpdated = false;
+    delete $scope.updateStatusMessage;
+
+    // text field analysis
+    $scope.showAnalysis = false;
+    $scope.sampleDocId = null;
+    $scope.indexText = "";
+    $scope.result = {};
+
+    // publish vars
+    delete $scope.newCollection;
+    $scope.reloadOnPublish = "true";
+
+    // query form
+    $scope.query = {q: '*:*', sortBy: 'score', sortDir: 'desc'};
+
+    SchemaDesigner.get({path: "configs"}, function (data) {
+
+      $scope.schemas = [];
+      $scope.publishedSchemas = ["_default"];
+
+      for (var s in data.configSets) {
+        // 1 means published but not editable
+        if (data.configSets[s] !== 1) {
+          $scope.schemas.push(s);
+        }
+
+        // 0 means not published yet (so can't copy from it yet)
+        if (data.configSets[s] > 0) {
+          $scope.publishedSchemas.push(s);
+        }
+      }
+
+      $scope.schemas.sort();
+      $scope.publishedSchemas.sort();
+
+      // if no schemas available to select, open the pop-up immediately
+      if ($scope.schemas.length === 0) {
+        $scope.firstSchemaMessage = true;
+        $scope.showNewSchemaDialog();
+      }
+    }, function(e) {
+      if (e.status === 401 || e.status === 403) {
+        $scope.isSchemaDesignerEnabled = false;
+        $scope.hideAll();
+      }
+    });
+  };
+
+  $scope.selectNodeInTree = function(nodeId) {
+    nodeId = stripAnchorSuffix(nodeId);
+    if (!nodeId) return;
+
+    var jst = $('#schemaJsTree').jstree(true);
+    if (jst) {
+      var selectedId = null;
+      var selected_node = jst.get_selected();
+      if (selected_node && selected_node.length > 0) {
+        selectedId = selected_node[0];
+      }
+      if (selectedId) {
+        try {
+          jst.deselect_node(selectedId);
+        } catch (err) {
+          // just ignore
+          //console.log("error deselecting "+selectedId);
+        }
+      }
+
+      try {
+        jst.select_node(nodeId, true);
+      } catch (err) {
+        // just ignore, some low-level tree issue
+        //console.log("error selecting "+nodeId);
+      }
+    }
+  };
+
+  $scope.loadFile = function (event) {
+    var t = event.target || event.srcElement || event.currentTarget;
+    if (t && t.text) {
+      $scope.onSelectFileNode("files/" + t.text, true);
+    }
+  };
+
+  $scope.confirmEditSchema = function () {
+    $scope.showConfirmEditSchema = false;
+    if ($scope.hasDocsOnServer || $scope.published) {
+      $scope.doAnalyze();
+    } else {
+      $scope.sampleMessage = "Please upload or paste some sample documents to analyze for building the '" + $scope.currentSchema + "' schema.";
+    }
+  };
+
+  $scope.cancelEditSchema = function () {
+    $scope.currentSchema = "";
+    $scope.showConfirmEditSchema = false;
+  };
+
+  $scope.loadSchema = function () {
+
+    if (!$scope.currentSchema) {
+      return;
+    }
+
+    $scope.resetSchema();
+    var params = {path: "info", configSet: $scope.currentSchema};
+    SchemaDesigner.get(params, function (data) {
+      $scope.currentSchema = data.configSet;
+      $("#select-schema").trigger("chosen:updated");
+
+      $scope.confirmSchema = data.configSet;
+      $scope.collectionsForConfig = data.collections;
+      $scope.hasDocsOnServer = data.numDocs > 0;
+      $scope.published = data.published;
+      $scope.initDesignerSettingsFromResponse(data);
+      if ($scope.collectionsForConfig && $scope.collectionsForConfig.length > 0) {
+        $scope.showConfirmEditSchema = true;
+      } else {
+        if ($scope.hasDocsOnServer || $scope.published) {
+          $scope.doAnalyze();
+        } else {
+          $scope.sampleMessage = "Please upload or paste some sample documents to build the '" + $scope.currentSchema + "' schema.";
+        }
+      }
+    });
+  };
+
+  $scope.showNewSchemaDialog = function () {
+    $scope.hideAll();
+    $scope.showNewSchema = true;
+    $scope.newSchema = "";
+  };
+
+  $scope.addSchema = function () {
+    $scope.firstSchemaMessage = false;
+    delete $scope.addMessage;
+
+    if (!$scope.newSchema) {
+      $scope.addMessage = "Please provide a schema name!";
+      return;
+    }
+
+    $scope.newSchema = $scope.newSchema.trim();
+    if ($scope.newSchema.length > 50) {
+      $scope.addMessage = "Schema name be 50 characters or less";
+      return;
+    }
+
+    if ($scope.newSchema.indexOf(" ") !== -1 || $scope.newSchema.indexOf("/") !== -1) {
+      $scope.addMessage = "Schema name should not contain spaces or /";
+      return;
+    }
+
+    if ($scope.publishedSchemas.includes($scope.newSchema) || $scope.schemas.includes($scope.newSchema)) {
+      $scope.addMessage = "Schema '" + $scope.newSchema + "' already exists!";
+      return;
+    }
+
+    delete $scope.addMessage;
+    if (!$scope.copyFrom) {
+      $scope.copyFrom = "_default";
+    }
+
+    $scope.resetSchema();
+    $scope.schemas.push($scope.newSchema);
+    $scope.showNewSchema = false;
+    $scope.currentSchema = $scope.newSchema;
+    $scope.sampleMessage = "Please upload or paste some sample documents to analyze for building the '" + $scope.currentSchema + "' schema.";
+
+    SchemaDesigner.post({path: "prep", configSet: $scope.newSchema, copyFrom: $scope.copyFrom}, null, function (data) {
+      $scope.initDesignerSettingsFromResponse(data);
+    }, $scope.errorHandler);
+  };
+
+  $scope.cancelAddSchema = function () {
+    delete $scope.addMessage;
+    delete $scope.sampleMessage;
+
+    $scope.showNewSchema = false
+  };
+
+  $scope.hideAll = function () {
+    delete $scope.helpId;
+    $scope.showPublish = false;
+    $scope.showDiff = false;
+    $scope.showNewSchema = false;
+    $scope.showAddField = false;
+    $scope.showAddDynamicField = false;
+    $scope.showAddCopyField = false;
+    $scope.showAnalysis = false;
+    // add more dialogs here
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.hideData = function () {
+    $scope.showData = false;
+  };
+
+  $scope.rootChanged = function () {
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  $scope.updateUniqueKey = function () {
+    delete $scope.schemaRootMessage;
+    var jst = $('#schemaJsTree').jstree();
+    if (jst) {
+      var node = jst.get_node("field/" + $scope.updateUniqueKeyField);
+      if (node && node.a_attr) {
+        var attrs = node.a_attr;
+        if (attrs.multiValued || attrs.tokenized || !attrs.stored || !attrs.indexed) {
+          $scope.schemaRootMessage = "Field '" + $scope.updateUniqueKeyField +
+              "' cannot be used as the uniqueKey field! Must be single-valued, stored, indexed, and not tokenized.";
+          $scope.updateUniqueKeyField = $scope.uniqueKeyField;
+          return;
+        }
+      }
+    }
+    $scope.uniqueKeyField = $scope.updateUniqueKeyField;
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  $scope.resetSchema = function () {
+    $scope.hideAll();
+    $scope.analysisVerbose = false;
+    $scope.showSchemaActions = false;
+    $scope.showAnalysis = false;
+    $scope.showFieldDetails = false;
+    $scope.hasDocsOnServer = false;
+    $scope.query = {q: '*:*', sortBy: 'score', sortDir: 'desc'};
+    $scope.schemaVersion = -1;
+
+    $scope.updateWorking = false;
+    $scope.isVersionMismatch = false;
+    delete $scope.updateStatusMessage;
+    delete $scope.designerAPIError;
+    delete $scope.designerAPIErrorDetails;
+    delete $scope.selectedFacets;
+    delete $scope.sampleDocuments;
+    delete $scope.selectedNode;
+    delete $scope.queryResultsTree;
+  };
+
+  $scope.onSchemaUpdated = function (schema, data, nodeId) {
+    $scope.hasDocsOnServer = data.numDocs && data.numDocs > 0;
+    $scope.uniqueKeyField = data.uniqueKeyField;
+    $scope.updateUniqueKeyField = $scope.uniqueKeyField;
+    $scope.initDesignerSettingsFromResponse(data);
+
+    var fieldTypes = fieldTypesToTree(data.fieldTypes);
+    var files = filesToTree(data.files);
+
+    var rootChildren = [];
+    $scope.fieldsSrc = fieldsToTree(data.fields);
+    $scope.fieldsNode = {
+      "id": "fields",
+      "text": "Fields",
+      "state": {"opened": true},
+      "a_attr": {"href": "fields"},
+      "children": $scope.fieldsSrc
+    };
+    rootChildren.push($scope.fieldsNode);
+
+    if ($scope.enableDynamicFields === "true") {
+      $scope.dynamicFieldsSrc = fieldsToTree(data.dynamicFields);
+      $scope.dynamicFieldsNode = {
+        "id": "dynamicFields",
+        "text": "Dynamic Fields",
+        "a_attr": {"href": "dynamicFields"},
+        "children": $scope.dynamicFieldsSrc
+      };
+      rootChildren.push($scope.dynamicFieldsNode);
+    } else {
+      delete $scope.dynamicFieldsNode;
+      delete $scope.dynamicFieldsSrc;
+    }
+
+    rootChildren.push({"id":"fieldTypes", "text": "Field Types", "a_attr": {"href": "fieldTypes"}, "children": fieldTypes});
+    rootChildren.push(files);
+
+    var tree = [{"id":"/", "text": schema, "a_attr": {"href": "/"}, "state":{"opened": true}, "children": rootChildren}];
+
+    $scope.fields = data.fields;
+    $scope.fieldNames = data.fields.map(f => f.name).sort();
+    $scope.possibleIdFields = data.fields.filter(f => f.indexed && f.stored && !f.tokenized).map(f => f.name).sort();
+    $scope.sortableFields = data.fields.filter(f => (f.indexed || f.docValues) && !f.multiValued && !f.tokenized).map(f => f.name).sort();
+    $scope.sortableFields.push("score");
+
+    $scope.facetFields = data.fields.filter(f => (f.indexed || f.docValues) && !f.tokenized && f.name !== '_version_').map(f => f.name).sort();
+    $scope.hlFields = data.fields.filter(f => f.stored && f.tokenized).map(f => f.name).sort();
+
+    $scope.schemaVersion = data.schemaVersion;
+    $scope.currentSchema = data.configSet;
+    $scope.fieldTypes = fieldTypes;
+    $scope.core = data.core;
+    $scope.schemaTree = tree;
+    $scope.refreshTree();
+
+    $scope.collectionsForConfig = data.collectionsForConfig;
+
+    if (data.docIds) {
+      $scope.sampleDocIds = data.docIds;
+    }
+
+    // re-apply the filters on the updated schema
+    $scope.onTreeFilterOptionChanged();
+
+    // Load the Luke schema
+    Luke.schema({core: data.core}, function (schema) {
+      Luke.raw({core: data.core}, function (index) {
+        $scope.luke = mergeIndexAndSchemaData(index, schema.schema);
+        $scope.types = Object.keys(schema.schema.types);
+        $scope.showSchemaActions = true;
+        if (!nodeId) {
+          nodeId = "/";
+        }
+        $scope.onSelectSchemaTreeNode(nodeId);
+
+        $scope.updateWorking = false;
+
+        if (data.updateError != null) {
+          $scope.onError(data.updateError, data.updateErrorCode, data.errorDetails);
+        } else {
+          if ($scope.selectedUpdated) {
+            $scope.selectedUpdated = false;
+            $scope.updateStatusMessage = "Changes applied successfully.";
+            var waitMs = 3000;
+            if (data.rebuild) {
+              $scope.updateStatusMessage += " Did full re-index of sample docs due to incompatible update.";
+              waitMs = 5000; // longer message, more time to read
+            }
+
+            if (data.analysisError) {
+              var updateType = data["updateType"];
+              var updatedObject = data[updateType];
+              var updatedName = updatedObject && updatedObject.name ? updatedObject.name : "";
+              var warnMsg = "Changes to "+updateType+" "+updatedName+" applied, but required the temp collection to be deleted " +
+                  "and re-created due to an incompatible Lucene change, see details below.";
+              $scope.onWarning(warnMsg, data.analysisError);
+            } else {
+              $timeout(function () { delete $scope.updateStatusMessage; }, waitMs);
+            }
+          } else {
+            var source = data.sampleSource;
+            if (source) {
+              if (source === "paste") {
+                source = "pasted sample"
+              } else if (source === "blob") {
+                source = "previous upload stored on the server"
+              }
+              if (data.numDocs > 0) {
+                $scope.updateStatusMessage = "Analyzed "+data.numDocs+" docs from "+source;
+              } else {
+                $scope.updateStatusMessage = "Schema '"+$scope.currentSchema+"' loaded.";
+              }
+            }
+            $timeout(function () {
+              delete $scope.updateStatusMessage;
+            }, 5000);
+          }
+        }
+
+        // re-fire the current query to reflect the updated schema
+        $scope.doQuery();
+        $scope.selectNodeInTree(nodeId);
+      });
+    });
+  };
+
+  $scope.toggleAddField = function (type) {
+    if ($scope.showAddField) {
+      $scope.hideAll();
+    } else {
+      $scope.hideAll();
+      $scope.showAddField = true;
+      $scope.adding = type;
+
+      $scope.newField = {
+        stored: "true",
+        indexed: "true",
+        uninvertible: "true",
+        docValues: "true"
+      }
+
+      if (type === "field") {
+        $scope.newField.type = "string";
+      }
+
+      delete $scope.addErrors;
+    }
+  };
+
+  function applyConstraintsOnField(f) {
+
+    if (!f.docValues) {
+      delete f.useDocValuesAsStored;
+    }
+
+    if (!f.docValues && !f.uninvertible) {
+      delete f.sortMissingLast; // remove this setting if no docValues / uninvertible
+    }
+
+    if (f.indexed) {
+      if (f.omitTermFreqAndPositions && !f.omitPositions) {
+        delete f.omitPositions; // :shrug ~ see SchemaField ln 295
+      }
+      if (!f.termVectors) {
+        delete f.termPositions;
+        delete f.termOffsets;
+        delete f.termPayloads;
+      }
+    } else {
+      // if not indexed, a bunch of fields are false
+      f.tokenized = false;
+      f.uninvertible = false;
+
+      // drop these from the request
+      delete f.termVectors;
+      delete f.termPositions;
+      delete f.termOffsets;
+      delete f.termPayloads;
+      delete f.omitNorms;
+      delete f.omitPositions;
+      delete f.omitTermFreqAndPositions;
+      delete f.storeOffsetsWithPositions;
+    }
+
+    return f;
+  }
+
+  $scope.addField = function () {
+
+    // validate the form input
+    $scope.addErrors = [];
+    if (!$scope.newField.name) {
+      $scope.addErrors.push($scope.adding + " name is required!");
+    }
+
+    if ($scope.newField.name.indexOf(" ") != -1) {
+      $scope.addErrors.push($scope.adding + " name should not have whitespace");
+    }
+
+    var command = "add-field-type";
+    if ("field" === $scope.adding) {
+
+      if ($scope.fieldNames.includes($scope.newField.name)) {
+        $scope.addErrors.push("Field '" + $scope.newField.name + "' already exists!");
+        return;
+      }
+
+      // TODO: is this the correct logic for detecting dynamic? Probably good enough for the designer
+      var isDynamic = $scope.newField.name.startsWith("*") || $scope.newField.name.endsWith("*");
+      if (isDynamic) {
+        if ($scope.luke && $scope.luke.dynamic_fields[$scope.newField.name]) {
+          $scope.addErrors.push("dynamic field '" + $scope.newField.name + "' already exists!");
+        }
+      } else {
+        if ($scope.luke && $scope.luke.fields[$scope.newField.name]) {
+          $scope.addErrors.push("field '" + $scope.newField.name + "' already exists!");
+        }
+      }
+
+      if (!$scope.newField.type) {
+        $scope.addErrors.push("field type is required!");
+      }
+
+      command = isDynamic ? "add-dynamic-field" : "add-field";
+    } else if ("type" === $scope.adding) {
+      if ($scope.types.includes($scope.newField.name)) {
+        $scope.addErrors.push("Type '" + $scope.newField.name + "' already exists!");
+      }
+
+      if (!$scope.newField.class) {
+        $scope.addErrors.push("class is required when creating a new field type!");
+      }
+    }
+
+    var addData = {};
+    addData[command] = applyConstraintsOnField($scope.newField);
+    if ($scope.textAnalysisJson) {
+      var text = $scope.textAnalysisJson.trim();
+      if (text.length > 0) {
+        text = text.replace(/\s+/g, ' ');
+        if (!text.startsWith("{")) {
+          text = "{ " + text + " }";
+        }
+        try {
+          var textJson = JSON.parse(text);
+          if (textJson.analyzer) {
+            addData[command].analyzer = textJson.analyzer;
+          } else {
+            if (!textJson.indexAnalyzer || !textJson.queryAnalyzer) {
+              $scope.addErrors.push("Text analysis JSON should define either an 'analyzer' or an 'indexAnalyzer' and 'queryAnalyzer'");
+              return;
+            }
+            addData[command].indexAnalyzer = textJson.indexAnalyzer;
+            addData[command].queryAnalyzer = textJson.queryAnalyzer;
+          }
+        } catch (e) {
+          $scope.addErrors.push("Failed to parse analysis as JSON due to: " + e.message +
+              "; expected JSON object with either an 'analyzer' or 'indexAnalyzer' and 'queryAnalyzer'");
+          return;
+        }
+      }
+    }
+
+    if ($scope.addErrors.length > 0) {
+      return;
+    }
+    delete $scope.addErrors; // no errors!
+
+    SchemaDesigner.post({
+      path: "add",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, addData, function (data) {
+      if (data.errors) {
+        $scope.addErrors = data.errors[0].errorMessages;
+        if (typeof $scope.addErrors === "string") {
+          $scope.addErrors = [$scope.addErrors];
+        }
+      } else {
+        delete $scope.textAnalysisJson;
+        $scope.added = true;
+        $timeout(function () {
+          $scope.showAddField = false;
+          $scope.added = false;
+          var nodeId = "/";
+          if ("field" === $scope.adding) {
+            nodeId = "field/" + data[command];
+          } else if ("type" === $scope.adding) {
+            nodeId = "type/" + data[command];
+          }
+          $scope.onSchemaUpdated(data.configSet, data, nodeId);
+        }, 500);
+      }
+    }, $scope.errorHandler);
+  }
+
+  function toSortedNameAndTypeList(fields, typeAttr) {
+    var list = [];
+    var keys = Object.keys(fields);
+    for (var f in keys) {
+      var field = fields[keys[f]];
+      var type = field[typeAttr];
+      if (type) {
+        list.push(field.name + ": "+type);
+      } else {
+        list.push(field.name);
+      }
+    }
+    return list.sort();
+  }
+
+  function toSortedFieldList(fields) {
+    var list = [];
+    var keys = Object.keys(fields);
+    for (var f in keys) {
+      list.push(fields[keys[f]]);
+    }
+    return list.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  $scope.toggleDiff = function (event) {
+    if ($scope.showDiff) {
+      // toggle, close dialog
+      $scope.showDiff = false;
+      return;
+    }
+
+    if (event) {
+      var t = event.target || event.currentTarget;
+      var leftPos = t.getBoundingClientRect().left - 600;
+      if (leftPos < 0) leftPos = 0;
+      $('#show-diff-dialog').css({left: leftPos});
+    }
+
+    SchemaDesigner.get({ path: "diff", configSet: $scope.currentSchema }, function (data) {
+      var diff = data.diff;
+
+      var dynamicFields = diff.dynamicFields;
+      var enableDynamicFields = data.enableDynamicFields !== null ? data.enableDynamicFields : true;
+      if (!enableDynamicFields) {
+        dynamicFields = null;
+      }
+
+      $scope.diffSource = data["diff-source"];
+      $scope.schemaDiff = {
+        "fieldsDiff": diff.fields,
+        "addedFields": [],
+        "removedFields": [],
+        "fieldTypesDiff": diff.fieldTypes,
+        "removedTypes": [],
+        "dynamicFieldsDiff": dynamicFields,
+        "copyFieldsDiff": diff.copyFields
+      }
+      if (diff.fields && diff.fields.added) {
+        $scope.schemaDiff.addedFields = toSortedFieldList(diff.fields.added);
+      }
+      if (diff.fields && diff.fields.removed) {
+        $scope.schemaDiff.removedFields = toSortedNameAndTypeList(diff.fields.removed, "type");
+      }
+      if (diff.fieldTypes && diff.fieldTypes.removed) {
+        $scope.schemaDiff.removedTypes = toSortedNameAndTypeList(diff.fieldTypes.removed, "class");
+      }
+
+      $scope.schemaDiffExists = !(diff.fields == null && diff.fieldTypes == null && dynamicFields == null && diff.copyFields == null);
+      $scope.showDiff = true;
+    });
+  }
+
+  $scope.togglePublish = function (event) {
+    if (event) {
+      var t = event.target || event.currentTarget;
+      var leftPos = t.getBoundingClientRect().left - 515;
+      if (leftPos < 0) leftPos = 0;
+      $('#publish-dialog').css({left: leftPos});
+    }
+
+    $scope.showDiff = false;
+    $scope.showPublish = !$scope.showPublish;
+    delete $scope.publishErrors;
+
+    $scope.disableDesigner = "false";
+
+    if ($scope.showPublish && !$scope.newCollection) {
+      $scope.newCollection = {numShards: 1, replicationFactor: 1, indexToCollection: "true"};
+    }
+  };
+
+  $scope.toggleAddCopyField = function () {
+    if ($scope.showAddCopyField) {
+      $scope.hideAll();
+      $scope.showFieldDetails = true;
+    } else {
+      $scope.hideAll();
+      $scope.showAddCopyField = true;
+      $scope.showFieldDetails = false;
+
+      $scope.copyField = {};
+      delete $scope.addCopyFieldErrors;
+    }
+  }
+  $scope.addCopyField = function () {
+    delete $scope.addCopyFieldErrors;
+    var data = {"add-copy-field": $scope.copyField};
+    SchemaDesigner.post({
+      path: "add",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, data, function (data) {
+      if (data.errors) {
+        $scope.addCopyFieldErrors = data.errors[0].errorMessages;
+        if (typeof $scope.addCopyFieldErrors === "string") {
+          $scope.addCopyFieldErrors = [$scope.addCopyFieldErrors];
+        }
+      } else {
+        $scope.showAddCopyField = false;
+        // TODO:
+        //$timeout($scope.refresh, 1500);
+      }
+    }, $scope.errorHandler);
+  }
+
+  $scope.toggleAnalyzer = function (analyzer) {
+    analyzer.show = !analyzer.show;
+  }
+
+  $scope.initTypeAnalysisInfo = function (typeName) {
+    $scope.analysis = getAnalysisInfo($scope.luke, {type: true}, typeName);
+    if ($scope.analysis && $scope.analysis.data) {
+      $scope.className = $scope.analysis.data.className
+    }
+    $scope.editAnalysis = "Edit JSON";
+    $scope.showAnalysisJson = false;
+    delete $scope.analysisJsonText;
+  };
+
+  $scope.toggleVerbose = function () {
+    $scope.analysisVerbose = !$scope.analysisVerbose;
+  };
+
+  $scope.updateSampleDocId = function () {
+    $scope.indexText = "";
+    $scope.result = {};
+
+    if (!$scope.selectedNode) {
+      return;
+    }
+
+    var field = $scope.selectedNode.name;
+    var params = {path: "sample"};
+    params.configSet = $scope.currentSchema;
+    params.uniqueKeyField = $scope.uniqueKeyField;
+    params.field = field;
+
+    if ($scope.sampleDocId) {
+      params.docId = $scope.sampleDocId;
+    } // else the server will pick the first doc with a non-empty text value for the desired field
+
+    SchemaDesigner.get(params, function (data) {
+      $scope.sampleDocId = data[$scope.uniqueKeyField];
+      $scope.indexText = data[field];
+      if (data.analysis && data.analysis["field_names"]) {
+        $scope.result = processFieldAnalysisData(data.analysis["field_names"][field]);
+      }
+    });
+  };
+
+  $scope.changeLanguages = function () {
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  function getType(typeName) {
+    if ($scope.fieldTypes) {
+      for (i in $scope.fieldTypes) {
+        if ($scope.fieldTypes[i].text === typeName) {
+          return $scope.fieldTypes[i];
+        }
+      }
+    }
+    return null;
+  }
+
+  $scope.refreshTree = function() {
+    var jst = $('#schemaJsTree').jstree(true);
+    if (jst) {
+      jst.refresh();
+    }
+  };
+
+  $scope.onSchemaTreeLoaded = function (id) {
+    //console.log(">> on tree loaded");
+  };
+
+  $scope.updateFile = function () {
+    var nodeId = "files/" + $scope.selectedFile;
+    var params = {path: "file", file: $scope.selectedFile, configSet: $scope.currentSchema};
+
+    $scope.updateWorking = true;
+    $scope.updateStatusMessage = "Updating file ...";
+
+    SchemaDesigner.post(params, $scope.fileNodeText, function (data) {
+      if (data.updateFileError) {
+        if (data[$scope.selectedFile]) {
+          $scope.fileNodeText = data[$scope.selectedFile];
+        }
+        $scope.updateFileError = data.updateFileError;
+      } else {
+        delete $scope.updateFileError;
+        $scope.updateStatusMessage = "File '"+$scope.selectedFile+"' updated.";
+        $scope.onSchemaUpdated(data.configSet, data, nodeId);
+      }
+    }, $scope.errorHandler);
+  };
+
+  $scope.onSelectFileNode = function (id, doSelectOnTree) {
+    $scope.selectedFile = id.startsWith("files/") ? id.substring("files/".length) : id;
+
+    var params = {path: "file", file: $scope.selectedFile, configSet: $scope.currentSchema};
+    SchemaDesigner.get(params, function (data) {
+      $scope.fileNodeText = data[$scope.selectedFile];
+      $scope.isLeafNode = false;
+      if (doSelectOnTree) {
+        delete $scope.selectedNode;
+        $scope.isLeafNode = false;
+        $scope.showFieldDetails = true;
+        delete $scope.sampleDocId;
+        $scope.showAnalysis = false;
+        $scope.selectNodeInTree(id);
+      }
+    });
+  };
+  
+  function fieldNodes(src, type) {
+    var nodes = [];
+    for (var c in src) {
+      var childNode = src[c];
+      if (childNode && childNode.a_attr) {
+        var a = childNode.a_attr;
+        var stored = a.stored || (a.docValues && a.useDocValuesAsStored);
+        var obj = {"name":a.name, "indexed":a.indexed, "docValues": a.docValues,
+          "multiValued":a.multiValued, "stored":stored, "tokenized": a.tokenized};
+        if (type === "field" || type === "dynamicField") {
+          obj.type = a.type;
+        } else if (type === "type") {
+          obj.class = a.class;
+        }
+        nodes.push(obj);
+      }
+    }
+    return nodes;
+  }
+
+  function stripAnchorSuffix(id) {
+    if (id && id.endsWith("_anchor")) {
+      id = id.substring(0, id.length - "_anchor".length);
+    }
+    return id;
+  }
+
+  $scope.onSelectSchemaTreeNode = function (id) {
+    id = stripAnchorSuffix(id);
+    $scope.showFieldDetails = false;
+    $scope.isSchemaRoot = false;
+    $scope.isLeafNode = false;
+    delete $scope.containerNodeLabel;
+    delete $scope.containerNode;
+    delete $scope.containerNodes;
+    delete $scope.selectedFile;
+
+    if (id === "/") {
+      $scope.selectedType = "Schema";
+      $scope.selectedNode = null;
+      $scope.isSchemaRoot = true;
+      $scope.isLeafNode = false;
+      $scope.isContainerNode = false;
+      $scope.showFieldDetails = true;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+
+      if (!$scope.treeFilter) {
+        $scope.treeFilter = "type";
+        $scope.treeFilterOption = "*";
+        $scope.initTreeFilters();
+      }
+      return;
+    }
+
+    var jst = $('#schemaJsTree').jstree(true);
+    if (!jst) {
+      return;
+    }
+
+    var node = jst.get_node(id);
+    if (!node) {
+      return;
+    }
+
+    if (id === "files") {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      return;
+    }
+
+    if (id.indexOf("/") === -1) {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      $scope.containerNode = id;
+
+      if (id === "fields") {
+        $scope.containerNodes = fieldNodes($scope.fieldsNode ? $scope.fieldsNode.children : $scope.fieldsSrc, "field");
+      } else if (id === "dynamicFields") {
+        $scope.containerNodes = fieldNodes($scope.dynamicFieldsNode ? $scope.dynamicFieldsNode.children : $scope.dynamicFieldsSrc, "dynamicField");
+      } else if (id === "fieldTypes") {
+        $scope.containerNodes = fieldNodes($scope.fieldTypes, "type");
+      }
+
+      $scope.containerNodeLabel = node.text;
+      $scope.showFieldDetails = true;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+      return;
+    }
+
+    if (id.startsWith("files/")) {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+      if (node.children.length === 0) {
+        // file
+        $scope.showFieldDetails = true;
+        $scope.onSelectFileNode(id, false);
+      } else {
+        // folder
+        $scope.showFieldDetails = false;
+        delete $scope.selectedFile;
+      }
+      return;
+    }
+
+    delete $scope.selectedFile;
+    $scope.selectedNode = node["a_attr"]; // all the info we need is in the a_attr object
+    if (!$scope.selectedNode) {
+      // a node in the tree that isn't a field was selected, just ignore
+      return;
+    }
+
+    $scope.selectedNode.fieldType = getType($scope.selectedNode.type);
+    $scope.isLeafNode = true;
+
+    var nodeType = id.substring(0, id.indexOf("/"));
+    var name = null;
+    if (nodeType === "field") {
+      $scope.selectedType = "Field";
+      name = $scope.selectedNode.type;
+    } else if (nodeType === "dynamic") {
+      $scope.selectedType = "Dynamic Field";
+    } else if (nodeType === "type") {
+      $scope.selectedType = "Type";
+      name = $scope.selectedNode.name;
+    }
+
+    if (name) {
+      $scope.initTypeAnalysisInfo(name, "type");
+    }
+
+    // apply some sanity to the checkboxes
+    $scope.selectedNode = applyConstraintsOnField($scope.selectedNode);
+    $scope.showFieldDetails = true;
+
+    if (nodeType === "field" && $scope.selectedNode.tokenized && $scope.selectedNode.stored) {
+      $scope.showAnalysis = true;
+      $scope.updateSampleDocId();
+    } else {
+      $scope.showAnalysis = false;
+      $scope.indexText = "";
+      $scope.result = {};
+    }
+  };
+
+  function addFileNode(dirs, parent, f) {
+    var path = f.split("/");
+    if (path.length === 1) {
+      if (!parent.children) {
+        parent.children = [];
+        dirs.push(parent); // now parent has children, so track in dirs ...
+      }
+      var nodeId = parent.id + "/" + f;
+      parent.children.push({"text": f, "id": nodeId, "a_attr": {"href": nodeId}});
+    } else {
+      // get the parent for this path
+      var parentId = "files/" + path.slice(0, path.length - 1).join("/");
+      var dir = null;
+      for (var d in dirs) {
+        if (dirs[d].id === parentId) {
+          dir = dirs[d];
+          break;
+        }
+      }
+      if (!dir) {
+        dir = {"text": path[0], "id": parentId, "a_attr": {"href": parentId}, "children": []};
+        dirs.push(dir);
+        parent.children.push(dir);
+      }
+
+      // walk down the next level in this path
+      addFileNode(dirs, dir, path.slice(1).join("/"));
+    }
+  }
+
+  // transform a flat list structure into the nested tree structure
+  function filesToTree(files) {
+    var filesNode = {"text": "Files", "a_attr": {"href": "files"}, "id": "files", "children": []};
+    if (files) {
+      var dirs = []; // lookup for known dirs by path since nodes don't keep a ref to their parent node
+      for (var i in files) {
+        // hide the configoverlay.json from the UI
+        if (files[i] === "configoverlay.json") {
+          continue;
+        }
+
+        addFileNode(dirs, filesNode, files[i]);
+      }
+      delete dirs;

Review comment:
       *JSC_DELETE_VARIABLE:*  variables, functions, and arguments cannot be deleted in strict mode
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/query.js
##########
@@ -87,32 +166,55 @@ solrAdminApp.controller('QueryController',
         params.handler = "select";
         set("qt", qt);
       }
+      // create rest result url
       var url = Query.url(params);
+
+      // create admin page url
+      var adminParams = {...params};

Review comment:
       *none:*  Parsing error: Unexpected token ...
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/schema-designer.js
##########
@@ -0,0 +1,2014 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $cookies, $window, Constants, SchemaDesigner, Luke) {
+  $scope.resetMenu("schema-designer", Constants.IS_ROOT_PAGE);
+
+  $scope.onWarning = function (warnMsg, warnDetails) {
+    $scope.updateWorking = false;
+    delete $scope.updateStatusMessage;
+    $scope.apiWarning = warnMsg;
+    $scope.apiWarningDetails = warnDetails;
+  };
+  
+  $scope.onError = function (errorMsg, errorCode, errorDetails) {
+    $scope.updateWorking = false;
+    delete $scope.updateStatusMessage;
+    $scope.designerAPIError = errorMsg;
+    if (errorDetails) {
+      var errorDetailsStr = "";
+      if (errorDetails["error"]) {
+        errorDetailsStr = errorDetails["error"];
+      } else {
+        for (var id in errorDetails) {
+          var msg = errorDetails[id];
+          var at = msg.indexOf("ERROR: ");
+          if (at !== -1) {
+            msg = msg.substring(at+7);
+          }
+          if (!msg.includes(id)) {
+            msg = id+": "+msg;
+          }
+          errorDetailsStr += msg+"\n\n";
+        }
+      }
+      $scope.designerAPIErrorDetails = errorDetailsStr;
+    } else {
+      delete $scope.designerAPIErrorDetails;
+    }
+
+    if (errorCode === 409) {
+      $scope.schemaVersion = -1; // reset to get the latest
+      $scope.isVersionMismatch = true;
+      $scope.errorButton = "Reload Schema";
+    } else if (errorCode < 500) {
+      $scope.isVersionMismatch = false;
+      $scope.errorButton = "OK";
+    } // else 500 errors get the top-level error message
+  };
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error) {
+      $scope.onError(error.msg, error.code, e.data.errorDetails);
+    } else {
+      // when a timeout occurs, the error details are sparse so just give the user a hint that something was off
+      var path = e.config && e.config.url ? e.config.url : "/api/schema-designer";
+      var reloadMsg = "";
+      if (path.includes("/analyze")) {
+        reloadMsg = " Re-try analyzing your sample docs by clicking on 'Analyze Documents' again."
+      }
+      $scope.onError("Request to "+path+" failed!", 408,
+          {"error":"Most likely the request timed out; check server log for more details."+reloadMsg});
+    }
+  };
+
+  $scope.closeWarnDialog = function () {
+    delete $scope.apiWarning;
+    delete $scope.apiWarningDetails;
+  };
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.designerAPIError;
+    delete $scope.designerAPIErrorDetails;
+    if ($scope.isVersionMismatch) {
+      $scope.isVersionMismatch = false;
+      var nodeId = "/";
+      if ($scope.selectedNode) {
+        nodeId = $scope.selectedNode.href;
+      }
+      $scope.doAnalyze(nodeId);
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.isSchemaDesignerEnabled = true;
+
+    delete $scope.helpId;
+
+    $scope.updateStatusMessage = "";
+    $scope.analysisVerbose = false;
+    $scope.updateWorking = false;
+    $scope.currentSchema = "";
+
+    delete $scope.hasDocsOnServer;
+    delete $scope.queryResultsTree;
+
+    $scope.languages = ["*"];
+    $scope.copyFrom = "_default";
+    delete $scope.sampleMessage;
+    delete $scope.sampleDocuments;
+
+    $scope.schemaVersion = -1;
+    $scope.schemaTree = {};
+    $scope.showSchemaActions = false;
+    $scope.sampleDocIds = [];
+    $scope.isSchemaRoot = false;
+
+    delete $scope.enableNestedDocs;
+    delete $scope.enableDynamicFields;
+    delete $scope.enableFieldGuessing;
+
+    // schema editor
+    $scope.showFieldDetails = false;
+    $scope.selectedNode = null;
+    $scope.selectedUpdated = false;
+    delete $scope.updateStatusMessage;
+
+    // text field analysis
+    $scope.showAnalysis = false;
+    $scope.sampleDocId = null;
+    $scope.indexText = "";
+    $scope.result = {};
+
+    // publish vars
+    delete $scope.newCollection;
+    $scope.reloadOnPublish = "true";
+
+    // query form
+    $scope.query = {q: '*:*', sortBy: 'score', sortDir: 'desc'};
+
+    SchemaDesigner.get({path: "configs"}, function (data) {
+
+      $scope.schemas = [];
+      $scope.publishedSchemas = ["_default"];
+
+      for (var s in data.configSets) {
+        // 1 means published but not editable
+        if (data.configSets[s] !== 1) {
+          $scope.schemas.push(s);
+        }
+
+        // 0 means not published yet (so can't copy from it yet)
+        if (data.configSets[s] > 0) {
+          $scope.publishedSchemas.push(s);
+        }
+      }
+
+      $scope.schemas.sort();
+      $scope.publishedSchemas.sort();
+
+      // if no schemas available to select, open the pop-up immediately
+      if ($scope.schemas.length === 0) {
+        $scope.firstSchemaMessage = true;
+        $scope.showNewSchemaDialog();
+      }
+    }, function(e) {
+      if (e.status === 401 || e.status === 403) {
+        $scope.isSchemaDesignerEnabled = false;
+        $scope.hideAll();
+      }
+    });
+  };
+
+  $scope.selectNodeInTree = function(nodeId) {
+    nodeId = stripAnchorSuffix(nodeId);
+    if (!nodeId) return;
+
+    var jst = $('#schemaJsTree').jstree(true);
+    if (jst) {
+      var selectedId = null;
+      var selected_node = jst.get_selected();
+      if (selected_node && selected_node.length > 0) {
+        selectedId = selected_node[0];
+      }
+      if (selectedId) {
+        try {
+          jst.deselect_node(selectedId);
+        } catch (err) {
+          // just ignore
+          //console.log("error deselecting "+selectedId);
+        }
+      }
+
+      try {
+        jst.select_node(nodeId, true);
+      } catch (err) {
+        // just ignore, some low-level tree issue
+        //console.log("error selecting "+nodeId);
+      }
+    }
+  };
+
+  $scope.loadFile = function (event) {
+    var t = event.target || event.srcElement || event.currentTarget;
+    if (t && t.text) {
+      $scope.onSelectFileNode("files/" + t.text, true);
+    }
+  };
+
+  $scope.confirmEditSchema = function () {
+    $scope.showConfirmEditSchema = false;
+    if ($scope.hasDocsOnServer || $scope.published) {
+      $scope.doAnalyze();
+    } else {
+      $scope.sampleMessage = "Please upload or paste some sample documents to analyze for building the '" + $scope.currentSchema + "' schema.";
+    }
+  };
+
+  $scope.cancelEditSchema = function () {
+    $scope.currentSchema = "";
+    $scope.showConfirmEditSchema = false;
+  };
+
+  $scope.loadSchema = function () {
+
+    if (!$scope.currentSchema) {
+      return;
+    }
+
+    $scope.resetSchema();
+    var params = {path: "info", configSet: $scope.currentSchema};
+    SchemaDesigner.get(params, function (data) {
+      $scope.currentSchema = data.configSet;
+      $("#select-schema").trigger("chosen:updated");
+
+      $scope.confirmSchema = data.configSet;
+      $scope.collectionsForConfig = data.collections;
+      $scope.hasDocsOnServer = data.numDocs > 0;
+      $scope.published = data.published;
+      $scope.initDesignerSettingsFromResponse(data);
+      if ($scope.collectionsForConfig && $scope.collectionsForConfig.length > 0) {
+        $scope.showConfirmEditSchema = true;
+      } else {
+        if ($scope.hasDocsOnServer || $scope.published) {
+          $scope.doAnalyze();
+        } else {
+          $scope.sampleMessage = "Please upload or paste some sample documents to build the '" + $scope.currentSchema + "' schema.";
+        }
+      }
+    });
+  };
+
+  $scope.showNewSchemaDialog = function () {
+    $scope.hideAll();
+    $scope.showNewSchema = true;
+    $scope.newSchema = "";
+  };
+
+  $scope.addSchema = function () {
+    $scope.firstSchemaMessage = false;
+    delete $scope.addMessage;
+
+    if (!$scope.newSchema) {
+      $scope.addMessage = "Please provide a schema name!";
+      return;
+    }
+
+    $scope.newSchema = $scope.newSchema.trim();
+    if ($scope.newSchema.length > 50) {
+      $scope.addMessage = "Schema name be 50 characters or less";
+      return;
+    }
+
+    if ($scope.newSchema.indexOf(" ") !== -1 || $scope.newSchema.indexOf("/") !== -1) {
+      $scope.addMessage = "Schema name should not contain spaces or /";
+      return;
+    }
+
+    if ($scope.publishedSchemas.includes($scope.newSchema) || $scope.schemas.includes($scope.newSchema)) {
+      $scope.addMessage = "Schema '" + $scope.newSchema + "' already exists!";
+      return;
+    }
+
+    delete $scope.addMessage;
+    if (!$scope.copyFrom) {
+      $scope.copyFrom = "_default";
+    }
+
+    $scope.resetSchema();
+    $scope.schemas.push($scope.newSchema);
+    $scope.showNewSchema = false;
+    $scope.currentSchema = $scope.newSchema;
+    $scope.sampleMessage = "Please upload or paste some sample documents to analyze for building the '" + $scope.currentSchema + "' schema.";
+
+    SchemaDesigner.post({path: "prep", configSet: $scope.newSchema, copyFrom: $scope.copyFrom}, null, function (data) {
+      $scope.initDesignerSettingsFromResponse(data);
+    }, $scope.errorHandler);
+  };
+
+  $scope.cancelAddSchema = function () {
+    delete $scope.addMessage;
+    delete $scope.sampleMessage;
+
+    $scope.showNewSchema = false
+  };
+
+  $scope.hideAll = function () {
+    delete $scope.helpId;
+    $scope.showPublish = false;
+    $scope.showDiff = false;
+    $scope.showNewSchema = false;
+    $scope.showAddField = false;
+    $scope.showAddDynamicField = false;
+    $scope.showAddCopyField = false;
+    $scope.showAnalysis = false;
+    // add more dialogs here
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.hideData = function () {
+    $scope.showData = false;
+  };
+
+  $scope.rootChanged = function () {
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  $scope.updateUniqueKey = function () {
+    delete $scope.schemaRootMessage;
+    var jst = $('#schemaJsTree').jstree();
+    if (jst) {
+      var node = jst.get_node("field/" + $scope.updateUniqueKeyField);
+      if (node && node.a_attr) {
+        var attrs = node.a_attr;
+        if (attrs.multiValued || attrs.tokenized || !attrs.stored || !attrs.indexed) {
+          $scope.schemaRootMessage = "Field '" + $scope.updateUniqueKeyField +
+              "' cannot be used as the uniqueKey field! Must be single-valued, stored, indexed, and not tokenized.";
+          $scope.updateUniqueKeyField = $scope.uniqueKeyField;
+          return;
+        }
+      }
+    }
+    $scope.uniqueKeyField = $scope.updateUniqueKeyField;
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  $scope.resetSchema = function () {
+    $scope.hideAll();
+    $scope.analysisVerbose = false;
+    $scope.showSchemaActions = false;
+    $scope.showAnalysis = false;
+    $scope.showFieldDetails = false;
+    $scope.hasDocsOnServer = false;
+    $scope.query = {q: '*:*', sortBy: 'score', sortDir: 'desc'};
+    $scope.schemaVersion = -1;
+
+    $scope.updateWorking = false;
+    $scope.isVersionMismatch = false;
+    delete $scope.updateStatusMessage;
+    delete $scope.designerAPIError;
+    delete $scope.designerAPIErrorDetails;
+    delete $scope.selectedFacets;
+    delete $scope.sampleDocuments;
+    delete $scope.selectedNode;
+    delete $scope.queryResultsTree;
+  };
+
+  $scope.onSchemaUpdated = function (schema, data, nodeId) {
+    $scope.hasDocsOnServer = data.numDocs && data.numDocs > 0;
+    $scope.uniqueKeyField = data.uniqueKeyField;
+    $scope.updateUniqueKeyField = $scope.uniqueKeyField;
+    $scope.initDesignerSettingsFromResponse(data);
+
+    var fieldTypes = fieldTypesToTree(data.fieldTypes);
+    var files = filesToTree(data.files);
+
+    var rootChildren = [];
+    $scope.fieldsSrc = fieldsToTree(data.fields);
+    $scope.fieldsNode = {
+      "id": "fields",
+      "text": "Fields",
+      "state": {"opened": true},
+      "a_attr": {"href": "fields"},
+      "children": $scope.fieldsSrc
+    };
+    rootChildren.push($scope.fieldsNode);
+
+    if ($scope.enableDynamicFields === "true") {
+      $scope.dynamicFieldsSrc = fieldsToTree(data.dynamicFields);
+      $scope.dynamicFieldsNode = {
+        "id": "dynamicFields",
+        "text": "Dynamic Fields",
+        "a_attr": {"href": "dynamicFields"},
+        "children": $scope.dynamicFieldsSrc
+      };
+      rootChildren.push($scope.dynamicFieldsNode);
+    } else {
+      delete $scope.dynamicFieldsNode;
+      delete $scope.dynamicFieldsSrc;
+    }
+
+    rootChildren.push({"id":"fieldTypes", "text": "Field Types", "a_attr": {"href": "fieldTypes"}, "children": fieldTypes});
+    rootChildren.push(files);
+
+    var tree = [{"id":"/", "text": schema, "a_attr": {"href": "/"}, "state":{"opened": true}, "children": rootChildren}];
+
+    $scope.fields = data.fields;
+    $scope.fieldNames = data.fields.map(f => f.name).sort();
+    $scope.possibleIdFields = data.fields.filter(f => f.indexed && f.stored && !f.tokenized).map(f => f.name).sort();
+    $scope.sortableFields = data.fields.filter(f => (f.indexed || f.docValues) && !f.multiValued && !f.tokenized).map(f => f.name).sort();
+    $scope.sortableFields.push("score");
+
+    $scope.facetFields = data.fields.filter(f => (f.indexed || f.docValues) && !f.tokenized && f.name !== '_version_').map(f => f.name).sort();
+    $scope.hlFields = data.fields.filter(f => f.stored && f.tokenized).map(f => f.name).sort();
+
+    $scope.schemaVersion = data.schemaVersion;
+    $scope.currentSchema = data.configSet;
+    $scope.fieldTypes = fieldTypes;
+    $scope.core = data.core;
+    $scope.schemaTree = tree;
+    $scope.refreshTree();
+
+    $scope.collectionsForConfig = data.collectionsForConfig;
+
+    if (data.docIds) {
+      $scope.sampleDocIds = data.docIds;
+    }
+
+    // re-apply the filters on the updated schema
+    $scope.onTreeFilterOptionChanged();
+
+    // Load the Luke schema
+    Luke.schema({core: data.core}, function (schema) {
+      Luke.raw({core: data.core}, function (index) {
+        $scope.luke = mergeIndexAndSchemaData(index, schema.schema);
+        $scope.types = Object.keys(schema.schema.types);
+        $scope.showSchemaActions = true;
+        if (!nodeId) {
+          nodeId = "/";
+        }
+        $scope.onSelectSchemaTreeNode(nodeId);
+
+        $scope.updateWorking = false;
+
+        if (data.updateError != null) {
+          $scope.onError(data.updateError, data.updateErrorCode, data.errorDetails);
+        } else {
+          if ($scope.selectedUpdated) {
+            $scope.selectedUpdated = false;
+            $scope.updateStatusMessage = "Changes applied successfully.";
+            var waitMs = 3000;
+            if (data.rebuild) {
+              $scope.updateStatusMessage += " Did full re-index of sample docs due to incompatible update.";
+              waitMs = 5000; // longer message, more time to read
+            }
+
+            if (data.analysisError) {
+              var updateType = data["updateType"];
+              var updatedObject = data[updateType];
+              var updatedName = updatedObject && updatedObject.name ? updatedObject.name : "";
+              var warnMsg = "Changes to "+updateType+" "+updatedName+" applied, but required the temp collection to be deleted " +
+                  "and re-created due to an incompatible Lucene change, see details below.";
+              $scope.onWarning(warnMsg, data.analysisError);
+            } else {
+              $timeout(function () { delete $scope.updateStatusMessage; }, waitMs);
+            }
+          } else {
+            var source = data.sampleSource;
+            if (source) {
+              if (source === "paste") {
+                source = "pasted sample"
+              } else if (source === "blob") {
+                source = "previous upload stored on the server"
+              }
+              if (data.numDocs > 0) {
+                $scope.updateStatusMessage = "Analyzed "+data.numDocs+" docs from "+source;
+              } else {
+                $scope.updateStatusMessage = "Schema '"+$scope.currentSchema+"' loaded.";
+              }
+            }
+            $timeout(function () {
+              delete $scope.updateStatusMessage;
+            }, 5000);
+          }
+        }
+
+        // re-fire the current query to reflect the updated schema
+        $scope.doQuery();
+        $scope.selectNodeInTree(nodeId);
+      });
+    });
+  };
+
+  $scope.toggleAddField = function (type) {
+    if ($scope.showAddField) {
+      $scope.hideAll();
+    } else {
+      $scope.hideAll();
+      $scope.showAddField = true;
+      $scope.adding = type;
+
+      $scope.newField = {
+        stored: "true",
+        indexed: "true",
+        uninvertible: "true",
+        docValues: "true"
+      }
+
+      if (type === "field") {
+        $scope.newField.type = "string";
+      }
+
+      delete $scope.addErrors;
+    }
+  };
+
+  function applyConstraintsOnField(f) {
+
+    if (!f.docValues) {
+      delete f.useDocValuesAsStored;
+    }
+
+    if (!f.docValues && !f.uninvertible) {
+      delete f.sortMissingLast; // remove this setting if no docValues / uninvertible
+    }
+
+    if (f.indexed) {
+      if (f.omitTermFreqAndPositions && !f.omitPositions) {
+        delete f.omitPositions; // :shrug ~ see SchemaField ln 295
+      }
+      if (!f.termVectors) {
+        delete f.termPositions;
+        delete f.termOffsets;
+        delete f.termPayloads;
+      }
+    } else {
+      // if not indexed, a bunch of fields are false
+      f.tokenized = false;
+      f.uninvertible = false;
+
+      // drop these from the request
+      delete f.termVectors;
+      delete f.termPositions;
+      delete f.termOffsets;
+      delete f.termPayloads;
+      delete f.omitNorms;
+      delete f.omitPositions;
+      delete f.omitTermFreqAndPositions;
+      delete f.storeOffsetsWithPositions;
+    }
+
+    return f;
+  }
+
+  $scope.addField = function () {
+
+    // validate the form input
+    $scope.addErrors = [];
+    if (!$scope.newField.name) {
+      $scope.addErrors.push($scope.adding + " name is required!");
+    }
+
+    if ($scope.newField.name.indexOf(" ") != -1) {
+      $scope.addErrors.push($scope.adding + " name should not have whitespace");
+    }
+
+    var command = "add-field-type";
+    if ("field" === $scope.adding) {
+
+      if ($scope.fieldNames.includes($scope.newField.name)) {
+        $scope.addErrors.push("Field '" + $scope.newField.name + "' already exists!");
+        return;
+      }
+
+      // TODO: is this the correct logic for detecting dynamic? Probably good enough for the designer
+      var isDynamic = $scope.newField.name.startsWith("*") || $scope.newField.name.endsWith("*");
+      if (isDynamic) {
+        if ($scope.luke && $scope.luke.dynamic_fields[$scope.newField.name]) {
+          $scope.addErrors.push("dynamic field '" + $scope.newField.name + "' already exists!");
+        }
+      } else {
+        if ($scope.luke && $scope.luke.fields[$scope.newField.name]) {
+          $scope.addErrors.push("field '" + $scope.newField.name + "' already exists!");
+        }
+      }
+
+      if (!$scope.newField.type) {
+        $scope.addErrors.push("field type is required!");
+      }
+
+      command = isDynamic ? "add-dynamic-field" : "add-field";
+    } else if ("type" === $scope.adding) {
+      if ($scope.types.includes($scope.newField.name)) {
+        $scope.addErrors.push("Type '" + $scope.newField.name + "' already exists!");
+      }
+
+      if (!$scope.newField.class) {
+        $scope.addErrors.push("class is required when creating a new field type!");
+      }
+    }
+
+    var addData = {};
+    addData[command] = applyConstraintsOnField($scope.newField);
+    if ($scope.textAnalysisJson) {
+      var text = $scope.textAnalysisJson.trim();
+      if (text.length > 0) {
+        text = text.replace(/\s+/g, ' ');
+        if (!text.startsWith("{")) {
+          text = "{ " + text + " }";
+        }
+        try {
+          var textJson = JSON.parse(text);
+          if (textJson.analyzer) {
+            addData[command].analyzer = textJson.analyzer;
+          } else {
+            if (!textJson.indexAnalyzer || !textJson.queryAnalyzer) {
+              $scope.addErrors.push("Text analysis JSON should define either an 'analyzer' or an 'indexAnalyzer' and 'queryAnalyzer'");
+              return;
+            }
+            addData[command].indexAnalyzer = textJson.indexAnalyzer;
+            addData[command].queryAnalyzer = textJson.queryAnalyzer;
+          }
+        } catch (e) {
+          $scope.addErrors.push("Failed to parse analysis as JSON due to: " + e.message +
+              "; expected JSON object with either an 'analyzer' or 'indexAnalyzer' and 'queryAnalyzer'");
+          return;
+        }
+      }
+    }
+
+    if ($scope.addErrors.length > 0) {
+      return;
+    }
+    delete $scope.addErrors; // no errors!
+
+    SchemaDesigner.post({
+      path: "add",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, addData, function (data) {
+      if (data.errors) {
+        $scope.addErrors = data.errors[0].errorMessages;
+        if (typeof $scope.addErrors === "string") {
+          $scope.addErrors = [$scope.addErrors];
+        }
+      } else {
+        delete $scope.textAnalysisJson;
+        $scope.added = true;
+        $timeout(function () {
+          $scope.showAddField = false;
+          $scope.added = false;
+          var nodeId = "/";
+          if ("field" === $scope.adding) {
+            nodeId = "field/" + data[command];
+          } else if ("type" === $scope.adding) {
+            nodeId = "type/" + data[command];
+          }
+          $scope.onSchemaUpdated(data.configSet, data, nodeId);
+        }, 500);
+      }
+    }, $scope.errorHandler);
+  }
+
+  function toSortedNameAndTypeList(fields, typeAttr) {
+    var list = [];
+    var keys = Object.keys(fields);
+    for (var f in keys) {
+      var field = fields[keys[f]];
+      var type = field[typeAttr];
+      if (type) {
+        list.push(field.name + ": "+type);
+      } else {
+        list.push(field.name);
+      }
+    }
+    return list.sort();
+  }
+
+  function toSortedFieldList(fields) {
+    var list = [];
+    var keys = Object.keys(fields);
+    for (var f in keys) {
+      list.push(fields[keys[f]]);
+    }
+    return list.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  $scope.toggleDiff = function (event) {
+    if ($scope.showDiff) {
+      // toggle, close dialog
+      $scope.showDiff = false;
+      return;
+    }
+
+    if (event) {
+      var t = event.target || event.currentTarget;
+      var leftPos = t.getBoundingClientRect().left - 600;
+      if (leftPos < 0) leftPos = 0;
+      $('#show-diff-dialog').css({left: leftPos});
+    }
+
+    SchemaDesigner.get({ path: "diff", configSet: $scope.currentSchema }, function (data) {
+      var diff = data.diff;
+
+      var dynamicFields = diff.dynamicFields;
+      var enableDynamicFields = data.enableDynamicFields !== null ? data.enableDynamicFields : true;
+      if (!enableDynamicFields) {
+        dynamicFields = null;
+      }
+
+      $scope.diffSource = data["diff-source"];
+      $scope.schemaDiff = {
+        "fieldsDiff": diff.fields,
+        "addedFields": [],
+        "removedFields": [],
+        "fieldTypesDiff": diff.fieldTypes,
+        "removedTypes": [],
+        "dynamicFieldsDiff": dynamicFields,
+        "copyFieldsDiff": diff.copyFields
+      }
+      if (diff.fields && diff.fields.added) {
+        $scope.schemaDiff.addedFields = toSortedFieldList(diff.fields.added);
+      }
+      if (diff.fields && diff.fields.removed) {
+        $scope.schemaDiff.removedFields = toSortedNameAndTypeList(diff.fields.removed, "type");
+      }
+      if (diff.fieldTypes && diff.fieldTypes.removed) {
+        $scope.schemaDiff.removedTypes = toSortedNameAndTypeList(diff.fieldTypes.removed, "class");
+      }
+
+      $scope.schemaDiffExists = !(diff.fields == null && diff.fieldTypes == null && dynamicFields == null && diff.copyFields == null);
+      $scope.showDiff = true;
+    });
+  }
+
+  $scope.togglePublish = function (event) {
+    if (event) {
+      var t = event.target || event.currentTarget;
+      var leftPos = t.getBoundingClientRect().left - 515;
+      if (leftPos < 0) leftPos = 0;
+      $('#publish-dialog').css({left: leftPos});
+    }
+
+    $scope.showDiff = false;
+    $scope.showPublish = !$scope.showPublish;
+    delete $scope.publishErrors;
+
+    $scope.disableDesigner = "false";
+
+    if ($scope.showPublish && !$scope.newCollection) {
+      $scope.newCollection = {numShards: 1, replicationFactor: 1, indexToCollection: "true"};
+    }
+  };
+
+  $scope.toggleAddCopyField = function () {
+    if ($scope.showAddCopyField) {
+      $scope.hideAll();
+      $scope.showFieldDetails = true;
+    } else {
+      $scope.hideAll();
+      $scope.showAddCopyField = true;
+      $scope.showFieldDetails = false;
+
+      $scope.copyField = {};
+      delete $scope.addCopyFieldErrors;
+    }
+  }
+  $scope.addCopyField = function () {
+    delete $scope.addCopyFieldErrors;
+    var data = {"add-copy-field": $scope.copyField};
+    SchemaDesigner.post({
+      path: "add",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, data, function (data) {
+      if (data.errors) {
+        $scope.addCopyFieldErrors = data.errors[0].errorMessages;
+        if (typeof $scope.addCopyFieldErrors === "string") {
+          $scope.addCopyFieldErrors = [$scope.addCopyFieldErrors];
+        }
+      } else {
+        $scope.showAddCopyField = false;
+        // TODO:
+        //$timeout($scope.refresh, 1500);
+      }
+    }, $scope.errorHandler);
+  }
+
+  $scope.toggleAnalyzer = function (analyzer) {
+    analyzer.show = !analyzer.show;
+  }
+
+  $scope.initTypeAnalysisInfo = function (typeName) {
+    $scope.analysis = getAnalysisInfo($scope.luke, {type: true}, typeName);
+    if ($scope.analysis && $scope.analysis.data) {
+      $scope.className = $scope.analysis.data.className
+    }
+    $scope.editAnalysis = "Edit JSON";
+    $scope.showAnalysisJson = false;
+    delete $scope.analysisJsonText;
+  };
+
+  $scope.toggleVerbose = function () {
+    $scope.analysisVerbose = !$scope.analysisVerbose;
+  };
+
+  $scope.updateSampleDocId = function () {
+    $scope.indexText = "";
+    $scope.result = {};
+
+    if (!$scope.selectedNode) {
+      return;
+    }
+
+    var field = $scope.selectedNode.name;
+    var params = {path: "sample"};
+    params.configSet = $scope.currentSchema;
+    params.uniqueKeyField = $scope.uniqueKeyField;
+    params.field = field;
+
+    if ($scope.sampleDocId) {
+      params.docId = $scope.sampleDocId;
+    } // else the server will pick the first doc with a non-empty text value for the desired field
+
+    SchemaDesigner.get(params, function (data) {
+      $scope.sampleDocId = data[$scope.uniqueKeyField];
+      $scope.indexText = data[field];
+      if (data.analysis && data.analysis["field_names"]) {
+        $scope.result = processFieldAnalysisData(data.analysis["field_names"][field]);
+      }
+    });
+  };
+
+  $scope.changeLanguages = function () {
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  function getType(typeName) {
+    if ($scope.fieldTypes) {
+      for (i in $scope.fieldTypes) {
+        if ($scope.fieldTypes[i].text === typeName) {
+          return $scope.fieldTypes[i];
+        }
+      }
+    }
+    return null;
+  }
+
+  $scope.refreshTree = function() {
+    var jst = $('#schemaJsTree').jstree(true);
+    if (jst) {
+      jst.refresh();
+    }
+  };
+
+  $scope.onSchemaTreeLoaded = function (id) {
+    //console.log(">> on tree loaded");
+  };
+
+  $scope.updateFile = function () {
+    var nodeId = "files/" + $scope.selectedFile;
+    var params = {path: "file", file: $scope.selectedFile, configSet: $scope.currentSchema};
+
+    $scope.updateWorking = true;
+    $scope.updateStatusMessage = "Updating file ...";
+
+    SchemaDesigner.post(params, $scope.fileNodeText, function (data) {
+      if (data.updateFileError) {
+        if (data[$scope.selectedFile]) {
+          $scope.fileNodeText = data[$scope.selectedFile];
+        }
+        $scope.updateFileError = data.updateFileError;
+      } else {
+        delete $scope.updateFileError;
+        $scope.updateStatusMessage = "File '"+$scope.selectedFile+"' updated.";
+        $scope.onSchemaUpdated(data.configSet, data, nodeId);
+      }
+    }, $scope.errorHandler);
+  };
+
+  $scope.onSelectFileNode = function (id, doSelectOnTree) {
+    $scope.selectedFile = id.startsWith("files/") ? id.substring("files/".length) : id;
+
+    var params = {path: "file", file: $scope.selectedFile, configSet: $scope.currentSchema};
+    SchemaDesigner.get(params, function (data) {
+      $scope.fileNodeText = data[$scope.selectedFile];
+      $scope.isLeafNode = false;
+      if (doSelectOnTree) {
+        delete $scope.selectedNode;
+        $scope.isLeafNode = false;
+        $scope.showFieldDetails = true;
+        delete $scope.sampleDocId;
+        $scope.showAnalysis = false;
+        $scope.selectNodeInTree(id);
+      }
+    });
+  };
+  
+  function fieldNodes(src, type) {
+    var nodes = [];
+    for (var c in src) {
+      var childNode = src[c];
+      if (childNode && childNode.a_attr) {
+        var a = childNode.a_attr;
+        var stored = a.stored || (a.docValues && a.useDocValuesAsStored);
+        var obj = {"name":a.name, "indexed":a.indexed, "docValues": a.docValues,
+          "multiValued":a.multiValued, "stored":stored, "tokenized": a.tokenized};
+        if (type === "field" || type === "dynamicField") {
+          obj.type = a.type;
+        } else if (type === "type") {
+          obj.class = a.class;
+        }
+        nodes.push(obj);
+      }
+    }
+    return nodes;
+  }
+
+  function stripAnchorSuffix(id) {
+    if (id && id.endsWith("_anchor")) {
+      id = id.substring(0, id.length - "_anchor".length);
+    }
+    return id;
+  }
+
+  $scope.onSelectSchemaTreeNode = function (id) {
+    id = stripAnchorSuffix(id);
+    $scope.showFieldDetails = false;
+    $scope.isSchemaRoot = false;
+    $scope.isLeafNode = false;
+    delete $scope.containerNodeLabel;
+    delete $scope.containerNode;
+    delete $scope.containerNodes;
+    delete $scope.selectedFile;
+
+    if (id === "/") {
+      $scope.selectedType = "Schema";
+      $scope.selectedNode = null;
+      $scope.isSchemaRoot = true;
+      $scope.isLeafNode = false;
+      $scope.isContainerNode = false;
+      $scope.showFieldDetails = true;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+
+      if (!$scope.treeFilter) {
+        $scope.treeFilter = "type";
+        $scope.treeFilterOption = "*";
+        $scope.initTreeFilters();
+      }
+      return;
+    }
+
+    var jst = $('#schemaJsTree').jstree(true);
+    if (!jst) {
+      return;
+    }
+
+    var node = jst.get_node(id);
+    if (!node) {
+      return;
+    }
+
+    if (id === "files") {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      return;
+    }
+
+    if (id.indexOf("/") === -1) {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      $scope.containerNode = id;
+
+      if (id === "fields") {
+        $scope.containerNodes = fieldNodes($scope.fieldsNode ? $scope.fieldsNode.children : $scope.fieldsSrc, "field");
+      } else if (id === "dynamicFields") {
+        $scope.containerNodes = fieldNodes($scope.dynamicFieldsNode ? $scope.dynamicFieldsNode.children : $scope.dynamicFieldsSrc, "dynamicField");
+      } else if (id === "fieldTypes") {
+        $scope.containerNodes = fieldNodes($scope.fieldTypes, "type");
+      }
+
+      $scope.containerNodeLabel = node.text;
+      $scope.showFieldDetails = true;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+      return;
+    }
+
+    if (id.startsWith("files/")) {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+      if (node.children.length === 0) {
+        // file
+        $scope.showFieldDetails = true;
+        $scope.onSelectFileNode(id, false);
+      } else {
+        // folder
+        $scope.showFieldDetails = false;
+        delete $scope.selectedFile;
+      }
+      return;
+    }
+
+    delete $scope.selectedFile;
+    $scope.selectedNode = node["a_attr"]; // all the info we need is in the a_attr object
+    if (!$scope.selectedNode) {
+      // a node in the tree that isn't a field was selected, just ignore
+      return;
+    }
+
+    $scope.selectedNode.fieldType = getType($scope.selectedNode.type);
+    $scope.isLeafNode = true;
+
+    var nodeType = id.substring(0, id.indexOf("/"));
+    var name = null;
+    if (nodeType === "field") {
+      $scope.selectedType = "Field";
+      name = $scope.selectedNode.type;
+    } else if (nodeType === "dynamic") {
+      $scope.selectedType = "Dynamic Field";
+    } else if (nodeType === "type") {
+      $scope.selectedType = "Type";
+      name = $scope.selectedNode.name;
+    }
+
+    if (name) {
+      $scope.initTypeAnalysisInfo(name, "type");
+    }
+
+    // apply some sanity to the checkboxes
+    $scope.selectedNode = applyConstraintsOnField($scope.selectedNode);
+    $scope.showFieldDetails = true;
+
+    if (nodeType === "field" && $scope.selectedNode.tokenized && $scope.selectedNode.stored) {
+      $scope.showAnalysis = true;
+      $scope.updateSampleDocId();
+    } else {
+      $scope.showAnalysis = false;
+      $scope.indexText = "";
+      $scope.result = {};
+    }
+  };
+
+  function addFileNode(dirs, parent, f) {
+    var path = f.split("/");
+    if (path.length === 1) {
+      if (!parent.children) {
+        parent.children = [];
+        dirs.push(parent); // now parent has children, so track in dirs ...
+      }
+      var nodeId = parent.id + "/" + f;
+      parent.children.push({"text": f, "id": nodeId, "a_attr": {"href": nodeId}});
+    } else {
+      // get the parent for this path
+      var parentId = "files/" + path.slice(0, path.length - 1).join("/");
+      var dir = null;
+      for (var d in dirs) {
+        if (dirs[d].id === parentId) {
+          dir = dirs[d];
+          break;
+        }
+      }
+      if (!dir) {
+        dir = {"text": path[0], "id": parentId, "a_attr": {"href": parentId}, "children": []};
+        dirs.push(dir);
+        parent.children.push(dir);
+      }
+
+      // walk down the next level in this path
+      addFileNode(dirs, dir, path.slice(1).join("/"));
+    }
+  }
+
+  // transform a flat list structure into the nested tree structure
+  function filesToTree(files) {
+    var filesNode = {"text": "Files", "a_attr": {"href": "files"}, "id": "files", "children": []};
+    if (files) {
+      var dirs = []; // lookup for known dirs by path since nodes don't keep a ref to their parent node
+      for (var i in files) {
+        // hide the configoverlay.json from the UI
+        if (files[i] === "configoverlay.json") {
+          continue;
+        }
+
+        addFileNode(dirs, filesNode, files[i]);
+      }
+      delete dirs;
+    }
+    return filesNode;
+  }
+
+  function fieldsToTree(fields) {
+    var children = [];
+    if (fields) {
+      for (var i in fields) {
+        var id = "field/" + fields[i].name;
+        fields[i].href = id;
+        var text = fields[i].name;
+        if (fields[i].name === $scope.uniqueKeyField) {
+          text += "*"; // unique key field
+        }
+        children.push({"text": text, "a_attr": fields[i], "id": id});
+      }
+    }
+    return children;
+  }
+
+  function fieldTypesToTree(types) {
+    var children = [];
+    for (var i in types) {
+      var ft = types[i]
+      var id = "type/" + ft.name;
+      ft.href = id;
+      children.push({"text": ft.name, "a_attr": ft, "id": id});
+    }
+    return children;
+  }
+
+  $scope.onSampleDocumentsChanged = function () {
+    $scope.hasDocsOnServer = false; // so the updates get sent on next analyze action
+  };
+
+  $scope.initDesignerSettingsFromResponse = function (data) {
+    $scope.enableDynamicFields = data.enableDynamicFields !== null ? "" + data.enableDynamicFields : "true";
+    $scope.enableFieldGuessing = data.enableFieldGuessing !== null ? "" + data.enableFieldGuessing : "true";
+    $scope.enableNestedDocs = data.enableNestedDocs !== null ? "" + data.enableNestedDocs : "false";
+    $scope.languages = data.languages !== null && data.languages.length > 0 ? data.languages : ["*"];
+    $scope.copyFrom = data.copyFrom !== null ? data.copyFrom : "_default";
+  };
+
+  $scope.doAnalyze = function (nodeId) {
+    delete $scope.sampleMessage;
+
+    var schema = $scope.currentSchema;
+    if (schema) {
+      delete $scope.copyFrom;
+    } else {
+      schema = $scope.newSchema;
+      if (!$scope.copyFrom) {
+        $scope.copyFrom = "_default";
+      }
+    }
+
+    if (!schema) {
+      return;
+    }
+
+    var params = {path: "analyze", configSet: schema};
+    if ($scope.schemaVersion && $scope.schemaVersion !== -1) {
+      params.schemaVersion = $scope.schemaVersion;
+    }
+
+    if ($scope.enableDynamicFields) {
+      params.enableDynamicFields = $scope.enableDynamicFields;
+    }
+    if ($scope.enableFieldGuessing) {
+      params.enableFieldGuessing = $scope.enableFieldGuessing;
+    }
+    if ($scope.enableNestedDocs) {
+      params.enableNestedDocs = $scope.enableNestedDocs;
+    }
+
+    if ($scope.languages && $scope.languages.length > 0) {
+      params.languages = $scope.languages;
+    }
+
+    if ($scope.copyFrom) {
+      params.copyFrom = $scope.copyFrom;
+    }
+
+    $scope.updateWorking = true;
+    if ($scope.selectedUpdated) {
+      $scope.updateStatusMessage = "Applying " + $scope.selectedType + " updates ..."
+    } else {
+      $scope.updateStatusMessage = "Analyzing your sample data, schema will load momentarily ..."
+    }
+
+    if (!nodeId && $scope.selectedNode) {
+      nodeId = $scope.selectedNode.id;
+    }
+
+    // a bit tricky ...
+    // so users can upload a file or paste in docs
+    // if they upload a file containing a small (<15K) of text data, then we'll show it in the textarea
+    // they can change the text and re-analyze too
+    // if no changes or nothing uploaded, the server-side uses the latest sample data stored in the blob store
+    if ($scope.fileUpload) {
+      var file = $scope.fileUpload;
+      var fd = new FormData();
+      fd.append('file', file);
+      SchemaDesigner.upload(params, fd, function (data) {
+        $("#upload-file").val("");
+        delete $scope.fileUpload;
+        $scope.onSchemaUpdated(schema, data, nodeId);
+      }, $scope.errorHandler);
+    } else {
+      // don't need to keep re-posting the same sample if already stored in the blob store
+      var postData = null;
+      if (!$scope.hasDocsOnServer) {
+        postData = $scope.sampleDocuments;
+        if (!postData && !$scope.published) {
+          return;
+        }
+      }
+
+      var respHandler = function (data) {
+        $scope.onSchemaUpdated(schema, data, nodeId);
+      };
+
+      // TODO: need a better approach to detecting the content type from text content
+      var contentType = "text/plain";
+      if (postData != null) {
+        var txt = postData.trim();
+        if ((txt.startsWith("[") && txt.includes("]")) || (txt.startsWith("{") && txt.includes("}"))) {
+          contentType = "application/json"
+        } else if (txt.startsWith("<") || txt.includes("<add>") || txt.includes("<!--")) {
+          contentType = "text/xml";
+        } else {
+          contentType = "application/csv";
+        }
+      }
+
+      if (contentType === "text/xml") {
+        SchemaDesigner.postXml(params, postData, respHandler, $scope.errorHandler);
+      } else if (contentType === "application/csv") {
+        SchemaDesigner.postCsv(params, postData, respHandler, $scope.errorHandler);
+      } else {
+        SchemaDesigner.post(params, postData, respHandler, $scope.errorHandler);
+      }
+    }
+  };
+
+  $scope.onFieldTypeChanged = function () {
+
+    var copyFromType = ["stored", "indexed", "multiValued", "docValues", "useDocValuesAsStored", "tokenized", "uninvertible", "termVectors", "termPositions", "termOffsets",
+      "omitNorms", "omitTermFreqAndPositions", "omitPositions", "storeOffsetsWithPositions"];
+
+    var type = $scope.selectedNode.type
+
+    // when the user updates the selected field's type, we go apply the
+    // new field type's properties to the selected node
+    for (var i in $scope.fieldTypes) {
+      if ($scope.fieldTypes[i].text == type) {
+        var ft = $scope.fieldTypes[i];
+        $scope.selectedNode.fieldType = ft;
+        for (var i in copyFromType) {

Review comment:
       *W004:*  'i' is already defined.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/security.js
##########
@@ -0,0 +1,1124 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cookies, $window, Constants, System, Security) {
+  $scope.resetMenu("security", Constants.IS_ROOT_PAGE);
+
+  $scope.params = [];
+
+  var strongPasswordRegex = /^(?=.*[0-9])(?=.*[!@#$%^&*\-_()[\]])[a-zA-Z0-9!@#$%^&*\-_()[\]]{8,30}$/;
+
+  function toList(str) {
+    if (Array.isArray(str)) {
+      return str; // already a list
+    }
+    return str.trim().split(",").map(s => s.trim()).filter(s => s !== "");
+  }
+
+  function asList(listOrStr) {
+    return Array.isArray(listOrStr) ? listOrStr : (listOrStr ? [listOrStr] : []);
+  }
+
+  function transposeUserRoles(userRoles) {
+    var roleUsers = {};
+    for (var u in userRoles) {
+      var roleList = asList(userRoles[u]);
+      for (var i in roleList) {
+        var role = roleList[i];
+        if (!roleUsers[role]) roleUsers[role] = []
+        roleUsers[role].push(u);
+      }
+    }
+
+    var roles = [];
+    for (var r in roleUsers) {
+      roles.push({"name":r, "users":Array.from(new Set(roleUsers[r]))});
+    }
+    return roles.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  function roleMatch(roles, rolesForUser) {
+    for (r in rolesForUser) {
+      if (roles.includes(rolesForUser[r]))
+        return true;
+    }
+    return false;
+  }
+
+  function permRow(perm, i) {
+    var roles = asList(perm.role);
+    var paths = asList(perm.path);
+
+    var collectionNames = "";
+    var collections = [];
+    if ("collection" in perm) {
+      if (perm["collection"] == null) {
+        collectionNames = "null";
+      } else {
+        collections = asList(perm.collection);
+        collectionNames = collections.sort().join(", ");
+      }
+    } else {
+      // no collection property on the perm, so the default "*" applies
+      collectionNames = "";
+      collections.push("*");
+    }
+
+    var method = asList(perm.method);
+
+    // perms don't always have an index ?!?
+    var index = "index" in perm ? perm["index"] : ""+i;
+
+    return { "index": index, "name": perm.name, "collectionNames": collectionNames, "collections": collections,
+      "roles": roles, "paths": paths, "method": method, "params": perm.params };
+  }
+
+  function checkError(data) {
+    var cause = null;
+    if ("errorMessages" in data && Array.isArray(data["errorMessages"]) && data["errorMessages"].length > 0) {
+      cause = "?";
+      if ("errorMessages" in data["errorMessages"][0] && Array.isArray(data["errorMessages"][0]["errorMessages"]) && data["errorMessages"][0]["errorMessages"].length > 0) {
+        cause = data["errorMessages"][0]["errorMessages"][0];
+      }
+    }
+    return cause;
+  }
+
+  function truncateTo(str, maxLen, delim) {
+    // allow for a little on either side of maxLen for better display
+    var varLen = Math.min(Math.round(maxLen * 0.1), 15);
+    if (str.length <= maxLen + varLen) {
+      return str;
+    }
+
+    var total = str.split(delim).length;
+    var at = str.indexOf(delim, maxLen - varLen);
+    str = (at !== -1 && at < maxLen + varLen) ? str.substring(0, at) : str.substring(0, maxLen);
+    var trimmed = str.split(delim).length;
+    var diff = total - trimmed;
+    str += " ... "+(diff > 1 ? "(+"+diff+" more)" : "");
+    return str;
+  }
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.securityAPIError;
+    delete $scope.securityAPIErrorDetails;
+  };
+
+  $scope.displayList = function(listOrStr) {
+    if (!listOrStr) return "";
+    var str = Array.isArray(listOrStr) ? listOrStr.sort().join(", ") : (""+listOrStr).trim();
+    return truncateTo(str, 160, ", ");
+  };
+
+  $scope.displayParams = function(obj) {
+    if (!obj) return "";
+    if (Array.isArray(obj)) return obj.sort().join(", ");
+
+    var display = "";
+    for (const [key, value] of Object.entries(obj)) {
+      if (display.length > 0) display += "; ";
+      display += (key + "=" + (Array.isArray(value)?value.sort().join(","):value+""));
+    }
+    return truncateTo(display, 160, "; ");
+  };
+
+  $scope.displayRoles = function(obj) {
+    return (!obj || (Array.isArray(obj) && obj.length === 0)) ? "null" : $scope.displayList(obj);
+  };
+
+  $scope.predefinedPermissions = ["collection-admin-edit", "collection-admin-read", "core-admin-read", "core-admin-edit", "zk-read",
+    "read", "update", "all", "config-edit", "config-read", "schema-read", "schema-edit", "security-edit", "security-read",
+    "metrics-read", "filestore-read", "filestore-write", "package-edit", "package-read"].sort();
+
+  $scope.predefinedPermissionCollection = {"read":"*", "update":"*", "config-edit":"*", "config-read":"*", "schema-edit":"*", "schema-read":"*"};
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error && error.msg) {
+      $scope.securityAPIError = error.msg;
+      $scope.securityAPIErrorDetails = e.data.errorDetails;
+    } else if (e.data && e.data.message) {
+      $scope.securityAPIError = e.data.message;
+      $scope.securityAPIErrorDetails = JSON.stringify(e.data);
+    }
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.hideAll();
+
+    $scope.tls = false;
+    $scope.blockUnknown = "false"; // default setting
+    $scope.realmName = "solr";
+    $scope.forwardCredentials = "false";
+
+    $scope.currentUser = sessionStorage.getItem("auth.username");
+
+    $scope.userFilter = "";
+    $scope.userFilterOption = "";
+    $scope.userFilterText = "";
+    $scope.userFilterOptions = [];
+
+    $scope.permFilter = "";
+    $scope.permFilterOption = "";
+    $scope.permFilterOptions = [];
+    $scope.permFilterTypes = ["", "name", "role", "path", "collection"];
+
+    System.get(function(data) {
+      $scope.tls = data.security ? data.security["tls"] : false;
+      $scope.authenticationPlugin = data.security ? data.security["authenticationPlugin"] : null;
+      $scope.authorizationPlugin = data.security ? data.security["authorizationPlugin"] : null;
+      $scope.myRoles = data.security ? data.security["roles"] : [];
+      $scope.isSecurityAdminEnabled = $scope.authenticationPlugin != null;
+      $scope.isCloudMode = data.mode.match( /solrcloud/i ) != null;
+      $scope.zkHost = $scope.isCloudMode ? data["zkHost"] : "";
+      $scope.solrHome = data["solr_home"];
+      $scope.refreshSecurityPanel();
+    }, function(e) {
+      if (e.status === 401 || e.status === 403) {
+        $scope.isSecurityAdminEnabled = true;
+        $scope.hasSecurityEditPerm = false;
+        $scope.hideAll();
+      }
+    });
+  };
+
+  $scope.hideAll = function () {
+    // add more dialogs here
+    delete $scope.validationError;
+    $scope.showUserDialog = false;
+    $scope.showPermDialog = false;
+    delete $scope.helpId;
+  };
+
+  $scope.getCurrentUserRoles = function() {
+    if ($scope.manageUserRolesEnabled) {
+      return Array.isArray($scope.userRoles[$scope.currentUser]) ? $scope.userRoles[$scope.currentUser] : [$scope.userRoles[$scope.currentUser]];
+    } else {
+      return $scope.myRoles;
+    }
+  };
+
+  $scope.hasPermission = function(permissionName) {
+    var rolesForPermission = $scope.permissionsTable.filter(p => permissionName === p.name).flatMap(p => p.roles);
+    return (rolesForPermission.length > 0 && roleMatch(rolesForPermission, $scope.getCurrentUserRoles()));
+  };
+
+  $scope.refreshSecurityPanel = function() {
+
+    // determine if the authorization plugin supports CRUD permissions
+    $scope.managePermissionsEnabled =
+        ($scope.authorizationPlugin === "org.apache.solr.security.RuleBasedAuthorizationPlugin" ||
+            $scope.authorizationPlugin === "org.apache.solr.security.ExternalRoleRuleBasedAuthorizationPlugin");
+
+    // don't allow CRUD on roles if using external
+    $scope.manageUserRolesEnabled = $scope.authorizationPlugin === "org.apache.solr.security.RuleBasedAuthorizationPlugin";
+
+    Security.get({path: "authorization"}, function (data) {
+      if (!data.authorization) {
+        $scope.isSecurityAdminEnabled = false;
+        $scope.hasSecurityEditPerm = false;
+        return;
+      }
+
+      if ($scope.manageUserRolesEnabled) {
+        $scope.userRoles = data.authorization["user-role"];
+        $scope.roles = transposeUserRoles($scope.userRoles);
+        $scope.filteredRoles = $scope.roles;
+        $scope.roleNames = $scope.roles.map(r => r.name).sort();
+        $scope.roleNamesWithWildcard = ["*"].concat($scope.roleNames);
+        if (!$scope.permFilterTypes.includes("user")) {
+          $scope.permFilterTypes.push("user"); // can only filter perms by user if we have a role to user mapping
+        }
+      } else {
+        $scope.userRoles = {};
+        $scope.roles = [];
+        $scope.filteredRoles = [];
+        $scope.roleNames = [];
+      }
+
+      $scope.permissions = data.authorization["permissions"];
+      $scope.permissionsTable = [];
+      for (p in $scope.permissions) {
+        $scope.permissionsTable.push(permRow($scope.permissions[p], parseInt(p)+1));
+      }
+      $scope.filteredPerms = $scope.permissionsTable;
+
+      $scope.hasSecurityEditPerm = $scope.hasPermission("security-edit");
+      $scope.hasSecurityReadPerm = $scope.hasSecurityEditPerm || $scope.hasPermission("security-read");
+
+      if ($scope.authenticationPlugin === "org.apache.solr.security.BasicAuthPlugin") {
+        $scope.manageUsersEnabled = true;
+
+        Security.get({path: "authentication"}, function (data) {
+          if (!data.authentication) {
+            // TODO: error msg
+            $scope.manageUsersEnabled = false;
+          }
+
+          $scope.blockUnknown = data.authentication["blockUnknown"] === true ? "true" : "false";
+          $scope.forwardCredentials = data.authentication["forwardCredentials"] === true ? "true" : "false";
+
+          if ("realm" in data.authentication) {
+            $scope.realmName = data.authentication["realm"];
+          }
+
+          var users = [];
+          if (data.authentication.credentials) {
+            for (var u in data.authentication.credentials) {
+              var roles = $scope.userRoles[u];
+              if (!roles) roles = [];
+              users.push({"username":u, "roles":roles});
+            }
+          }
+          $scope.users = users.sort((a, b) => (a.username > b.username) ? 1 : -1);
+          $scope.filteredUsers = $scope.users.slice(0,100); // only display first 100
+        }, $scope.errorHandler);
+      } else {
+        $scope.users = [];
+        $scope.filteredUsers = $scope.users;
+        $scope.manageUsersEnabled = false;
+      }
+    }, $scope.errorHandler);
+  };
+
+  $scope.validatePassword = function() {
+    var password = $scope.upsertUser.password.trim();
+    var password2 = $scope.upsertUser.password2 ? $scope.upsertUser.password2.trim() : "";
+    if (password !== password2) {
+      $scope.validationError = "Passwords do not match!";
+      return false;
+    }
+
+    if (!password.match(strongPasswordRegex)) {
+      $scope.validationError = "Password not strong enough! Must contain at least one lowercase letter, one uppercase letter, one digit, and one of these special characters: !@#$%^&*_-[]()";
+      return false;
+    }
+
+    return true;
+  };
+
+  $scope.updateUserRoles = function() {
+    var setUserRoles = {};
+    var roles = [];
+    if ($scope.upsertUser.selectedRoles) {
+      roles = roles.concat($scope.upsertUser.selectedRoles);
+    }
+    if ($scope.upsertUser.newRole && $scope.upsertUser.newRole.trim() !== "") {
+      var newRole = $scope.upsertUser.newRole.trim();
+      if (newRole !== "null" && newRole !== "*" && newRole.length <= 30) {
+        roles.push(newRole);
+      } // else, no new role for you!
+    }
+    var userRoles = Array.from(new Set(roles));
+    setUserRoles[$scope.upsertUser.username] = userRoles.length > 0 ? userRoles : null;
+    Security.post({path: "authorization"}, { "set-user-role": setUserRoles }, function (data) {
+      $scope.toggleUserDialog();
+      $scope.refreshSecurityPanel();
+    });
+  };
+
+  $scope.doUpsertUser = function() {
+    if (!$scope.upsertUser) {
+      delete $scope.validationError;
+      $scope.showUserDialog = false;
+      return;
+    }
+
+    if (!$scope.upsertUser.username || $scope.upsertUser.username.trim() === "") {
+      $scope.validationError = "Username is required!";
+      return;
+    }
+
+    // keep username to a reasonable length? but allow for email addresses
+    var username = $scope.upsertUser.username.trim();
+    if (username.length > 30) {
+      $scope.validationError = "Username must be 30 characters or less!";
+      return;
+    }
+
+    var doSetUser = false;
+    if ($scope.userDialogMode === 'add') {
+      if ($scope.users) {
+        var existing = $scope.users.find(u => u.username === username);
+        if (existing) {
+          $scope.validationError = "User '"+username+"' already exists!";
+          return;
+        }
+      }
+
+      if (!$scope.upsertUser.password) {
+        $scope.validationError = "Password is required!";
+        return;
+      }
+
+      if (!$scope.validatePassword()) {
+        return;
+      }
+      doSetUser = true;
+    } else {
+      if ($scope.upsertUser.password) {
+        if ($scope.validatePassword()) {
+          doSetUser = true;
+        } else {
+          return; // update to password is invalid
+        }
+      } // else no update to password
+    }
+
+    if ($scope.upsertUser.newRole && $scope.upsertUser.newRole.trim() !== "") {
+      var newRole = $scope.upsertUser.newRole.trim();
+      if (newRole === "null" || newRole === "*" || newRole.length > 30) {
+        $scope.validationError = "Invalid new role: "+newRole;
+        return;
+      }
+    }
+
+    delete $scope.validationError;
+
+    if (doSetUser) {
+      var setUserJson = {};
+      setUserJson[username] = $scope.upsertUser.password.trim();
+      Security.post({path: "authentication"}, { "set-user": setUserJson }, function (data) {
+
+        var errorCause = checkError(data);
+        if (errorCause != null) {
+          $scope.securityAPIError = "create user "+username+" failed due to: "+errorCause;
+          $scope.securityAPIErrorDetails = JSON.stringify(data);
+          return;
+        }
+
+        $scope.updateUserRoles();
+      });
+    } else {
+      $scope.updateUserRoles();
+    }
+  };
+
+  $scope.confirmDeleteUser = function() {
+    if (window.confirm("Confirm delete the '"+$scope.upsertUser.username+"' user?")) {
+      // remove all roles for the user and the delete the user
+      var removeRoles = {};
+      removeRoles[$scope.upsertUser.username] = null;
+      Security.post({path: "authorization"}, { "set-user-role": removeRoles }, function (data) {
+        Security.post({path: "authentication"}, {"delete-user": [$scope.upsertUser.username]}, function (data2) {
+          $scope.toggleUserDialog();
+          $scope.refreshSecurityPanel();
+        });
+      });
+    }
+  };
+
+  $scope.showAddUserDialog = function() {
+    $scope.userDialogMode = "add";
+    $scope.userDialogHeader = "Add New User";
+    $scope.userDialogAction = "Add User";
+    $scope.upsertUser = {};
+    $scope.toggleUserDialog();
+  };
+
+  $scope.toggleUserDialog = function() {
+    if ($scope.showUserDialog) {
+      delete $scope.upsertUser;
+      delete $scope.validationError;
+      $scope.showUserDialog = false;
+      return;
+    }
+
+    $scope.hideAll();
+    $('#user-dialog').css({left: 132, top: 132});
+    $scope.showUserDialog = true;
+  };
+
+  $scope.onPredefinedChanged = function() {
+    if (!$scope.upsertPerm) {
+      return;
+    }
+
+    if ($scope.upsertPerm.name && $scope.upsertPerm.name.trim() !== "") {
+      delete $scope.selectedPredefinedPermission;
+    } else {
+      $scope.upsertPerm.name = "";
+    }
+
+    if ($scope.selectedPredefinedPermission && $scope.selectedPredefinedPermission in $scope.predefinedPermissionCollection) {
+      $scope.upsertPerm.collection = $scope.predefinedPermissionCollection[$scope.selectedPredefinedPermission];
+    }
+
+    $scope.isPermFieldDisabled = ($scope.upsertPerm.name === "" && $scope.selectedPredefinedPermission);
+  };
+
+  $scope.showAddPermDialog = function() {
+    $scope.permDialogMode = "add";
+    $scope.permDialogHeader = "Add New Permission";
+    $scope.permDialogAction = "Add Permission";
+    $scope.upsertPerm = {};
+    $scope.upsertPerm.name = "";
+    $scope.upsertPerm.index = "";
+    $scope.upsertPerm["method"] = {"get":"true", "post":"true", "put":"true", "delete":"true"};
+    $scope.isPermFieldDisabled = false;
+    delete $scope.selectedPredefinedPermission;
+
+    $scope.params = [{"name":"", "value":""}];
+
+    var permissionNames = $scope.permissions.map(p => p.name);
+    $scope.filteredPredefinedPermissions = $scope.predefinedPermissions.filter(p => !permissionNames.includes(p));
+
+    $scope.togglePermDialog();
+  };
+
+  $scope.togglePermDialog = function() {
+    if ($scope.showPermDialog) {
+      delete $scope.upsertPerm;
+      delete $scope.validationError;
+      $scope.showPermDialog = false;
+      $scope.isPermFieldDisabled = false;
+      delete $scope.selectedPredefinedPermission;
+      return;
+    }
+
+    $scope.hideAll();
+
+    var leftPos = $scope.permDialogMode === "add" ? 500 : 100;
+    var topPos = $('#permissions').offset().top - 320;
+    if (topPos < 0) topPos = 0;
+    $('#add-permission-dialog').css({left: leftPos, top: topPos});
+
+    $scope.showPermDialog = true;
+  };
+
+  $scope.getMethods = function() {
+    var methods = [];
+    if ($scope.upsertPerm.method.get === "true") {
+      methods.push("GET");
+    }
+    if ($scope.upsertPerm.method.put === "true") {
+      methods.push("PUT");
+    }
+    if ($scope.upsertPerm.method.post === "true") {
+      methods.push("POST");
+    }
+    if ($scope.upsertPerm.method.delete === "true") {
+      methods.push("DELETE");
+    }
+    return methods;
+  };
+
+  $scope.confirmDeletePerm = function() {
+    var permName = $scope.selectedPredefinedPermission ? $scope.selectedPredefinedPermission : $scope.upsertPerm.name.trim();
+    if (window.confirm("Confirm delete the '"+permName+"' permission?")) {
+      var index = parseInt($scope.upsertPerm.index);
+      Security.post({path: "authorization"}, { "delete-permission": index }, function (data) {
+        $scope.togglePermDialog();
+        $scope.refreshSecurityPanel();
+      });
+    }
+  };
+
+  $scope.doUpsertPermission = function() {
+    if (!$scope.upsertPerm) {
+      $scope.upsertPerm = {};
+    }
+
+    var isAdd = $scope.permDialogMode === "add";
+    var name = $scope.selectedPredefinedPermission ? $scope.selectedPredefinedPermission : $scope.upsertPerm.name.trim();
+
+    if (isAdd) {
+      if (!name) {
+        $scope.validationError = "Either select a predefined permission or provide a name for a custom permission";
+        return;
+      }
+      var permissionNames = $scope.permissions.map(p => p.name);
+      if (permissionNames.includes(name)) {
+        $scope.validationError = "Permission '"+name+"' already exists!";
+        return;
+      }
+
+      if (name === "*") {
+        $scope.validationError = "Invalid permission name!";
+        return;
+      }
+    }
+
+    var role = null;
+    if ($scope.manageUserRolesEnabled) {
+      role = $scope.upsertPerm.selectedRoles;
+      if (!role || role.length === 0) {
+        role = null;
+      } else if (role.includes("*")) {
+        role = ["*"];
+      }
+    } else if ($scope.upsertPerm.manualRoles && $scope.upsertPerm.manualRoles.trim() !== "") {
+      var manualRoles = $scope.upsertPerm.manualRoles.trim();
+      role = (manualRoles === "null") ? null : toList(manualRoles);
+    }
+
+    var setPermJson = {"name": name, "role": role };
+
+    if ($scope.selectedPredefinedPermission) {
+      $scope.params = [{"name":"","value":""}];
+    } else {
+      // collection
+      var coll = null;
+      if ($scope.upsertPerm.collection != null && $scope.upsertPerm.collection !== "null") {
+        if ($scope.upsertPerm.collection === "*") {
+          coll = "*";
+        } else {
+          coll = $scope.upsertPerm.collection && $scope.upsertPerm.collection.trim() !== "" ? toList($scope.upsertPerm.collection) : "";
+        }
+      }
+      setPermJson["collection"] = coll;
+
+      // path
+      if (!$scope.upsertPerm.path || (Array.isArray($scope.upsertPerm.path) && $scope.upsertPerm.path.length === 0)) {
+        $scope.validationError = "Path is required for custom permissions!";
+        return;
+      }
+
+      setPermJson["path"] = toList($scope.upsertPerm.path);
+
+      if ($scope.upsertPerm.method) {
+        var methods = $scope.getMethods();
+        if (methods.length === 0) {
+          $scope.validationError = "Must specify at least one request method for a custom permission!";
+          return;
+        }
+
+        if (methods.length < 4) {
+          setPermJson["method"] = methods;
+        } // else no need to specify, rule applies to all methods
+      }
+
+      // params
+      var params = {};
+      if ($scope.params && $scope.params.length > 0) {
+        for (i in $scope.params) {
+          var p = $scope.params[i];
+          var name = p.name.trim();

Review comment:
       *W004:*  'name' is already defined.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/schema-designer.js
##########
@@ -0,0 +1,2014 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $cookies, $window, Constants, SchemaDesigner, Luke) {
+  $scope.resetMenu("schema-designer", Constants.IS_ROOT_PAGE);
+
+  $scope.onWarning = function (warnMsg, warnDetails) {
+    $scope.updateWorking = false;
+    delete $scope.updateStatusMessage;
+    $scope.apiWarning = warnMsg;
+    $scope.apiWarningDetails = warnDetails;
+  };
+  
+  $scope.onError = function (errorMsg, errorCode, errorDetails) {
+    $scope.updateWorking = false;
+    delete $scope.updateStatusMessage;
+    $scope.designerAPIError = errorMsg;
+    if (errorDetails) {
+      var errorDetailsStr = "";
+      if (errorDetails["error"]) {
+        errorDetailsStr = errorDetails["error"];
+      } else {
+        for (var id in errorDetails) {
+          var msg = errorDetails[id];
+          var at = msg.indexOf("ERROR: ");
+          if (at !== -1) {
+            msg = msg.substring(at+7);
+          }
+          if (!msg.includes(id)) {
+            msg = id+": "+msg;
+          }
+          errorDetailsStr += msg+"\n\n";
+        }
+      }
+      $scope.designerAPIErrorDetails = errorDetailsStr;
+    } else {
+      delete $scope.designerAPIErrorDetails;
+    }
+
+    if (errorCode === 409) {
+      $scope.schemaVersion = -1; // reset to get the latest
+      $scope.isVersionMismatch = true;
+      $scope.errorButton = "Reload Schema";
+    } else if (errorCode < 500) {
+      $scope.isVersionMismatch = false;
+      $scope.errorButton = "OK";
+    } // else 500 errors get the top-level error message
+  };
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error) {
+      $scope.onError(error.msg, error.code, e.data.errorDetails);
+    } else {
+      // when a timeout occurs, the error details are sparse so just give the user a hint that something was off
+      var path = e.config && e.config.url ? e.config.url : "/api/schema-designer";
+      var reloadMsg = "";
+      if (path.includes("/analyze")) {
+        reloadMsg = " Re-try analyzing your sample docs by clicking on 'Analyze Documents' again."
+      }
+      $scope.onError("Request to "+path+" failed!", 408,
+          {"error":"Most likely the request timed out; check server log for more details."+reloadMsg});
+    }
+  };
+
+  $scope.closeWarnDialog = function () {
+    delete $scope.apiWarning;
+    delete $scope.apiWarningDetails;
+  };
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.designerAPIError;
+    delete $scope.designerAPIErrorDetails;
+    if ($scope.isVersionMismatch) {
+      $scope.isVersionMismatch = false;
+      var nodeId = "/";
+      if ($scope.selectedNode) {
+        nodeId = $scope.selectedNode.href;
+      }
+      $scope.doAnalyze(nodeId);
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.isSchemaDesignerEnabled = true;
+
+    delete $scope.helpId;
+
+    $scope.updateStatusMessage = "";
+    $scope.analysisVerbose = false;
+    $scope.updateWorking = false;
+    $scope.currentSchema = "";
+
+    delete $scope.hasDocsOnServer;
+    delete $scope.queryResultsTree;
+
+    $scope.languages = ["*"];
+    $scope.copyFrom = "_default";
+    delete $scope.sampleMessage;
+    delete $scope.sampleDocuments;
+
+    $scope.schemaVersion = -1;
+    $scope.schemaTree = {};
+    $scope.showSchemaActions = false;
+    $scope.sampleDocIds = [];
+    $scope.isSchemaRoot = false;
+
+    delete $scope.enableNestedDocs;
+    delete $scope.enableDynamicFields;
+    delete $scope.enableFieldGuessing;
+
+    // schema editor
+    $scope.showFieldDetails = false;
+    $scope.selectedNode = null;
+    $scope.selectedUpdated = false;
+    delete $scope.updateStatusMessage;
+
+    // text field analysis
+    $scope.showAnalysis = false;
+    $scope.sampleDocId = null;
+    $scope.indexText = "";
+    $scope.result = {};
+
+    // publish vars
+    delete $scope.newCollection;
+    $scope.reloadOnPublish = "true";
+
+    // query form
+    $scope.query = {q: '*:*', sortBy: 'score', sortDir: 'desc'};
+
+    SchemaDesigner.get({path: "configs"}, function (data) {
+
+      $scope.schemas = [];
+      $scope.publishedSchemas = ["_default"];
+
+      for (var s in data.configSets) {
+        // 1 means published but not editable
+        if (data.configSets[s] !== 1) {
+          $scope.schemas.push(s);
+        }
+
+        // 0 means not published yet (so can't copy from it yet)
+        if (data.configSets[s] > 0) {
+          $scope.publishedSchemas.push(s);
+        }
+      }
+
+      $scope.schemas.sort();
+      $scope.publishedSchemas.sort();
+
+      // if no schemas available to select, open the pop-up immediately
+      if ($scope.schemas.length === 0) {
+        $scope.firstSchemaMessage = true;
+        $scope.showNewSchemaDialog();
+      }
+    }, function(e) {
+      if (e.status === 401 || e.status === 403) {
+        $scope.isSchemaDesignerEnabled = false;
+        $scope.hideAll();
+      }
+    });
+  };
+
+  $scope.selectNodeInTree = function(nodeId) {
+    nodeId = stripAnchorSuffix(nodeId);
+    if (!nodeId) return;
+
+    var jst = $('#schemaJsTree').jstree(true);
+    if (jst) {
+      var selectedId = null;
+      var selected_node = jst.get_selected();
+      if (selected_node && selected_node.length > 0) {
+        selectedId = selected_node[0];
+      }
+      if (selectedId) {
+        try {
+          jst.deselect_node(selectedId);
+        } catch (err) {
+          // just ignore
+          //console.log("error deselecting "+selectedId);
+        }
+      }
+
+      try {
+        jst.select_node(nodeId, true);
+      } catch (err) {
+        // just ignore, some low-level tree issue
+        //console.log("error selecting "+nodeId);
+      }
+    }
+  };
+
+  $scope.loadFile = function (event) {
+    var t = event.target || event.srcElement || event.currentTarget;
+    if (t && t.text) {
+      $scope.onSelectFileNode("files/" + t.text, true);
+    }
+  };
+
+  $scope.confirmEditSchema = function () {
+    $scope.showConfirmEditSchema = false;
+    if ($scope.hasDocsOnServer || $scope.published) {
+      $scope.doAnalyze();
+    } else {
+      $scope.sampleMessage = "Please upload or paste some sample documents to analyze for building the '" + $scope.currentSchema + "' schema.";
+    }
+  };
+
+  $scope.cancelEditSchema = function () {
+    $scope.currentSchema = "";
+    $scope.showConfirmEditSchema = false;
+  };
+
+  $scope.loadSchema = function () {
+
+    if (!$scope.currentSchema) {
+      return;
+    }
+
+    $scope.resetSchema();
+    var params = {path: "info", configSet: $scope.currentSchema};
+    SchemaDesigner.get(params, function (data) {
+      $scope.currentSchema = data.configSet;
+      $("#select-schema").trigger("chosen:updated");
+
+      $scope.confirmSchema = data.configSet;
+      $scope.collectionsForConfig = data.collections;
+      $scope.hasDocsOnServer = data.numDocs > 0;
+      $scope.published = data.published;
+      $scope.initDesignerSettingsFromResponse(data);
+      if ($scope.collectionsForConfig && $scope.collectionsForConfig.length > 0) {
+        $scope.showConfirmEditSchema = true;
+      } else {
+        if ($scope.hasDocsOnServer || $scope.published) {
+          $scope.doAnalyze();
+        } else {
+          $scope.sampleMessage = "Please upload or paste some sample documents to build the '" + $scope.currentSchema + "' schema.";
+        }
+      }
+    });
+  };
+
+  $scope.showNewSchemaDialog = function () {
+    $scope.hideAll();
+    $scope.showNewSchema = true;
+    $scope.newSchema = "";
+  };
+
+  $scope.addSchema = function () {
+    $scope.firstSchemaMessage = false;
+    delete $scope.addMessage;
+
+    if (!$scope.newSchema) {
+      $scope.addMessage = "Please provide a schema name!";
+      return;
+    }
+
+    $scope.newSchema = $scope.newSchema.trim();
+    if ($scope.newSchema.length > 50) {
+      $scope.addMessage = "Schema name be 50 characters or less";
+      return;
+    }
+
+    if ($scope.newSchema.indexOf(" ") !== -1 || $scope.newSchema.indexOf("/") !== -1) {
+      $scope.addMessage = "Schema name should not contain spaces or /";
+      return;
+    }
+
+    if ($scope.publishedSchemas.includes($scope.newSchema) || $scope.schemas.includes($scope.newSchema)) {
+      $scope.addMessage = "Schema '" + $scope.newSchema + "' already exists!";
+      return;
+    }
+
+    delete $scope.addMessage;
+    if (!$scope.copyFrom) {
+      $scope.copyFrom = "_default";
+    }
+
+    $scope.resetSchema();
+    $scope.schemas.push($scope.newSchema);
+    $scope.showNewSchema = false;
+    $scope.currentSchema = $scope.newSchema;
+    $scope.sampleMessage = "Please upload or paste some sample documents to analyze for building the '" + $scope.currentSchema + "' schema.";
+
+    SchemaDesigner.post({path: "prep", configSet: $scope.newSchema, copyFrom: $scope.copyFrom}, null, function (data) {
+      $scope.initDesignerSettingsFromResponse(data);
+    }, $scope.errorHandler);
+  };
+
+  $scope.cancelAddSchema = function () {
+    delete $scope.addMessage;
+    delete $scope.sampleMessage;
+
+    $scope.showNewSchema = false
+  };
+
+  $scope.hideAll = function () {
+    delete $scope.helpId;
+    $scope.showPublish = false;
+    $scope.showDiff = false;
+    $scope.showNewSchema = false;
+    $scope.showAddField = false;
+    $scope.showAddDynamicField = false;
+    $scope.showAddCopyField = false;
+    $scope.showAnalysis = false;
+    // add more dialogs here
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.hideData = function () {
+    $scope.showData = false;
+  };
+
+  $scope.rootChanged = function () {
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  $scope.updateUniqueKey = function () {
+    delete $scope.schemaRootMessage;
+    var jst = $('#schemaJsTree').jstree();
+    if (jst) {
+      var node = jst.get_node("field/" + $scope.updateUniqueKeyField);
+      if (node && node.a_attr) {
+        var attrs = node.a_attr;
+        if (attrs.multiValued || attrs.tokenized || !attrs.stored || !attrs.indexed) {
+          $scope.schemaRootMessage = "Field '" + $scope.updateUniqueKeyField +
+              "' cannot be used as the uniqueKey field! Must be single-valued, stored, indexed, and not tokenized.";
+          $scope.updateUniqueKeyField = $scope.uniqueKeyField;
+          return;
+        }
+      }
+    }
+    $scope.uniqueKeyField = $scope.updateUniqueKeyField;
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  $scope.resetSchema = function () {
+    $scope.hideAll();
+    $scope.analysisVerbose = false;
+    $scope.showSchemaActions = false;
+    $scope.showAnalysis = false;
+    $scope.showFieldDetails = false;
+    $scope.hasDocsOnServer = false;
+    $scope.query = {q: '*:*', sortBy: 'score', sortDir: 'desc'};
+    $scope.schemaVersion = -1;
+
+    $scope.updateWorking = false;
+    $scope.isVersionMismatch = false;
+    delete $scope.updateStatusMessage;
+    delete $scope.designerAPIError;
+    delete $scope.designerAPIErrorDetails;
+    delete $scope.selectedFacets;
+    delete $scope.sampleDocuments;
+    delete $scope.selectedNode;
+    delete $scope.queryResultsTree;
+  };
+
+  $scope.onSchemaUpdated = function (schema, data, nodeId) {
+    $scope.hasDocsOnServer = data.numDocs && data.numDocs > 0;
+    $scope.uniqueKeyField = data.uniqueKeyField;
+    $scope.updateUniqueKeyField = $scope.uniqueKeyField;
+    $scope.initDesignerSettingsFromResponse(data);
+
+    var fieldTypes = fieldTypesToTree(data.fieldTypes);
+    var files = filesToTree(data.files);
+
+    var rootChildren = [];
+    $scope.fieldsSrc = fieldsToTree(data.fields);
+    $scope.fieldsNode = {
+      "id": "fields",
+      "text": "Fields",
+      "state": {"opened": true},
+      "a_attr": {"href": "fields"},
+      "children": $scope.fieldsSrc
+    };
+    rootChildren.push($scope.fieldsNode);
+
+    if ($scope.enableDynamicFields === "true") {
+      $scope.dynamicFieldsSrc = fieldsToTree(data.dynamicFields);
+      $scope.dynamicFieldsNode = {
+        "id": "dynamicFields",
+        "text": "Dynamic Fields",
+        "a_attr": {"href": "dynamicFields"},
+        "children": $scope.dynamicFieldsSrc
+      };
+      rootChildren.push($scope.dynamicFieldsNode);
+    } else {
+      delete $scope.dynamicFieldsNode;
+      delete $scope.dynamicFieldsSrc;
+    }
+
+    rootChildren.push({"id":"fieldTypes", "text": "Field Types", "a_attr": {"href": "fieldTypes"}, "children": fieldTypes});
+    rootChildren.push(files);
+
+    var tree = [{"id":"/", "text": schema, "a_attr": {"href": "/"}, "state":{"opened": true}, "children": rootChildren}];
+
+    $scope.fields = data.fields;
+    $scope.fieldNames = data.fields.map(f => f.name).sort();
+    $scope.possibleIdFields = data.fields.filter(f => f.indexed && f.stored && !f.tokenized).map(f => f.name).sort();
+    $scope.sortableFields = data.fields.filter(f => (f.indexed || f.docValues) && !f.multiValued && !f.tokenized).map(f => f.name).sort();
+    $scope.sortableFields.push("score");
+
+    $scope.facetFields = data.fields.filter(f => (f.indexed || f.docValues) && !f.tokenized && f.name !== '_version_').map(f => f.name).sort();
+    $scope.hlFields = data.fields.filter(f => f.stored && f.tokenized).map(f => f.name).sort();
+
+    $scope.schemaVersion = data.schemaVersion;
+    $scope.currentSchema = data.configSet;
+    $scope.fieldTypes = fieldTypes;
+    $scope.core = data.core;
+    $scope.schemaTree = tree;
+    $scope.refreshTree();
+
+    $scope.collectionsForConfig = data.collectionsForConfig;
+
+    if (data.docIds) {
+      $scope.sampleDocIds = data.docIds;
+    }
+
+    // re-apply the filters on the updated schema
+    $scope.onTreeFilterOptionChanged();
+
+    // Load the Luke schema
+    Luke.schema({core: data.core}, function (schema) {
+      Luke.raw({core: data.core}, function (index) {
+        $scope.luke = mergeIndexAndSchemaData(index, schema.schema);
+        $scope.types = Object.keys(schema.schema.types);
+        $scope.showSchemaActions = true;
+        if (!nodeId) {
+          nodeId = "/";
+        }
+        $scope.onSelectSchemaTreeNode(nodeId);
+
+        $scope.updateWorking = false;
+
+        if (data.updateError != null) {
+          $scope.onError(data.updateError, data.updateErrorCode, data.errorDetails);
+        } else {
+          if ($scope.selectedUpdated) {
+            $scope.selectedUpdated = false;
+            $scope.updateStatusMessage = "Changes applied successfully.";
+            var waitMs = 3000;
+            if (data.rebuild) {
+              $scope.updateStatusMessage += " Did full re-index of sample docs due to incompatible update.";
+              waitMs = 5000; // longer message, more time to read
+            }
+
+            if (data.analysisError) {
+              var updateType = data["updateType"];
+              var updatedObject = data[updateType];
+              var updatedName = updatedObject && updatedObject.name ? updatedObject.name : "";
+              var warnMsg = "Changes to "+updateType+" "+updatedName+" applied, but required the temp collection to be deleted " +
+                  "and re-created due to an incompatible Lucene change, see details below.";
+              $scope.onWarning(warnMsg, data.analysisError);
+            } else {
+              $timeout(function () { delete $scope.updateStatusMessage; }, waitMs);
+            }
+          } else {
+            var source = data.sampleSource;
+            if (source) {
+              if (source === "paste") {
+                source = "pasted sample"
+              } else if (source === "blob") {
+                source = "previous upload stored on the server"
+              }
+              if (data.numDocs > 0) {
+                $scope.updateStatusMessage = "Analyzed "+data.numDocs+" docs from "+source;
+              } else {
+                $scope.updateStatusMessage = "Schema '"+$scope.currentSchema+"' loaded.";
+              }
+            }
+            $timeout(function () {
+              delete $scope.updateStatusMessage;
+            }, 5000);
+          }
+        }
+
+        // re-fire the current query to reflect the updated schema
+        $scope.doQuery();
+        $scope.selectNodeInTree(nodeId);
+      });
+    });
+  };
+
+  $scope.toggleAddField = function (type) {
+    if ($scope.showAddField) {
+      $scope.hideAll();
+    } else {
+      $scope.hideAll();
+      $scope.showAddField = true;
+      $scope.adding = type;
+
+      $scope.newField = {
+        stored: "true",
+        indexed: "true",
+        uninvertible: "true",
+        docValues: "true"
+      }
+
+      if (type === "field") {
+        $scope.newField.type = "string";
+      }
+
+      delete $scope.addErrors;
+    }
+  };
+
+  function applyConstraintsOnField(f) {
+
+    if (!f.docValues) {
+      delete f.useDocValuesAsStored;
+    }
+
+    if (!f.docValues && !f.uninvertible) {
+      delete f.sortMissingLast; // remove this setting if no docValues / uninvertible
+    }
+
+    if (f.indexed) {
+      if (f.omitTermFreqAndPositions && !f.omitPositions) {
+        delete f.omitPositions; // :shrug ~ see SchemaField ln 295
+      }
+      if (!f.termVectors) {
+        delete f.termPositions;
+        delete f.termOffsets;
+        delete f.termPayloads;
+      }
+    } else {
+      // if not indexed, a bunch of fields are false
+      f.tokenized = false;
+      f.uninvertible = false;
+
+      // drop these from the request
+      delete f.termVectors;
+      delete f.termPositions;
+      delete f.termOffsets;
+      delete f.termPayloads;
+      delete f.omitNorms;
+      delete f.omitPositions;
+      delete f.omitTermFreqAndPositions;
+      delete f.storeOffsetsWithPositions;
+    }
+
+    return f;
+  }
+
+  $scope.addField = function () {
+
+    // validate the form input
+    $scope.addErrors = [];
+    if (!$scope.newField.name) {
+      $scope.addErrors.push($scope.adding + " name is required!");
+    }
+
+    if ($scope.newField.name.indexOf(" ") != -1) {
+      $scope.addErrors.push($scope.adding + " name should not have whitespace");
+    }
+
+    var command = "add-field-type";
+    if ("field" === $scope.adding) {
+
+      if ($scope.fieldNames.includes($scope.newField.name)) {
+        $scope.addErrors.push("Field '" + $scope.newField.name + "' already exists!");
+        return;
+      }
+
+      // TODO: is this the correct logic for detecting dynamic? Probably good enough for the designer
+      var isDynamic = $scope.newField.name.startsWith("*") || $scope.newField.name.endsWith("*");
+      if (isDynamic) {
+        if ($scope.luke && $scope.luke.dynamic_fields[$scope.newField.name]) {
+          $scope.addErrors.push("dynamic field '" + $scope.newField.name + "' already exists!");
+        }
+      } else {
+        if ($scope.luke && $scope.luke.fields[$scope.newField.name]) {
+          $scope.addErrors.push("field '" + $scope.newField.name + "' already exists!");
+        }
+      }
+
+      if (!$scope.newField.type) {
+        $scope.addErrors.push("field type is required!");
+      }
+
+      command = isDynamic ? "add-dynamic-field" : "add-field";
+    } else if ("type" === $scope.adding) {
+      if ($scope.types.includes($scope.newField.name)) {
+        $scope.addErrors.push("Type '" + $scope.newField.name + "' already exists!");
+      }
+
+      if (!$scope.newField.class) {
+        $scope.addErrors.push("class is required when creating a new field type!");
+      }
+    }
+
+    var addData = {};
+    addData[command] = applyConstraintsOnField($scope.newField);
+    if ($scope.textAnalysisJson) {
+      var text = $scope.textAnalysisJson.trim();
+      if (text.length > 0) {
+        text = text.replace(/\s+/g, ' ');
+        if (!text.startsWith("{")) {
+          text = "{ " + text + " }";
+        }
+        try {
+          var textJson = JSON.parse(text);
+          if (textJson.analyzer) {
+            addData[command].analyzer = textJson.analyzer;
+          } else {
+            if (!textJson.indexAnalyzer || !textJson.queryAnalyzer) {
+              $scope.addErrors.push("Text analysis JSON should define either an 'analyzer' or an 'indexAnalyzer' and 'queryAnalyzer'");
+              return;
+            }
+            addData[command].indexAnalyzer = textJson.indexAnalyzer;
+            addData[command].queryAnalyzer = textJson.queryAnalyzer;
+          }
+        } catch (e) {
+          $scope.addErrors.push("Failed to parse analysis as JSON due to: " + e.message +
+              "; expected JSON object with either an 'analyzer' or 'indexAnalyzer' and 'queryAnalyzer'");
+          return;
+        }
+      }
+    }
+
+    if ($scope.addErrors.length > 0) {
+      return;
+    }
+    delete $scope.addErrors; // no errors!
+
+    SchemaDesigner.post({
+      path: "add",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, addData, function (data) {
+      if (data.errors) {
+        $scope.addErrors = data.errors[0].errorMessages;
+        if (typeof $scope.addErrors === "string") {
+          $scope.addErrors = [$scope.addErrors];
+        }
+      } else {
+        delete $scope.textAnalysisJson;
+        $scope.added = true;
+        $timeout(function () {
+          $scope.showAddField = false;
+          $scope.added = false;
+          var nodeId = "/";
+          if ("field" === $scope.adding) {
+            nodeId = "field/" + data[command];
+          } else if ("type" === $scope.adding) {
+            nodeId = "type/" + data[command];
+          }
+          $scope.onSchemaUpdated(data.configSet, data, nodeId);
+        }, 500);
+      }
+    }, $scope.errorHandler);
+  }
+
+  function toSortedNameAndTypeList(fields, typeAttr) {
+    var list = [];
+    var keys = Object.keys(fields);
+    for (var f in keys) {
+      var field = fields[keys[f]];
+      var type = field[typeAttr];
+      if (type) {
+        list.push(field.name + ": "+type);
+      } else {
+        list.push(field.name);
+      }
+    }
+    return list.sort();
+  }
+
+  function toSortedFieldList(fields) {
+    var list = [];
+    var keys = Object.keys(fields);
+    for (var f in keys) {
+      list.push(fields[keys[f]]);
+    }
+    return list.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  $scope.toggleDiff = function (event) {
+    if ($scope.showDiff) {
+      // toggle, close dialog
+      $scope.showDiff = false;
+      return;
+    }
+
+    if (event) {
+      var t = event.target || event.currentTarget;
+      var leftPos = t.getBoundingClientRect().left - 600;
+      if (leftPos < 0) leftPos = 0;
+      $('#show-diff-dialog').css({left: leftPos});
+    }
+
+    SchemaDesigner.get({ path: "diff", configSet: $scope.currentSchema }, function (data) {
+      var diff = data.diff;
+
+      var dynamicFields = diff.dynamicFields;
+      var enableDynamicFields = data.enableDynamicFields !== null ? data.enableDynamicFields : true;
+      if (!enableDynamicFields) {
+        dynamicFields = null;
+      }
+
+      $scope.diffSource = data["diff-source"];
+      $scope.schemaDiff = {
+        "fieldsDiff": diff.fields,
+        "addedFields": [],
+        "removedFields": [],
+        "fieldTypesDiff": diff.fieldTypes,
+        "removedTypes": [],
+        "dynamicFieldsDiff": dynamicFields,
+        "copyFieldsDiff": diff.copyFields
+      }
+      if (diff.fields && diff.fields.added) {
+        $scope.schemaDiff.addedFields = toSortedFieldList(diff.fields.added);
+      }
+      if (diff.fields && diff.fields.removed) {
+        $scope.schemaDiff.removedFields = toSortedNameAndTypeList(diff.fields.removed, "type");
+      }
+      if (diff.fieldTypes && diff.fieldTypes.removed) {
+        $scope.schemaDiff.removedTypes = toSortedNameAndTypeList(diff.fieldTypes.removed, "class");
+      }
+
+      $scope.schemaDiffExists = !(diff.fields == null && diff.fieldTypes == null && dynamicFields == null && diff.copyFields == null);
+      $scope.showDiff = true;
+    });
+  }
+
+  $scope.togglePublish = function (event) {
+    if (event) {
+      var t = event.target || event.currentTarget;
+      var leftPos = t.getBoundingClientRect().left - 515;
+      if (leftPos < 0) leftPos = 0;
+      $('#publish-dialog').css({left: leftPos});
+    }
+
+    $scope.showDiff = false;
+    $scope.showPublish = !$scope.showPublish;
+    delete $scope.publishErrors;
+
+    $scope.disableDesigner = "false";
+
+    if ($scope.showPublish && !$scope.newCollection) {
+      $scope.newCollection = {numShards: 1, replicationFactor: 1, indexToCollection: "true"};
+    }
+  };
+
+  $scope.toggleAddCopyField = function () {
+    if ($scope.showAddCopyField) {
+      $scope.hideAll();
+      $scope.showFieldDetails = true;
+    } else {
+      $scope.hideAll();
+      $scope.showAddCopyField = true;
+      $scope.showFieldDetails = false;
+
+      $scope.copyField = {};
+      delete $scope.addCopyFieldErrors;
+    }
+  }
+  $scope.addCopyField = function () {
+    delete $scope.addCopyFieldErrors;
+    var data = {"add-copy-field": $scope.copyField};
+    SchemaDesigner.post({
+      path: "add",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, data, function (data) {
+      if (data.errors) {
+        $scope.addCopyFieldErrors = data.errors[0].errorMessages;
+        if (typeof $scope.addCopyFieldErrors === "string") {
+          $scope.addCopyFieldErrors = [$scope.addCopyFieldErrors];
+        }
+      } else {
+        $scope.showAddCopyField = false;
+        // TODO:
+        //$timeout($scope.refresh, 1500);
+      }
+    }, $scope.errorHandler);
+  }
+
+  $scope.toggleAnalyzer = function (analyzer) {
+    analyzer.show = !analyzer.show;
+  }
+
+  $scope.initTypeAnalysisInfo = function (typeName) {
+    $scope.analysis = getAnalysisInfo($scope.luke, {type: true}, typeName);
+    if ($scope.analysis && $scope.analysis.data) {
+      $scope.className = $scope.analysis.data.className
+    }
+    $scope.editAnalysis = "Edit JSON";
+    $scope.showAnalysisJson = false;
+    delete $scope.analysisJsonText;
+  };
+
+  $scope.toggleVerbose = function () {
+    $scope.analysisVerbose = !$scope.analysisVerbose;
+  };
+
+  $scope.updateSampleDocId = function () {
+    $scope.indexText = "";
+    $scope.result = {};
+
+    if (!$scope.selectedNode) {
+      return;
+    }
+
+    var field = $scope.selectedNode.name;
+    var params = {path: "sample"};
+    params.configSet = $scope.currentSchema;
+    params.uniqueKeyField = $scope.uniqueKeyField;
+    params.field = field;
+
+    if ($scope.sampleDocId) {
+      params.docId = $scope.sampleDocId;
+    } // else the server will pick the first doc with a non-empty text value for the desired field
+
+    SchemaDesigner.get(params, function (data) {
+      $scope.sampleDocId = data[$scope.uniqueKeyField];
+      $scope.indexText = data[field];
+      if (data.analysis && data.analysis["field_names"]) {
+        $scope.result = processFieldAnalysisData(data.analysis["field_names"][field]);
+      }
+    });
+  };
+
+  $scope.changeLanguages = function () {
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  function getType(typeName) {
+    if ($scope.fieldTypes) {
+      for (i in $scope.fieldTypes) {
+        if ($scope.fieldTypes[i].text === typeName) {
+          return $scope.fieldTypes[i];
+        }
+      }
+    }
+    return null;
+  }
+
+  $scope.refreshTree = function() {
+    var jst = $('#schemaJsTree').jstree(true);
+    if (jst) {
+      jst.refresh();
+    }
+  };
+
+  $scope.onSchemaTreeLoaded = function (id) {
+    //console.log(">> on tree loaded");
+  };
+
+  $scope.updateFile = function () {
+    var nodeId = "files/" + $scope.selectedFile;
+    var params = {path: "file", file: $scope.selectedFile, configSet: $scope.currentSchema};
+
+    $scope.updateWorking = true;
+    $scope.updateStatusMessage = "Updating file ...";
+
+    SchemaDesigner.post(params, $scope.fileNodeText, function (data) {
+      if (data.updateFileError) {
+        if (data[$scope.selectedFile]) {
+          $scope.fileNodeText = data[$scope.selectedFile];
+        }
+        $scope.updateFileError = data.updateFileError;
+      } else {
+        delete $scope.updateFileError;
+        $scope.updateStatusMessage = "File '"+$scope.selectedFile+"' updated.";
+        $scope.onSchemaUpdated(data.configSet, data, nodeId);
+      }
+    }, $scope.errorHandler);
+  };
+
+  $scope.onSelectFileNode = function (id, doSelectOnTree) {
+    $scope.selectedFile = id.startsWith("files/") ? id.substring("files/".length) : id;
+
+    var params = {path: "file", file: $scope.selectedFile, configSet: $scope.currentSchema};
+    SchemaDesigner.get(params, function (data) {
+      $scope.fileNodeText = data[$scope.selectedFile];
+      $scope.isLeafNode = false;
+      if (doSelectOnTree) {
+        delete $scope.selectedNode;
+        $scope.isLeafNode = false;
+        $scope.showFieldDetails = true;
+        delete $scope.sampleDocId;
+        $scope.showAnalysis = false;
+        $scope.selectNodeInTree(id);
+      }
+    });
+  };
+  
+  function fieldNodes(src, type) {
+    var nodes = [];
+    for (var c in src) {
+      var childNode = src[c];
+      if (childNode && childNode.a_attr) {
+        var a = childNode.a_attr;
+        var stored = a.stored || (a.docValues && a.useDocValuesAsStored);
+        var obj = {"name":a.name, "indexed":a.indexed, "docValues": a.docValues,
+          "multiValued":a.multiValued, "stored":stored, "tokenized": a.tokenized};
+        if (type === "field" || type === "dynamicField") {
+          obj.type = a.type;
+        } else if (type === "type") {
+          obj.class = a.class;
+        }
+        nodes.push(obj);
+      }
+    }
+    return nodes;
+  }
+
+  function stripAnchorSuffix(id) {
+    if (id && id.endsWith("_anchor")) {
+      id = id.substring(0, id.length - "_anchor".length);
+    }
+    return id;
+  }
+
+  $scope.onSelectSchemaTreeNode = function (id) {
+    id = stripAnchorSuffix(id);
+    $scope.showFieldDetails = false;
+    $scope.isSchemaRoot = false;
+    $scope.isLeafNode = false;
+    delete $scope.containerNodeLabel;
+    delete $scope.containerNode;
+    delete $scope.containerNodes;
+    delete $scope.selectedFile;
+
+    if (id === "/") {
+      $scope.selectedType = "Schema";
+      $scope.selectedNode = null;
+      $scope.isSchemaRoot = true;
+      $scope.isLeafNode = false;
+      $scope.isContainerNode = false;
+      $scope.showFieldDetails = true;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+
+      if (!$scope.treeFilter) {
+        $scope.treeFilter = "type";
+        $scope.treeFilterOption = "*";
+        $scope.initTreeFilters();
+      }
+      return;
+    }
+
+    var jst = $('#schemaJsTree').jstree(true);
+    if (!jst) {
+      return;
+    }
+
+    var node = jst.get_node(id);
+    if (!node) {
+      return;
+    }
+
+    if (id === "files") {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      return;
+    }
+
+    if (id.indexOf("/") === -1) {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      $scope.containerNode = id;
+
+      if (id === "fields") {
+        $scope.containerNodes = fieldNodes($scope.fieldsNode ? $scope.fieldsNode.children : $scope.fieldsSrc, "field");
+      } else if (id === "dynamicFields") {
+        $scope.containerNodes = fieldNodes($scope.dynamicFieldsNode ? $scope.dynamicFieldsNode.children : $scope.dynamicFieldsSrc, "dynamicField");
+      } else if (id === "fieldTypes") {
+        $scope.containerNodes = fieldNodes($scope.fieldTypes, "type");
+      }
+
+      $scope.containerNodeLabel = node.text;
+      $scope.showFieldDetails = true;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+      return;
+    }
+
+    if (id.startsWith("files/")) {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+      if (node.children.length === 0) {
+        // file
+        $scope.showFieldDetails = true;
+        $scope.onSelectFileNode(id, false);
+      } else {
+        // folder
+        $scope.showFieldDetails = false;
+        delete $scope.selectedFile;
+      }
+      return;
+    }
+
+    delete $scope.selectedFile;
+    $scope.selectedNode = node["a_attr"]; // all the info we need is in the a_attr object
+    if (!$scope.selectedNode) {
+      // a node in the tree that isn't a field was selected, just ignore
+      return;
+    }
+
+    $scope.selectedNode.fieldType = getType($scope.selectedNode.type);
+    $scope.isLeafNode = true;
+
+    var nodeType = id.substring(0, id.indexOf("/"));
+    var name = null;
+    if (nodeType === "field") {
+      $scope.selectedType = "Field";
+      name = $scope.selectedNode.type;
+    } else if (nodeType === "dynamic") {
+      $scope.selectedType = "Dynamic Field";
+    } else if (nodeType === "type") {
+      $scope.selectedType = "Type";
+      name = $scope.selectedNode.name;
+    }
+
+    if (name) {
+      $scope.initTypeAnalysisInfo(name, "type");
+    }
+
+    // apply some sanity to the checkboxes
+    $scope.selectedNode = applyConstraintsOnField($scope.selectedNode);
+    $scope.showFieldDetails = true;
+
+    if (nodeType === "field" && $scope.selectedNode.tokenized && $scope.selectedNode.stored) {
+      $scope.showAnalysis = true;
+      $scope.updateSampleDocId();
+    } else {
+      $scope.showAnalysis = false;
+      $scope.indexText = "";
+      $scope.result = {};
+    }
+  };
+
+  function addFileNode(dirs, parent, f) {
+    var path = f.split("/");
+    if (path.length === 1) {
+      if (!parent.children) {
+        parent.children = [];
+        dirs.push(parent); // now parent has children, so track in dirs ...
+      }
+      var nodeId = parent.id + "/" + f;
+      parent.children.push({"text": f, "id": nodeId, "a_attr": {"href": nodeId}});
+    } else {
+      // get the parent for this path
+      var parentId = "files/" + path.slice(0, path.length - 1).join("/");
+      var dir = null;
+      for (var d in dirs) {
+        if (dirs[d].id === parentId) {
+          dir = dirs[d];
+          break;
+        }
+      }
+      if (!dir) {
+        dir = {"text": path[0], "id": parentId, "a_attr": {"href": parentId}, "children": []};
+        dirs.push(dir);
+        parent.children.push(dir);
+      }
+
+      // walk down the next level in this path
+      addFileNode(dirs, dir, path.slice(1).join("/"));
+    }
+  }
+
+  // transform a flat list structure into the nested tree structure
+  function filesToTree(files) {
+    var filesNode = {"text": "Files", "a_attr": {"href": "files"}, "id": "files", "children": []};
+    if (files) {
+      var dirs = []; // lookup for known dirs by path since nodes don't keep a ref to their parent node
+      for (var i in files) {
+        // hide the configoverlay.json from the UI
+        if (files[i] === "configoverlay.json") {
+          continue;
+        }
+
+        addFileNode(dirs, filesNode, files[i]);
+      }
+      delete dirs;
+    }
+    return filesNode;
+  }
+
+  function fieldsToTree(fields) {
+    var children = [];
+    if (fields) {
+      for (var i in fields) {
+        var id = "field/" + fields[i].name;
+        fields[i].href = id;
+        var text = fields[i].name;
+        if (fields[i].name === $scope.uniqueKeyField) {
+          text += "*"; // unique key field
+        }
+        children.push({"text": text, "a_attr": fields[i], "id": id});
+      }
+    }
+    return children;
+  }
+
+  function fieldTypesToTree(types) {
+    var children = [];
+    for (var i in types) {
+      var ft = types[i]
+      var id = "type/" + ft.name;
+      ft.href = id;
+      children.push({"text": ft.name, "a_attr": ft, "id": id});
+    }
+    return children;
+  }
+
+  $scope.onSampleDocumentsChanged = function () {
+    $scope.hasDocsOnServer = false; // so the updates get sent on next analyze action
+  };
+
+  $scope.initDesignerSettingsFromResponse = function (data) {
+    $scope.enableDynamicFields = data.enableDynamicFields !== null ? "" + data.enableDynamicFields : "true";
+    $scope.enableFieldGuessing = data.enableFieldGuessing !== null ? "" + data.enableFieldGuessing : "true";
+    $scope.enableNestedDocs = data.enableNestedDocs !== null ? "" + data.enableNestedDocs : "false";
+    $scope.languages = data.languages !== null && data.languages.length > 0 ? data.languages : ["*"];
+    $scope.copyFrom = data.copyFrom !== null ? data.copyFrom : "_default";
+  };
+
+  $scope.doAnalyze = function (nodeId) {
+    delete $scope.sampleMessage;
+
+    var schema = $scope.currentSchema;
+    if (schema) {
+      delete $scope.copyFrom;
+    } else {
+      schema = $scope.newSchema;
+      if (!$scope.copyFrom) {
+        $scope.copyFrom = "_default";
+      }
+    }
+
+    if (!schema) {
+      return;
+    }
+
+    var params = {path: "analyze", configSet: schema};
+    if ($scope.schemaVersion && $scope.schemaVersion !== -1) {
+      params.schemaVersion = $scope.schemaVersion;
+    }
+
+    if ($scope.enableDynamicFields) {
+      params.enableDynamicFields = $scope.enableDynamicFields;
+    }
+    if ($scope.enableFieldGuessing) {
+      params.enableFieldGuessing = $scope.enableFieldGuessing;
+    }
+    if ($scope.enableNestedDocs) {
+      params.enableNestedDocs = $scope.enableNestedDocs;
+    }
+
+    if ($scope.languages && $scope.languages.length > 0) {
+      params.languages = $scope.languages;
+    }
+
+    if ($scope.copyFrom) {
+      params.copyFrom = $scope.copyFrom;
+    }
+
+    $scope.updateWorking = true;
+    if ($scope.selectedUpdated) {
+      $scope.updateStatusMessage = "Applying " + $scope.selectedType + " updates ..."
+    } else {
+      $scope.updateStatusMessage = "Analyzing your sample data, schema will load momentarily ..."
+    }
+
+    if (!nodeId && $scope.selectedNode) {
+      nodeId = $scope.selectedNode.id;
+    }
+
+    // a bit tricky ...
+    // so users can upload a file or paste in docs
+    // if they upload a file containing a small (<15K) of text data, then we'll show it in the textarea
+    // they can change the text and re-analyze too
+    // if no changes or nothing uploaded, the server-side uses the latest sample data stored in the blob store
+    if ($scope.fileUpload) {
+      var file = $scope.fileUpload;
+      var fd = new FormData();
+      fd.append('file', file);
+      SchemaDesigner.upload(params, fd, function (data) {
+        $("#upload-file").val("");
+        delete $scope.fileUpload;
+        $scope.onSchemaUpdated(schema, data, nodeId);
+      }, $scope.errorHandler);
+    } else {
+      // don't need to keep re-posting the same sample if already stored in the blob store
+      var postData = null;
+      if (!$scope.hasDocsOnServer) {
+        postData = $scope.sampleDocuments;
+        if (!postData && !$scope.published) {
+          return;
+        }
+      }
+
+      var respHandler = function (data) {
+        $scope.onSchemaUpdated(schema, data, nodeId);
+      };
+
+      // TODO: need a better approach to detecting the content type from text content
+      var contentType = "text/plain";
+      if (postData != null) {
+        var txt = postData.trim();
+        if ((txt.startsWith("[") && txt.includes("]")) || (txt.startsWith("{") && txt.includes("}"))) {
+          contentType = "application/json"
+        } else if (txt.startsWith("<") || txt.includes("<add>") || txt.includes("<!--")) {
+          contentType = "text/xml";
+        } else {
+          contentType = "application/csv";
+        }
+      }
+
+      if (contentType === "text/xml") {
+        SchemaDesigner.postXml(params, postData, respHandler, $scope.errorHandler);
+      } else if (contentType === "application/csv") {
+        SchemaDesigner.postCsv(params, postData, respHandler, $scope.errorHandler);
+      } else {
+        SchemaDesigner.post(params, postData, respHandler, $scope.errorHandler);
+      }
+    }
+  };
+
+  $scope.onFieldTypeChanged = function () {
+
+    var copyFromType = ["stored", "indexed", "multiValued", "docValues", "useDocValuesAsStored", "tokenized", "uninvertible", "termVectors", "termPositions", "termOffsets",
+      "omitNorms", "omitTermFreqAndPositions", "omitPositions", "storeOffsetsWithPositions"];
+
+    var type = $scope.selectedNode.type
+
+    // when the user updates the selected field's type, we go apply the
+    // new field type's properties to the selected node
+    for (var i in $scope.fieldTypes) {
+      if ($scope.fieldTypes[i].text == type) {
+        var ft = $scope.fieldTypes[i];
+        $scope.selectedNode.fieldType = ft;
+        for (var i in copyFromType) {
+          var x = copyFromType[i];
+          if (ft.a_attr[x] !== null) {
+            $scope.selectedNode[x] = ft.a_attr[x];
+          } else {
+            delete $scope.selectedNode[x];
+          }
+        }
+        $scope.selectedUpdated = true;
+        break;
+      }
+    }
+
+    $scope.selectedNode = applyConstraintsOnField($scope.selectedNode);
+    if ($scope.selectedUpdated) {
+      // for luke analysis, we need the type info here, not the specific field into b/c it just changed.
+      $scope.initTypeAnalysisInfo(type, "type");
+    }
+  };
+
+  $scope.isDisabled = function (dep) {
+    if (!$scope.selectedNode) {
+      return false;
+    }
+
+    if (dep === "termVectors") {
+      // termVectors dependency depends on indexed
+      return !($scope.selectedNode.indexed && $scope.selectedNode.termVectors);
+    }
+
+    if (dep === "not-text" || dep === "docValues") {
+      if ($scope.selectedNode.fieldType && $scope.selectedNode.fieldType.a_attr.class === "solr.TextField") {
+        // no doc values for TextField
+        return true;
+      }
+    }
+
+    return $scope.selectedNode[dep] === false;
+  };
+
+  // this updates checkboxes based on the current settings
+  $scope.markSelectedUpdated = function (event) {
+    delete $scope.updateStatusMessage;
+    $scope.selectedUpdated = true; // enable the update button for this field
+  };
+
+  $scope.deleteSelected = function () {
+    // console.log(">> deleteSelected");
+  };
+
+  $scope.updateSelected = function () {
+    if (!$scope.selectedNode) {
+
+      if ($scope.selectedUpdated) {
+        // some root level property changed ... re-analyze
+        $scope.doAnalyze("/");
+      }
+
+      return;
+    }
+
+    delete $scope.updateSelectedError;
+
+    // make a copy for the PUT
+    var href = $scope.selectedNode.href;
+    var id = $scope.selectedNode.id;
+
+    var putData = JSON.parse(JSON.stringify($scope.selectedNode));
+    if ($scope.selectedType === "Field" && putData.copyDest) {
+      var fields = putData.copyDest.split(",");
+      for (var f in fields) {
+        var name = fields[f].trim();
+        if (!$scope.fieldNames.includes(name)) {
+          $scope.updateSelectedError = "Copy to field '" + name + "' doesn't exist!";
+          return;
+        }
+        if (name === $scope.selectedNode.name) {
+          $scope.updateSelectedError = "Cannot copy a field to itself!";
+          return;
+        }
+      }
+    } else {
+      delete putData.copyDest;
+    }
+
+    delete putData.fieldType;
+    delete putData.href;
+    delete putData.id;
+
+    putData = applyConstraintsOnField(putData);
+
+    if ($scope.analysisJsonText && !$scope.selectedNode.type) {
+      var text = $scope.analysisJsonText.trim();
+      if (text.length > 0) {
+        text = text.replace(/\s+/g, ' ');
+        if (!text.startsWith("{")) {
+          text = "{ " + text + " }";
+        }
+        try {
+          var textJson = JSON.parse(text);
+          if (textJson.analyzer) {
+            putData.analyzer = textJson.analyzer;
+          } else {
+            if (textJson.indexAnalyzer && textJson.queryAnalyzer) {
+              putData.indexAnalyzer = textJson.indexAnalyzer;
+              putData.queryAnalyzer = textJson.queryAnalyzer;
+            }
+          }
+        } catch (e) {
+          $scope.updateSelectedError = "Failed to parse analysis as JSON due to: " + e.message +
+              "; expected JSON object with either an 'analyzer' or 'indexAnalyzer' and 'queryAnalyzer'";
+          return;
+        }
+      }
+    }
+
+    $scope.updateWorking = true;
+    $scope.updateStatusMessage = "Updating " + $scope.selectedType + " ...";
+
+    SchemaDesigner.put({
+      path: "update",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, putData, function (data) {
+
+      var nodeType = data.updateType;
+      $scope.schemaVersion = data.schemaVersion;
+      $scope.currentSchema = data.configSet;
+      $scope.core = data.core;
+
+      $scope.selectedNode = data[nodeType];
+      $scope.selectedNode.href = href;
+      $scope.selectedNode.id = id;
+
+      var name = nodeType === "field" ? $scope.selectedNode.type : $scope.selectedNode.name;
+      $scope.initTypeAnalysisInfo(name, "type");
+      $scope.showFieldDetails = true;
+
+      if (nodeType === "field" && $scope.selectedNode.tokenized) {
+        $scope.showAnalysis = true;
+        $scope.updateSampleDocId();
+      }
+
+      $scope.onSchemaUpdated($scope.currentSchema, data, href);
+    }, $scope.errorHandler);
+  };
+
+  // TODO: These are copied from analysis.js, so move to a shared location for both vs. duplicating
+  var getShortComponentName = function (longname) {
+    var short = -1 !== longname.indexOf('$')
+        ? longname.split('$')[1]
+        : longname.split('.').pop();
+    return short.match(/[A-Z]/g).join('');
+  };
+
+  var getCaptionsForComponent = function (data) {
+    var captions = [];
+    for (var key in data[0]) {
+      key = key.replace(/.*#/, '');
+      if (key != "match" && key != "positionHistory") {
+        captions.push(key.replace(/.*#/, ''));
+      }
+    }
+    return captions;
+  };
+
+  var getTokensForComponent = function (data) {
+    var tokens = [];
+    var previousPosition = 0;
+    var index = 0;
+    for (var i in data) {
+      var tokenhash = data[i];
+      var positionDifference = tokenhash.position - previousPosition;
+      for (var j = positionDifference; j > 1; j--) {
+        tokens.push({position: tokenhash.position - j + 1, blank: true, index: index++});
+      }
+
+      var token = {position: tokenhash.position, keys: [], index: index++};
+
+      for (key in tokenhash) {
+        if (key == "match" || key == "positionHistory") {
+          //skip, to not display these keys in the UI
+        } else {
+          var tokenInfo = new Object();

Review comment:
       *W010:*  The object literal notation {} is preferable.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/schema-designer.js
##########
@@ -0,0 +1,2014 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $cookies, $window, Constants, SchemaDesigner, Luke) {
+  $scope.resetMenu("schema-designer", Constants.IS_ROOT_PAGE);
+
+  $scope.onWarning = function (warnMsg, warnDetails) {
+    $scope.updateWorking = false;
+    delete $scope.updateStatusMessage;
+    $scope.apiWarning = warnMsg;
+    $scope.apiWarningDetails = warnDetails;
+  };
+  
+  $scope.onError = function (errorMsg, errorCode, errorDetails) {
+    $scope.updateWorking = false;
+    delete $scope.updateStatusMessage;
+    $scope.designerAPIError = errorMsg;
+    if (errorDetails) {
+      var errorDetailsStr = "";
+      if (errorDetails["error"]) {
+        errorDetailsStr = errorDetails["error"];
+      } else {
+        for (var id in errorDetails) {
+          var msg = errorDetails[id];
+          var at = msg.indexOf("ERROR: ");
+          if (at !== -1) {
+            msg = msg.substring(at+7);
+          }
+          if (!msg.includes(id)) {
+            msg = id+": "+msg;
+          }
+          errorDetailsStr += msg+"\n\n";
+        }
+      }
+      $scope.designerAPIErrorDetails = errorDetailsStr;
+    } else {
+      delete $scope.designerAPIErrorDetails;
+    }
+
+    if (errorCode === 409) {
+      $scope.schemaVersion = -1; // reset to get the latest
+      $scope.isVersionMismatch = true;
+      $scope.errorButton = "Reload Schema";
+    } else if (errorCode < 500) {
+      $scope.isVersionMismatch = false;
+      $scope.errorButton = "OK";
+    } // else 500 errors get the top-level error message
+  };
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error) {
+      $scope.onError(error.msg, error.code, e.data.errorDetails);
+    } else {
+      // when a timeout occurs, the error details are sparse so just give the user a hint that something was off
+      var path = e.config && e.config.url ? e.config.url : "/api/schema-designer";
+      var reloadMsg = "";
+      if (path.includes("/analyze")) {
+        reloadMsg = " Re-try analyzing your sample docs by clicking on 'Analyze Documents' again."
+      }
+      $scope.onError("Request to "+path+" failed!", 408,
+          {"error":"Most likely the request timed out; check server log for more details."+reloadMsg});
+    }
+  };
+
+  $scope.closeWarnDialog = function () {
+    delete $scope.apiWarning;
+    delete $scope.apiWarningDetails;
+  };
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.designerAPIError;
+    delete $scope.designerAPIErrorDetails;
+    if ($scope.isVersionMismatch) {
+      $scope.isVersionMismatch = false;
+      var nodeId = "/";
+      if ($scope.selectedNode) {
+        nodeId = $scope.selectedNode.href;
+      }
+      $scope.doAnalyze(nodeId);
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.isSchemaDesignerEnabled = true;
+
+    delete $scope.helpId;
+
+    $scope.updateStatusMessage = "";
+    $scope.analysisVerbose = false;
+    $scope.updateWorking = false;
+    $scope.currentSchema = "";
+
+    delete $scope.hasDocsOnServer;
+    delete $scope.queryResultsTree;
+
+    $scope.languages = ["*"];
+    $scope.copyFrom = "_default";
+    delete $scope.sampleMessage;
+    delete $scope.sampleDocuments;
+
+    $scope.schemaVersion = -1;
+    $scope.schemaTree = {};
+    $scope.showSchemaActions = false;
+    $scope.sampleDocIds = [];
+    $scope.isSchemaRoot = false;
+
+    delete $scope.enableNestedDocs;
+    delete $scope.enableDynamicFields;
+    delete $scope.enableFieldGuessing;
+
+    // schema editor
+    $scope.showFieldDetails = false;
+    $scope.selectedNode = null;
+    $scope.selectedUpdated = false;
+    delete $scope.updateStatusMessage;
+
+    // text field analysis
+    $scope.showAnalysis = false;
+    $scope.sampleDocId = null;
+    $scope.indexText = "";
+    $scope.result = {};
+
+    // publish vars
+    delete $scope.newCollection;
+    $scope.reloadOnPublish = "true";
+
+    // query form
+    $scope.query = {q: '*:*', sortBy: 'score', sortDir: 'desc'};
+
+    SchemaDesigner.get({path: "configs"}, function (data) {
+
+      $scope.schemas = [];
+      $scope.publishedSchemas = ["_default"];
+
+      for (var s in data.configSets) {
+        // 1 means published but not editable
+        if (data.configSets[s] !== 1) {
+          $scope.schemas.push(s);
+        }
+
+        // 0 means not published yet (so can't copy from it yet)
+        if (data.configSets[s] > 0) {
+          $scope.publishedSchemas.push(s);
+        }
+      }
+
+      $scope.schemas.sort();
+      $scope.publishedSchemas.sort();
+
+      // if no schemas available to select, open the pop-up immediately
+      if ($scope.schemas.length === 0) {
+        $scope.firstSchemaMessage = true;
+        $scope.showNewSchemaDialog();
+      }
+    }, function(e) {
+      if (e.status === 401 || e.status === 403) {
+        $scope.isSchemaDesignerEnabled = false;
+        $scope.hideAll();
+      }
+    });
+  };
+
+  $scope.selectNodeInTree = function(nodeId) {
+    nodeId = stripAnchorSuffix(nodeId);
+    if (!nodeId) return;
+
+    var jst = $('#schemaJsTree').jstree(true);
+    if (jst) {
+      var selectedId = null;
+      var selected_node = jst.get_selected();
+      if (selected_node && selected_node.length > 0) {
+        selectedId = selected_node[0];
+      }
+      if (selectedId) {
+        try {
+          jst.deselect_node(selectedId);
+        } catch (err) {
+          // just ignore
+          //console.log("error deselecting "+selectedId);
+        }
+      }
+
+      try {
+        jst.select_node(nodeId, true);
+      } catch (err) {
+        // just ignore, some low-level tree issue
+        //console.log("error selecting "+nodeId);
+      }
+    }
+  };
+
+  $scope.loadFile = function (event) {
+    var t = event.target || event.srcElement || event.currentTarget;
+    if (t && t.text) {
+      $scope.onSelectFileNode("files/" + t.text, true);
+    }
+  };
+
+  $scope.confirmEditSchema = function () {
+    $scope.showConfirmEditSchema = false;
+    if ($scope.hasDocsOnServer || $scope.published) {
+      $scope.doAnalyze();
+    } else {
+      $scope.sampleMessage = "Please upload or paste some sample documents to analyze for building the '" + $scope.currentSchema + "' schema.";
+    }
+  };
+
+  $scope.cancelEditSchema = function () {
+    $scope.currentSchema = "";
+    $scope.showConfirmEditSchema = false;
+  };
+
+  $scope.loadSchema = function () {
+
+    if (!$scope.currentSchema) {
+      return;
+    }
+
+    $scope.resetSchema();
+    var params = {path: "info", configSet: $scope.currentSchema};
+    SchemaDesigner.get(params, function (data) {
+      $scope.currentSchema = data.configSet;
+      $("#select-schema").trigger("chosen:updated");
+
+      $scope.confirmSchema = data.configSet;
+      $scope.collectionsForConfig = data.collections;
+      $scope.hasDocsOnServer = data.numDocs > 0;
+      $scope.published = data.published;
+      $scope.initDesignerSettingsFromResponse(data);
+      if ($scope.collectionsForConfig && $scope.collectionsForConfig.length > 0) {
+        $scope.showConfirmEditSchema = true;
+      } else {
+        if ($scope.hasDocsOnServer || $scope.published) {
+          $scope.doAnalyze();
+        } else {
+          $scope.sampleMessage = "Please upload or paste some sample documents to build the '" + $scope.currentSchema + "' schema.";
+        }
+      }
+    });
+  };
+
+  $scope.showNewSchemaDialog = function () {
+    $scope.hideAll();
+    $scope.showNewSchema = true;
+    $scope.newSchema = "";
+  };
+
+  $scope.addSchema = function () {
+    $scope.firstSchemaMessage = false;
+    delete $scope.addMessage;
+
+    if (!$scope.newSchema) {
+      $scope.addMessage = "Please provide a schema name!";
+      return;
+    }
+
+    $scope.newSchema = $scope.newSchema.trim();
+    if ($scope.newSchema.length > 50) {
+      $scope.addMessage = "Schema name be 50 characters or less";
+      return;
+    }
+
+    if ($scope.newSchema.indexOf(" ") !== -1 || $scope.newSchema.indexOf("/") !== -1) {
+      $scope.addMessage = "Schema name should not contain spaces or /";
+      return;
+    }
+
+    if ($scope.publishedSchemas.includes($scope.newSchema) || $scope.schemas.includes($scope.newSchema)) {
+      $scope.addMessage = "Schema '" + $scope.newSchema + "' already exists!";
+      return;
+    }
+
+    delete $scope.addMessage;
+    if (!$scope.copyFrom) {
+      $scope.copyFrom = "_default";
+    }
+
+    $scope.resetSchema();
+    $scope.schemas.push($scope.newSchema);
+    $scope.showNewSchema = false;
+    $scope.currentSchema = $scope.newSchema;
+    $scope.sampleMessage = "Please upload or paste some sample documents to analyze for building the '" + $scope.currentSchema + "' schema.";
+
+    SchemaDesigner.post({path: "prep", configSet: $scope.newSchema, copyFrom: $scope.copyFrom}, null, function (data) {
+      $scope.initDesignerSettingsFromResponse(data);
+    }, $scope.errorHandler);
+  };
+
+  $scope.cancelAddSchema = function () {
+    delete $scope.addMessage;
+    delete $scope.sampleMessage;
+
+    $scope.showNewSchema = false
+  };
+
+  $scope.hideAll = function () {
+    delete $scope.helpId;
+    $scope.showPublish = false;
+    $scope.showDiff = false;
+    $scope.showNewSchema = false;
+    $scope.showAddField = false;
+    $scope.showAddDynamicField = false;
+    $scope.showAddCopyField = false;
+    $scope.showAnalysis = false;
+    // add more dialogs here
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.hideData = function () {
+    $scope.showData = false;
+  };
+
+  $scope.rootChanged = function () {
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  $scope.updateUniqueKey = function () {
+    delete $scope.schemaRootMessage;
+    var jst = $('#schemaJsTree').jstree();
+    if (jst) {
+      var node = jst.get_node("field/" + $scope.updateUniqueKeyField);
+      if (node && node.a_attr) {
+        var attrs = node.a_attr;
+        if (attrs.multiValued || attrs.tokenized || !attrs.stored || !attrs.indexed) {
+          $scope.schemaRootMessage = "Field '" + $scope.updateUniqueKeyField +
+              "' cannot be used as the uniqueKey field! Must be single-valued, stored, indexed, and not tokenized.";
+          $scope.updateUniqueKeyField = $scope.uniqueKeyField;
+          return;
+        }
+      }
+    }
+    $scope.uniqueKeyField = $scope.updateUniqueKeyField;
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  $scope.resetSchema = function () {
+    $scope.hideAll();
+    $scope.analysisVerbose = false;
+    $scope.showSchemaActions = false;
+    $scope.showAnalysis = false;
+    $scope.showFieldDetails = false;
+    $scope.hasDocsOnServer = false;
+    $scope.query = {q: '*:*', sortBy: 'score', sortDir: 'desc'};
+    $scope.schemaVersion = -1;
+
+    $scope.updateWorking = false;
+    $scope.isVersionMismatch = false;
+    delete $scope.updateStatusMessage;
+    delete $scope.designerAPIError;
+    delete $scope.designerAPIErrorDetails;
+    delete $scope.selectedFacets;
+    delete $scope.sampleDocuments;
+    delete $scope.selectedNode;
+    delete $scope.queryResultsTree;
+  };
+
+  $scope.onSchemaUpdated = function (schema, data, nodeId) {
+    $scope.hasDocsOnServer = data.numDocs && data.numDocs > 0;
+    $scope.uniqueKeyField = data.uniqueKeyField;
+    $scope.updateUniqueKeyField = $scope.uniqueKeyField;
+    $scope.initDesignerSettingsFromResponse(data);
+
+    var fieldTypes = fieldTypesToTree(data.fieldTypes);
+    var files = filesToTree(data.files);
+
+    var rootChildren = [];
+    $scope.fieldsSrc = fieldsToTree(data.fields);
+    $scope.fieldsNode = {
+      "id": "fields",
+      "text": "Fields",
+      "state": {"opened": true},
+      "a_attr": {"href": "fields"},
+      "children": $scope.fieldsSrc
+    };
+    rootChildren.push($scope.fieldsNode);
+
+    if ($scope.enableDynamicFields === "true") {
+      $scope.dynamicFieldsSrc = fieldsToTree(data.dynamicFields);
+      $scope.dynamicFieldsNode = {
+        "id": "dynamicFields",
+        "text": "Dynamic Fields",
+        "a_attr": {"href": "dynamicFields"},
+        "children": $scope.dynamicFieldsSrc
+      };
+      rootChildren.push($scope.dynamicFieldsNode);
+    } else {
+      delete $scope.dynamicFieldsNode;
+      delete $scope.dynamicFieldsSrc;
+    }
+
+    rootChildren.push({"id":"fieldTypes", "text": "Field Types", "a_attr": {"href": "fieldTypes"}, "children": fieldTypes});
+    rootChildren.push(files);
+
+    var tree = [{"id":"/", "text": schema, "a_attr": {"href": "/"}, "state":{"opened": true}, "children": rootChildren}];
+
+    $scope.fields = data.fields;
+    $scope.fieldNames = data.fields.map(f => f.name).sort();
+    $scope.possibleIdFields = data.fields.filter(f => f.indexed && f.stored && !f.tokenized).map(f => f.name).sort();
+    $scope.sortableFields = data.fields.filter(f => (f.indexed || f.docValues) && !f.multiValued && !f.tokenized).map(f => f.name).sort();
+    $scope.sortableFields.push("score");
+
+    $scope.facetFields = data.fields.filter(f => (f.indexed || f.docValues) && !f.tokenized && f.name !== '_version_').map(f => f.name).sort();
+    $scope.hlFields = data.fields.filter(f => f.stored && f.tokenized).map(f => f.name).sort();
+
+    $scope.schemaVersion = data.schemaVersion;
+    $scope.currentSchema = data.configSet;
+    $scope.fieldTypes = fieldTypes;
+    $scope.core = data.core;
+    $scope.schemaTree = tree;
+    $scope.refreshTree();
+
+    $scope.collectionsForConfig = data.collectionsForConfig;
+
+    if (data.docIds) {
+      $scope.sampleDocIds = data.docIds;
+    }
+
+    // re-apply the filters on the updated schema
+    $scope.onTreeFilterOptionChanged();
+
+    // Load the Luke schema
+    Luke.schema({core: data.core}, function (schema) {
+      Luke.raw({core: data.core}, function (index) {
+        $scope.luke = mergeIndexAndSchemaData(index, schema.schema);
+        $scope.types = Object.keys(schema.schema.types);
+        $scope.showSchemaActions = true;
+        if (!nodeId) {
+          nodeId = "/";
+        }
+        $scope.onSelectSchemaTreeNode(nodeId);
+
+        $scope.updateWorking = false;
+
+        if (data.updateError != null) {
+          $scope.onError(data.updateError, data.updateErrorCode, data.errorDetails);
+        } else {
+          if ($scope.selectedUpdated) {
+            $scope.selectedUpdated = false;
+            $scope.updateStatusMessage = "Changes applied successfully.";
+            var waitMs = 3000;
+            if (data.rebuild) {
+              $scope.updateStatusMessage += " Did full re-index of sample docs due to incompatible update.";
+              waitMs = 5000; // longer message, more time to read
+            }
+
+            if (data.analysisError) {
+              var updateType = data["updateType"];
+              var updatedObject = data[updateType];
+              var updatedName = updatedObject && updatedObject.name ? updatedObject.name : "";
+              var warnMsg = "Changes to "+updateType+" "+updatedName+" applied, but required the temp collection to be deleted " +
+                  "and re-created due to an incompatible Lucene change, see details below.";
+              $scope.onWarning(warnMsg, data.analysisError);
+            } else {
+              $timeout(function () { delete $scope.updateStatusMessage; }, waitMs);
+            }
+          } else {
+            var source = data.sampleSource;
+            if (source) {
+              if (source === "paste") {
+                source = "pasted sample"
+              } else if (source === "blob") {
+                source = "previous upload stored on the server"
+              }
+              if (data.numDocs > 0) {
+                $scope.updateStatusMessage = "Analyzed "+data.numDocs+" docs from "+source;
+              } else {
+                $scope.updateStatusMessage = "Schema '"+$scope.currentSchema+"' loaded.";
+              }
+            }
+            $timeout(function () {
+              delete $scope.updateStatusMessage;
+            }, 5000);
+          }
+        }
+
+        // re-fire the current query to reflect the updated schema
+        $scope.doQuery();
+        $scope.selectNodeInTree(nodeId);
+      });
+    });
+  };
+
+  $scope.toggleAddField = function (type) {
+    if ($scope.showAddField) {
+      $scope.hideAll();
+    } else {
+      $scope.hideAll();
+      $scope.showAddField = true;
+      $scope.adding = type;
+
+      $scope.newField = {
+        stored: "true",
+        indexed: "true",
+        uninvertible: "true",
+        docValues: "true"
+      }
+
+      if (type === "field") {
+        $scope.newField.type = "string";
+      }
+
+      delete $scope.addErrors;
+    }
+  };
+
+  function applyConstraintsOnField(f) {
+
+    if (!f.docValues) {
+      delete f.useDocValuesAsStored;
+    }
+
+    if (!f.docValues && !f.uninvertible) {
+      delete f.sortMissingLast; // remove this setting if no docValues / uninvertible
+    }
+
+    if (f.indexed) {
+      if (f.omitTermFreqAndPositions && !f.omitPositions) {
+        delete f.omitPositions; // :shrug ~ see SchemaField ln 295
+      }
+      if (!f.termVectors) {
+        delete f.termPositions;
+        delete f.termOffsets;
+        delete f.termPayloads;
+      }
+    } else {
+      // if not indexed, a bunch of fields are false
+      f.tokenized = false;
+      f.uninvertible = false;
+
+      // drop these from the request
+      delete f.termVectors;
+      delete f.termPositions;
+      delete f.termOffsets;
+      delete f.termPayloads;
+      delete f.omitNorms;
+      delete f.omitPositions;
+      delete f.omitTermFreqAndPositions;
+      delete f.storeOffsetsWithPositions;
+    }
+
+    return f;
+  }
+
+  $scope.addField = function () {
+
+    // validate the form input
+    $scope.addErrors = [];
+    if (!$scope.newField.name) {
+      $scope.addErrors.push($scope.adding + " name is required!");
+    }
+
+    if ($scope.newField.name.indexOf(" ") != -1) {
+      $scope.addErrors.push($scope.adding + " name should not have whitespace");
+    }
+
+    var command = "add-field-type";
+    if ("field" === $scope.adding) {
+
+      if ($scope.fieldNames.includes($scope.newField.name)) {
+        $scope.addErrors.push("Field '" + $scope.newField.name + "' already exists!");
+        return;
+      }
+
+      // TODO: is this the correct logic for detecting dynamic? Probably good enough for the designer
+      var isDynamic = $scope.newField.name.startsWith("*") || $scope.newField.name.endsWith("*");
+      if (isDynamic) {
+        if ($scope.luke && $scope.luke.dynamic_fields[$scope.newField.name]) {
+          $scope.addErrors.push("dynamic field '" + $scope.newField.name + "' already exists!");
+        }
+      } else {
+        if ($scope.luke && $scope.luke.fields[$scope.newField.name]) {
+          $scope.addErrors.push("field '" + $scope.newField.name + "' already exists!");
+        }
+      }
+
+      if (!$scope.newField.type) {
+        $scope.addErrors.push("field type is required!");
+      }
+
+      command = isDynamic ? "add-dynamic-field" : "add-field";
+    } else if ("type" === $scope.adding) {
+      if ($scope.types.includes($scope.newField.name)) {
+        $scope.addErrors.push("Type '" + $scope.newField.name + "' already exists!");
+      }
+
+      if (!$scope.newField.class) {
+        $scope.addErrors.push("class is required when creating a new field type!");
+      }
+    }
+
+    var addData = {};
+    addData[command] = applyConstraintsOnField($scope.newField);
+    if ($scope.textAnalysisJson) {
+      var text = $scope.textAnalysisJson.trim();
+      if (text.length > 0) {
+        text = text.replace(/\s+/g, ' ');
+        if (!text.startsWith("{")) {
+          text = "{ " + text + " }";
+        }
+        try {
+          var textJson = JSON.parse(text);
+          if (textJson.analyzer) {
+            addData[command].analyzer = textJson.analyzer;
+          } else {
+            if (!textJson.indexAnalyzer || !textJson.queryAnalyzer) {
+              $scope.addErrors.push("Text analysis JSON should define either an 'analyzer' or an 'indexAnalyzer' and 'queryAnalyzer'");
+              return;
+            }
+            addData[command].indexAnalyzer = textJson.indexAnalyzer;
+            addData[command].queryAnalyzer = textJson.queryAnalyzer;
+          }
+        } catch (e) {
+          $scope.addErrors.push("Failed to parse analysis as JSON due to: " + e.message +
+              "; expected JSON object with either an 'analyzer' or 'indexAnalyzer' and 'queryAnalyzer'");
+          return;
+        }
+      }
+    }
+
+    if ($scope.addErrors.length > 0) {
+      return;
+    }
+    delete $scope.addErrors; // no errors!
+
+    SchemaDesigner.post({
+      path: "add",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, addData, function (data) {
+      if (data.errors) {
+        $scope.addErrors = data.errors[0].errorMessages;
+        if (typeof $scope.addErrors === "string") {
+          $scope.addErrors = [$scope.addErrors];
+        }
+      } else {
+        delete $scope.textAnalysisJson;
+        $scope.added = true;
+        $timeout(function () {
+          $scope.showAddField = false;
+          $scope.added = false;
+          var nodeId = "/";
+          if ("field" === $scope.adding) {
+            nodeId = "field/" + data[command];
+          } else if ("type" === $scope.adding) {
+            nodeId = "type/" + data[command];
+          }
+          $scope.onSchemaUpdated(data.configSet, data, nodeId);
+        }, 500);
+      }
+    }, $scope.errorHandler);
+  }
+
+  function toSortedNameAndTypeList(fields, typeAttr) {
+    var list = [];
+    var keys = Object.keys(fields);
+    for (var f in keys) {
+      var field = fields[keys[f]];
+      var type = field[typeAttr];
+      if (type) {
+        list.push(field.name + ": "+type);
+      } else {
+        list.push(field.name);
+      }
+    }
+    return list.sort();
+  }
+
+  function toSortedFieldList(fields) {
+    var list = [];
+    var keys = Object.keys(fields);
+    for (var f in keys) {
+      list.push(fields[keys[f]]);
+    }
+    return list.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  $scope.toggleDiff = function (event) {
+    if ($scope.showDiff) {
+      // toggle, close dialog
+      $scope.showDiff = false;
+      return;
+    }
+
+    if (event) {
+      var t = event.target || event.currentTarget;
+      var leftPos = t.getBoundingClientRect().left - 600;
+      if (leftPos < 0) leftPos = 0;
+      $('#show-diff-dialog').css({left: leftPos});
+    }
+
+    SchemaDesigner.get({ path: "diff", configSet: $scope.currentSchema }, function (data) {
+      var diff = data.diff;
+
+      var dynamicFields = diff.dynamicFields;
+      var enableDynamicFields = data.enableDynamicFields !== null ? data.enableDynamicFields : true;
+      if (!enableDynamicFields) {
+        dynamicFields = null;
+      }
+
+      $scope.diffSource = data["diff-source"];
+      $scope.schemaDiff = {
+        "fieldsDiff": diff.fields,
+        "addedFields": [],
+        "removedFields": [],
+        "fieldTypesDiff": diff.fieldTypes,
+        "removedTypes": [],
+        "dynamicFieldsDiff": dynamicFields,
+        "copyFieldsDiff": diff.copyFields
+      }
+      if (diff.fields && diff.fields.added) {
+        $scope.schemaDiff.addedFields = toSortedFieldList(diff.fields.added);
+      }
+      if (diff.fields && diff.fields.removed) {
+        $scope.schemaDiff.removedFields = toSortedNameAndTypeList(diff.fields.removed, "type");
+      }
+      if (diff.fieldTypes && diff.fieldTypes.removed) {
+        $scope.schemaDiff.removedTypes = toSortedNameAndTypeList(diff.fieldTypes.removed, "class");
+      }
+
+      $scope.schemaDiffExists = !(diff.fields == null && diff.fieldTypes == null && dynamicFields == null && diff.copyFields == null);
+      $scope.showDiff = true;
+    });
+  }
+
+  $scope.togglePublish = function (event) {
+    if (event) {
+      var t = event.target || event.currentTarget;
+      var leftPos = t.getBoundingClientRect().left - 515;
+      if (leftPos < 0) leftPos = 0;
+      $('#publish-dialog').css({left: leftPos});
+    }
+
+    $scope.showDiff = false;
+    $scope.showPublish = !$scope.showPublish;
+    delete $scope.publishErrors;
+
+    $scope.disableDesigner = "false";
+
+    if ($scope.showPublish && !$scope.newCollection) {
+      $scope.newCollection = {numShards: 1, replicationFactor: 1, indexToCollection: "true"};
+    }
+  };
+
+  $scope.toggleAddCopyField = function () {
+    if ($scope.showAddCopyField) {
+      $scope.hideAll();
+      $scope.showFieldDetails = true;
+    } else {
+      $scope.hideAll();
+      $scope.showAddCopyField = true;
+      $scope.showFieldDetails = false;
+
+      $scope.copyField = {};
+      delete $scope.addCopyFieldErrors;
+    }
+  }
+  $scope.addCopyField = function () {
+    delete $scope.addCopyFieldErrors;
+    var data = {"add-copy-field": $scope.copyField};
+    SchemaDesigner.post({
+      path: "add",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, data, function (data) {
+      if (data.errors) {
+        $scope.addCopyFieldErrors = data.errors[0].errorMessages;
+        if (typeof $scope.addCopyFieldErrors === "string") {
+          $scope.addCopyFieldErrors = [$scope.addCopyFieldErrors];
+        }
+      } else {
+        $scope.showAddCopyField = false;
+        // TODO:
+        //$timeout($scope.refresh, 1500);
+      }
+    }, $scope.errorHandler);
+  }
+
+  $scope.toggleAnalyzer = function (analyzer) {
+    analyzer.show = !analyzer.show;
+  }
+
+  $scope.initTypeAnalysisInfo = function (typeName) {
+    $scope.analysis = getAnalysisInfo($scope.luke, {type: true}, typeName);
+    if ($scope.analysis && $scope.analysis.data) {
+      $scope.className = $scope.analysis.data.className
+    }
+    $scope.editAnalysis = "Edit JSON";
+    $scope.showAnalysisJson = false;
+    delete $scope.analysisJsonText;
+  };
+
+  $scope.toggleVerbose = function () {
+    $scope.analysisVerbose = !$scope.analysisVerbose;
+  };
+
+  $scope.updateSampleDocId = function () {
+    $scope.indexText = "";
+    $scope.result = {};
+
+    if (!$scope.selectedNode) {
+      return;
+    }
+
+    var field = $scope.selectedNode.name;
+    var params = {path: "sample"};
+    params.configSet = $scope.currentSchema;
+    params.uniqueKeyField = $scope.uniqueKeyField;
+    params.field = field;
+
+    if ($scope.sampleDocId) {
+      params.docId = $scope.sampleDocId;
+    } // else the server will pick the first doc with a non-empty text value for the desired field
+
+    SchemaDesigner.get(params, function (data) {
+      $scope.sampleDocId = data[$scope.uniqueKeyField];
+      $scope.indexText = data[field];
+      if (data.analysis && data.analysis["field_names"]) {
+        $scope.result = processFieldAnalysisData(data.analysis["field_names"][field]);
+      }
+    });
+  };
+
+  $scope.changeLanguages = function () {
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  function getType(typeName) {
+    if ($scope.fieldTypes) {
+      for (i in $scope.fieldTypes) {
+        if ($scope.fieldTypes[i].text === typeName) {
+          return $scope.fieldTypes[i];
+        }
+      }
+    }
+    return null;
+  }
+
+  $scope.refreshTree = function() {
+    var jst = $('#schemaJsTree').jstree(true);
+    if (jst) {
+      jst.refresh();
+    }
+  };
+
+  $scope.onSchemaTreeLoaded = function (id) {
+    //console.log(">> on tree loaded");
+  };
+
+  $scope.updateFile = function () {
+    var nodeId = "files/" + $scope.selectedFile;
+    var params = {path: "file", file: $scope.selectedFile, configSet: $scope.currentSchema};
+
+    $scope.updateWorking = true;
+    $scope.updateStatusMessage = "Updating file ...";
+
+    SchemaDesigner.post(params, $scope.fileNodeText, function (data) {
+      if (data.updateFileError) {
+        if (data[$scope.selectedFile]) {
+          $scope.fileNodeText = data[$scope.selectedFile];
+        }
+        $scope.updateFileError = data.updateFileError;
+      } else {
+        delete $scope.updateFileError;
+        $scope.updateStatusMessage = "File '"+$scope.selectedFile+"' updated.";
+        $scope.onSchemaUpdated(data.configSet, data, nodeId);
+      }
+    }, $scope.errorHandler);
+  };
+
+  $scope.onSelectFileNode = function (id, doSelectOnTree) {
+    $scope.selectedFile = id.startsWith("files/") ? id.substring("files/".length) : id;
+
+    var params = {path: "file", file: $scope.selectedFile, configSet: $scope.currentSchema};
+    SchemaDesigner.get(params, function (data) {
+      $scope.fileNodeText = data[$scope.selectedFile];
+      $scope.isLeafNode = false;
+      if (doSelectOnTree) {
+        delete $scope.selectedNode;
+        $scope.isLeafNode = false;
+        $scope.showFieldDetails = true;
+        delete $scope.sampleDocId;
+        $scope.showAnalysis = false;
+        $scope.selectNodeInTree(id);
+      }
+    });
+  };
+  
+  function fieldNodes(src, type) {
+    var nodes = [];
+    for (var c in src) {
+      var childNode = src[c];
+      if (childNode && childNode.a_attr) {
+        var a = childNode.a_attr;
+        var stored = a.stored || (a.docValues && a.useDocValuesAsStored);
+        var obj = {"name":a.name, "indexed":a.indexed, "docValues": a.docValues,
+          "multiValued":a.multiValued, "stored":stored, "tokenized": a.tokenized};
+        if (type === "field" || type === "dynamicField") {
+          obj.type = a.type;
+        } else if (type === "type") {
+          obj.class = a.class;
+        }
+        nodes.push(obj);
+      }
+    }
+    return nodes;
+  }
+
+  function stripAnchorSuffix(id) {
+    if (id && id.endsWith("_anchor")) {
+      id = id.substring(0, id.length - "_anchor".length);
+    }
+    return id;
+  }
+
+  $scope.onSelectSchemaTreeNode = function (id) {
+    id = stripAnchorSuffix(id);
+    $scope.showFieldDetails = false;
+    $scope.isSchemaRoot = false;
+    $scope.isLeafNode = false;
+    delete $scope.containerNodeLabel;
+    delete $scope.containerNode;
+    delete $scope.containerNodes;
+    delete $scope.selectedFile;
+
+    if (id === "/") {
+      $scope.selectedType = "Schema";
+      $scope.selectedNode = null;
+      $scope.isSchemaRoot = true;
+      $scope.isLeafNode = false;
+      $scope.isContainerNode = false;
+      $scope.showFieldDetails = true;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+
+      if (!$scope.treeFilter) {
+        $scope.treeFilter = "type";
+        $scope.treeFilterOption = "*";
+        $scope.initTreeFilters();
+      }
+      return;
+    }
+
+    var jst = $('#schemaJsTree').jstree(true);
+    if (!jst) {
+      return;
+    }
+
+    var node = jst.get_node(id);
+    if (!node) {
+      return;
+    }
+
+    if (id === "files") {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      return;
+    }
+
+    if (id.indexOf("/") === -1) {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      $scope.containerNode = id;
+
+      if (id === "fields") {
+        $scope.containerNodes = fieldNodes($scope.fieldsNode ? $scope.fieldsNode.children : $scope.fieldsSrc, "field");
+      } else if (id === "dynamicFields") {
+        $scope.containerNodes = fieldNodes($scope.dynamicFieldsNode ? $scope.dynamicFieldsNode.children : $scope.dynamicFieldsSrc, "dynamicField");
+      } else if (id === "fieldTypes") {
+        $scope.containerNodes = fieldNodes($scope.fieldTypes, "type");
+      }
+
+      $scope.containerNodeLabel = node.text;
+      $scope.showFieldDetails = true;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+      return;
+    }
+
+    if (id.startsWith("files/")) {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+      if (node.children.length === 0) {
+        // file
+        $scope.showFieldDetails = true;
+        $scope.onSelectFileNode(id, false);
+      } else {
+        // folder
+        $scope.showFieldDetails = false;
+        delete $scope.selectedFile;
+      }
+      return;
+    }
+
+    delete $scope.selectedFile;
+    $scope.selectedNode = node["a_attr"]; // all the info we need is in the a_attr object
+    if (!$scope.selectedNode) {
+      // a node in the tree that isn't a field was selected, just ignore
+      return;
+    }
+
+    $scope.selectedNode.fieldType = getType($scope.selectedNode.type);
+    $scope.isLeafNode = true;
+
+    var nodeType = id.substring(0, id.indexOf("/"));
+    var name = null;
+    if (nodeType === "field") {
+      $scope.selectedType = "Field";
+      name = $scope.selectedNode.type;
+    } else if (nodeType === "dynamic") {
+      $scope.selectedType = "Dynamic Field";
+    } else if (nodeType === "type") {
+      $scope.selectedType = "Type";
+      name = $scope.selectedNode.name;
+    }
+
+    if (name) {
+      $scope.initTypeAnalysisInfo(name, "type");
+    }
+
+    // apply some sanity to the checkboxes
+    $scope.selectedNode = applyConstraintsOnField($scope.selectedNode);
+    $scope.showFieldDetails = true;
+
+    if (nodeType === "field" && $scope.selectedNode.tokenized && $scope.selectedNode.stored) {
+      $scope.showAnalysis = true;
+      $scope.updateSampleDocId();
+    } else {
+      $scope.showAnalysis = false;
+      $scope.indexText = "";
+      $scope.result = {};
+    }
+  };
+
+  function addFileNode(dirs, parent, f) {
+    var path = f.split("/");
+    if (path.length === 1) {
+      if (!parent.children) {
+        parent.children = [];
+        dirs.push(parent); // now parent has children, so track in dirs ...
+      }
+      var nodeId = parent.id + "/" + f;
+      parent.children.push({"text": f, "id": nodeId, "a_attr": {"href": nodeId}});
+    } else {
+      // get the parent for this path
+      var parentId = "files/" + path.slice(0, path.length - 1).join("/");
+      var dir = null;
+      for (var d in dirs) {
+        if (dirs[d].id === parentId) {
+          dir = dirs[d];
+          break;
+        }
+      }
+      if (!dir) {
+        dir = {"text": path[0], "id": parentId, "a_attr": {"href": parentId}, "children": []};
+        dirs.push(dir);
+        parent.children.push(dir);
+      }
+
+      // walk down the next level in this path
+      addFileNode(dirs, dir, path.slice(1).join("/"));
+    }
+  }
+
+  // transform a flat list structure into the nested tree structure
+  function filesToTree(files) {
+    var filesNode = {"text": "Files", "a_attr": {"href": "files"}, "id": "files", "children": []};
+    if (files) {
+      var dirs = []; // lookup for known dirs by path since nodes don't keep a ref to their parent node
+      for (var i in files) {
+        // hide the configoverlay.json from the UI
+        if (files[i] === "configoverlay.json") {
+          continue;
+        }
+
+        addFileNode(dirs, filesNode, files[i]);
+      }
+      delete dirs;
+    }
+    return filesNode;
+  }
+
+  function fieldsToTree(fields) {
+    var children = [];
+    if (fields) {
+      for (var i in fields) {
+        var id = "field/" + fields[i].name;
+        fields[i].href = id;
+        var text = fields[i].name;
+        if (fields[i].name === $scope.uniqueKeyField) {
+          text += "*"; // unique key field
+        }
+        children.push({"text": text, "a_attr": fields[i], "id": id});
+      }
+    }
+    return children;
+  }
+
+  function fieldTypesToTree(types) {
+    var children = [];
+    for (var i in types) {
+      var ft = types[i]
+      var id = "type/" + ft.name;
+      ft.href = id;
+      children.push({"text": ft.name, "a_attr": ft, "id": id});
+    }
+    return children;
+  }
+
+  $scope.onSampleDocumentsChanged = function () {
+    $scope.hasDocsOnServer = false; // so the updates get sent on next analyze action
+  };
+
+  $scope.initDesignerSettingsFromResponse = function (data) {
+    $scope.enableDynamicFields = data.enableDynamicFields !== null ? "" + data.enableDynamicFields : "true";
+    $scope.enableFieldGuessing = data.enableFieldGuessing !== null ? "" + data.enableFieldGuessing : "true";
+    $scope.enableNestedDocs = data.enableNestedDocs !== null ? "" + data.enableNestedDocs : "false";
+    $scope.languages = data.languages !== null && data.languages.length > 0 ? data.languages : ["*"];
+    $scope.copyFrom = data.copyFrom !== null ? data.copyFrom : "_default";
+  };
+
+  $scope.doAnalyze = function (nodeId) {
+    delete $scope.sampleMessage;
+
+    var schema = $scope.currentSchema;
+    if (schema) {
+      delete $scope.copyFrom;
+    } else {
+      schema = $scope.newSchema;
+      if (!$scope.copyFrom) {
+        $scope.copyFrom = "_default";
+      }
+    }
+
+    if (!schema) {
+      return;
+    }
+
+    var params = {path: "analyze", configSet: schema};
+    if ($scope.schemaVersion && $scope.schemaVersion !== -1) {
+      params.schemaVersion = $scope.schemaVersion;
+    }
+
+    if ($scope.enableDynamicFields) {
+      params.enableDynamicFields = $scope.enableDynamicFields;
+    }
+    if ($scope.enableFieldGuessing) {
+      params.enableFieldGuessing = $scope.enableFieldGuessing;
+    }
+    if ($scope.enableNestedDocs) {
+      params.enableNestedDocs = $scope.enableNestedDocs;
+    }
+
+    if ($scope.languages && $scope.languages.length > 0) {
+      params.languages = $scope.languages;
+    }
+
+    if ($scope.copyFrom) {
+      params.copyFrom = $scope.copyFrom;
+    }
+
+    $scope.updateWorking = true;
+    if ($scope.selectedUpdated) {
+      $scope.updateStatusMessage = "Applying " + $scope.selectedType + " updates ..."
+    } else {
+      $scope.updateStatusMessage = "Analyzing your sample data, schema will load momentarily ..."
+    }
+
+    if (!nodeId && $scope.selectedNode) {
+      nodeId = $scope.selectedNode.id;
+    }
+
+    // a bit tricky ...
+    // so users can upload a file or paste in docs
+    // if they upload a file containing a small (<15K) of text data, then we'll show it in the textarea
+    // they can change the text and re-analyze too
+    // if no changes or nothing uploaded, the server-side uses the latest sample data stored in the blob store
+    if ($scope.fileUpload) {
+      var file = $scope.fileUpload;
+      var fd = new FormData();
+      fd.append('file', file);
+      SchemaDesigner.upload(params, fd, function (data) {
+        $("#upload-file").val("");
+        delete $scope.fileUpload;
+        $scope.onSchemaUpdated(schema, data, nodeId);
+      }, $scope.errorHandler);
+    } else {
+      // don't need to keep re-posting the same sample if already stored in the blob store
+      var postData = null;
+      if (!$scope.hasDocsOnServer) {
+        postData = $scope.sampleDocuments;
+        if (!postData && !$scope.published) {
+          return;
+        }
+      }
+
+      var respHandler = function (data) {
+        $scope.onSchemaUpdated(schema, data, nodeId);
+      };
+
+      // TODO: need a better approach to detecting the content type from text content
+      var contentType = "text/plain";
+      if (postData != null) {
+        var txt = postData.trim();
+        if ((txt.startsWith("[") && txt.includes("]")) || (txt.startsWith("{") && txt.includes("}"))) {
+          contentType = "application/json"
+        } else if (txt.startsWith("<") || txt.includes("<add>") || txt.includes("<!--")) {
+          contentType = "text/xml";
+        } else {
+          contentType = "application/csv";
+        }
+      }
+
+      if (contentType === "text/xml") {
+        SchemaDesigner.postXml(params, postData, respHandler, $scope.errorHandler);
+      } else if (contentType === "application/csv") {
+        SchemaDesigner.postCsv(params, postData, respHandler, $scope.errorHandler);
+      } else {
+        SchemaDesigner.post(params, postData, respHandler, $scope.errorHandler);
+      }
+    }
+  };
+
+  $scope.onFieldTypeChanged = function () {
+
+    var copyFromType = ["stored", "indexed", "multiValued", "docValues", "useDocValuesAsStored", "tokenized", "uninvertible", "termVectors", "termPositions", "termOffsets",
+      "omitNorms", "omitTermFreqAndPositions", "omitPositions", "storeOffsetsWithPositions"];
+
+    var type = $scope.selectedNode.type
+
+    // when the user updates the selected field's type, we go apply the
+    // new field type's properties to the selected node
+    for (var i in $scope.fieldTypes) {
+      if ($scope.fieldTypes[i].text == type) {
+        var ft = $scope.fieldTypes[i];
+        $scope.selectedNode.fieldType = ft;
+        for (var i in copyFromType) {
+          var x = copyFromType[i];
+          if (ft.a_attr[x] !== null) {
+            $scope.selectedNode[x] = ft.a_attr[x];
+          } else {
+            delete $scope.selectedNode[x];
+          }
+        }
+        $scope.selectedUpdated = true;
+        break;
+      }
+    }
+
+    $scope.selectedNode = applyConstraintsOnField($scope.selectedNode);
+    if ($scope.selectedUpdated) {
+      // for luke analysis, we need the type info here, not the specific field into b/c it just changed.
+      $scope.initTypeAnalysisInfo(type, "type");
+    }
+  };
+
+  $scope.isDisabled = function (dep) {
+    if (!$scope.selectedNode) {
+      return false;
+    }
+
+    if (dep === "termVectors") {
+      // termVectors dependency depends on indexed
+      return !($scope.selectedNode.indexed && $scope.selectedNode.termVectors);
+    }
+
+    if (dep === "not-text" || dep === "docValues") {
+      if ($scope.selectedNode.fieldType && $scope.selectedNode.fieldType.a_attr.class === "solr.TextField") {
+        // no doc values for TextField
+        return true;
+      }
+    }
+
+    return $scope.selectedNode[dep] === false;
+  };
+
+  // this updates checkboxes based on the current settings
+  $scope.markSelectedUpdated = function (event) {
+    delete $scope.updateStatusMessage;
+    $scope.selectedUpdated = true; // enable the update button for this field
+  };
+
+  $scope.deleteSelected = function () {
+    // console.log(">> deleteSelected");
+  };
+
+  $scope.updateSelected = function () {
+    if (!$scope.selectedNode) {
+
+      if ($scope.selectedUpdated) {
+        // some root level property changed ... re-analyze
+        $scope.doAnalyze("/");
+      }
+
+      return;
+    }
+
+    delete $scope.updateSelectedError;
+
+    // make a copy for the PUT
+    var href = $scope.selectedNode.href;
+    var id = $scope.selectedNode.id;
+
+    var putData = JSON.parse(JSON.stringify($scope.selectedNode));
+    if ($scope.selectedType === "Field" && putData.copyDest) {
+      var fields = putData.copyDest.split(",");
+      for (var f in fields) {
+        var name = fields[f].trim();
+        if (!$scope.fieldNames.includes(name)) {
+          $scope.updateSelectedError = "Copy to field '" + name + "' doesn't exist!";
+          return;
+        }
+        if (name === $scope.selectedNode.name) {
+          $scope.updateSelectedError = "Cannot copy a field to itself!";
+          return;
+        }
+      }
+    } else {
+      delete putData.copyDest;
+    }
+
+    delete putData.fieldType;
+    delete putData.href;
+    delete putData.id;
+
+    putData = applyConstraintsOnField(putData);
+
+    if ($scope.analysisJsonText && !$scope.selectedNode.type) {
+      var text = $scope.analysisJsonText.trim();
+      if (text.length > 0) {
+        text = text.replace(/\s+/g, ' ');
+        if (!text.startsWith("{")) {
+          text = "{ " + text + " }";
+        }
+        try {
+          var textJson = JSON.parse(text);
+          if (textJson.analyzer) {
+            putData.analyzer = textJson.analyzer;
+          } else {
+            if (textJson.indexAnalyzer && textJson.queryAnalyzer) {
+              putData.indexAnalyzer = textJson.indexAnalyzer;
+              putData.queryAnalyzer = textJson.queryAnalyzer;
+            }
+          }
+        } catch (e) {
+          $scope.updateSelectedError = "Failed to parse analysis as JSON due to: " + e.message +
+              "; expected JSON object with either an 'analyzer' or 'indexAnalyzer' and 'queryAnalyzer'";
+          return;
+        }
+      }
+    }
+
+    $scope.updateWorking = true;
+    $scope.updateStatusMessage = "Updating " + $scope.selectedType + " ...";
+
+    SchemaDesigner.put({
+      path: "update",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, putData, function (data) {
+
+      var nodeType = data.updateType;
+      $scope.schemaVersion = data.schemaVersion;
+      $scope.currentSchema = data.configSet;
+      $scope.core = data.core;
+
+      $scope.selectedNode = data[nodeType];
+      $scope.selectedNode.href = href;
+      $scope.selectedNode.id = id;
+
+      var name = nodeType === "field" ? $scope.selectedNode.type : $scope.selectedNode.name;
+      $scope.initTypeAnalysisInfo(name, "type");
+      $scope.showFieldDetails = true;
+
+      if (nodeType === "field" && $scope.selectedNode.tokenized) {
+        $scope.showAnalysis = true;
+        $scope.updateSampleDocId();
+      }
+
+      $scope.onSchemaUpdated($scope.currentSchema, data, href);
+    }, $scope.errorHandler);
+  };
+
+  // TODO: These are copied from analysis.js, so move to a shared location for both vs. duplicating
+  var getShortComponentName = function (longname) {
+    var short = -1 !== longname.indexOf('$')
+        ? longname.split('$')[1]

Review comment:
       *W014:*  Misleading line break before '?'; readers may interpret this as an expression boundary.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/solr-ref-guide/src/js/customscripts.js
##########
@@ -67,3 +62,18 @@ $(document).ready(function () {
     });
 
 });
+
+// Forces all non-solr.apache.org links to open in new tab
+$(document).ready(function () {
+  $("a[href^=http]").each(function(){
+    var all_links = document.querySelectorAll('a');
+    for (var i = 0; i < all_links.length; i++){
+       var a = all_links[i];
+       if(a.hostname != location.hostname) {
+               a.rel = 'noopener';
+               a.target = '_blank';
+       }
+}
+  }
+)

Review comment:
       *W033:*  Missing semicolon.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/schema-designer.js
##########
@@ -0,0 +1,2014 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $cookies, $window, Constants, SchemaDesigner, Luke) {
+  $scope.resetMenu("schema-designer", Constants.IS_ROOT_PAGE);
+
+  $scope.onWarning = function (warnMsg, warnDetails) {
+    $scope.updateWorking = false;
+    delete $scope.updateStatusMessage;
+    $scope.apiWarning = warnMsg;
+    $scope.apiWarningDetails = warnDetails;
+  };
+  
+  $scope.onError = function (errorMsg, errorCode, errorDetails) {
+    $scope.updateWorking = false;
+    delete $scope.updateStatusMessage;
+    $scope.designerAPIError = errorMsg;
+    if (errorDetails) {
+      var errorDetailsStr = "";
+      if (errorDetails["error"]) {
+        errorDetailsStr = errorDetails["error"];
+      } else {
+        for (var id in errorDetails) {
+          var msg = errorDetails[id];
+          var at = msg.indexOf("ERROR: ");
+          if (at !== -1) {
+            msg = msg.substring(at+7);
+          }
+          if (!msg.includes(id)) {
+            msg = id+": "+msg;
+          }
+          errorDetailsStr += msg+"\n\n";
+        }
+      }
+      $scope.designerAPIErrorDetails = errorDetailsStr;
+    } else {
+      delete $scope.designerAPIErrorDetails;
+    }
+
+    if (errorCode === 409) {
+      $scope.schemaVersion = -1; // reset to get the latest
+      $scope.isVersionMismatch = true;
+      $scope.errorButton = "Reload Schema";
+    } else if (errorCode < 500) {
+      $scope.isVersionMismatch = false;
+      $scope.errorButton = "OK";
+    } // else 500 errors get the top-level error message
+  };
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error) {
+      $scope.onError(error.msg, error.code, e.data.errorDetails);
+    } else {
+      // when a timeout occurs, the error details are sparse so just give the user a hint that something was off
+      var path = e.config && e.config.url ? e.config.url : "/api/schema-designer";
+      var reloadMsg = "";
+      if (path.includes("/analyze")) {
+        reloadMsg = " Re-try analyzing your sample docs by clicking on 'Analyze Documents' again."
+      }
+      $scope.onError("Request to "+path+" failed!", 408,
+          {"error":"Most likely the request timed out; check server log for more details."+reloadMsg});
+    }
+  };
+
+  $scope.closeWarnDialog = function () {
+    delete $scope.apiWarning;
+    delete $scope.apiWarningDetails;
+  };
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.designerAPIError;
+    delete $scope.designerAPIErrorDetails;
+    if ($scope.isVersionMismatch) {
+      $scope.isVersionMismatch = false;
+      var nodeId = "/";
+      if ($scope.selectedNode) {
+        nodeId = $scope.selectedNode.href;
+      }
+      $scope.doAnalyze(nodeId);
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.isSchemaDesignerEnabled = true;
+
+    delete $scope.helpId;
+
+    $scope.updateStatusMessage = "";
+    $scope.analysisVerbose = false;
+    $scope.updateWorking = false;
+    $scope.currentSchema = "";
+
+    delete $scope.hasDocsOnServer;
+    delete $scope.queryResultsTree;
+
+    $scope.languages = ["*"];
+    $scope.copyFrom = "_default";
+    delete $scope.sampleMessage;
+    delete $scope.sampleDocuments;
+
+    $scope.schemaVersion = -1;
+    $scope.schemaTree = {};
+    $scope.showSchemaActions = false;
+    $scope.sampleDocIds = [];
+    $scope.isSchemaRoot = false;
+
+    delete $scope.enableNestedDocs;
+    delete $scope.enableDynamicFields;
+    delete $scope.enableFieldGuessing;
+
+    // schema editor
+    $scope.showFieldDetails = false;
+    $scope.selectedNode = null;
+    $scope.selectedUpdated = false;
+    delete $scope.updateStatusMessage;
+
+    // text field analysis
+    $scope.showAnalysis = false;
+    $scope.sampleDocId = null;
+    $scope.indexText = "";
+    $scope.result = {};
+
+    // publish vars
+    delete $scope.newCollection;
+    $scope.reloadOnPublish = "true";
+
+    // query form
+    $scope.query = {q: '*:*', sortBy: 'score', sortDir: 'desc'};
+
+    SchemaDesigner.get({path: "configs"}, function (data) {
+
+      $scope.schemas = [];
+      $scope.publishedSchemas = ["_default"];
+
+      for (var s in data.configSets) {
+        // 1 means published but not editable
+        if (data.configSets[s] !== 1) {
+          $scope.schemas.push(s);
+        }
+
+        // 0 means not published yet (so can't copy from it yet)
+        if (data.configSets[s] > 0) {
+          $scope.publishedSchemas.push(s);
+        }
+      }
+
+      $scope.schemas.sort();
+      $scope.publishedSchemas.sort();
+
+      // if no schemas available to select, open the pop-up immediately
+      if ($scope.schemas.length === 0) {
+        $scope.firstSchemaMessage = true;
+        $scope.showNewSchemaDialog();
+      }
+    }, function(e) {
+      if (e.status === 401 || e.status === 403) {
+        $scope.isSchemaDesignerEnabled = false;
+        $scope.hideAll();
+      }
+    });
+  };
+
+  $scope.selectNodeInTree = function(nodeId) {
+    nodeId = stripAnchorSuffix(nodeId);
+    if (!nodeId) return;
+
+    var jst = $('#schemaJsTree').jstree(true);
+    if (jst) {
+      var selectedId = null;
+      var selected_node = jst.get_selected();
+      if (selected_node && selected_node.length > 0) {
+        selectedId = selected_node[0];
+      }
+      if (selectedId) {
+        try {
+          jst.deselect_node(selectedId);
+        } catch (err) {
+          // just ignore
+          //console.log("error deselecting "+selectedId);
+        }
+      }
+
+      try {
+        jst.select_node(nodeId, true);
+      } catch (err) {
+        // just ignore, some low-level tree issue
+        //console.log("error selecting "+nodeId);
+      }
+    }
+  };
+
+  $scope.loadFile = function (event) {
+    var t = event.target || event.srcElement || event.currentTarget;
+    if (t && t.text) {
+      $scope.onSelectFileNode("files/" + t.text, true);
+    }
+  };
+
+  $scope.confirmEditSchema = function () {
+    $scope.showConfirmEditSchema = false;
+    if ($scope.hasDocsOnServer || $scope.published) {
+      $scope.doAnalyze();
+    } else {
+      $scope.sampleMessage = "Please upload or paste some sample documents to analyze for building the '" + $scope.currentSchema + "' schema.";
+    }
+  };
+
+  $scope.cancelEditSchema = function () {
+    $scope.currentSchema = "";
+    $scope.showConfirmEditSchema = false;
+  };
+
+  $scope.loadSchema = function () {
+
+    if (!$scope.currentSchema) {
+      return;
+    }
+
+    $scope.resetSchema();
+    var params = {path: "info", configSet: $scope.currentSchema};
+    SchemaDesigner.get(params, function (data) {
+      $scope.currentSchema = data.configSet;
+      $("#select-schema").trigger("chosen:updated");
+
+      $scope.confirmSchema = data.configSet;
+      $scope.collectionsForConfig = data.collections;
+      $scope.hasDocsOnServer = data.numDocs > 0;
+      $scope.published = data.published;
+      $scope.initDesignerSettingsFromResponse(data);
+      if ($scope.collectionsForConfig && $scope.collectionsForConfig.length > 0) {
+        $scope.showConfirmEditSchema = true;
+      } else {
+        if ($scope.hasDocsOnServer || $scope.published) {
+          $scope.doAnalyze();
+        } else {
+          $scope.sampleMessage = "Please upload or paste some sample documents to build the '" + $scope.currentSchema + "' schema.";
+        }
+      }
+    });
+  };
+
+  $scope.showNewSchemaDialog = function () {
+    $scope.hideAll();
+    $scope.showNewSchema = true;
+    $scope.newSchema = "";
+  };
+
+  $scope.addSchema = function () {
+    $scope.firstSchemaMessage = false;
+    delete $scope.addMessage;
+
+    if (!$scope.newSchema) {
+      $scope.addMessage = "Please provide a schema name!";
+      return;
+    }
+
+    $scope.newSchema = $scope.newSchema.trim();
+    if ($scope.newSchema.length > 50) {
+      $scope.addMessage = "Schema name be 50 characters or less";
+      return;
+    }
+
+    if ($scope.newSchema.indexOf(" ") !== -1 || $scope.newSchema.indexOf("/") !== -1) {
+      $scope.addMessage = "Schema name should not contain spaces or /";
+      return;
+    }
+
+    if ($scope.publishedSchemas.includes($scope.newSchema) || $scope.schemas.includes($scope.newSchema)) {
+      $scope.addMessage = "Schema '" + $scope.newSchema + "' already exists!";
+      return;
+    }
+
+    delete $scope.addMessage;
+    if (!$scope.copyFrom) {
+      $scope.copyFrom = "_default";
+    }
+
+    $scope.resetSchema();
+    $scope.schemas.push($scope.newSchema);
+    $scope.showNewSchema = false;
+    $scope.currentSchema = $scope.newSchema;
+    $scope.sampleMessage = "Please upload or paste some sample documents to analyze for building the '" + $scope.currentSchema + "' schema.";
+
+    SchemaDesigner.post({path: "prep", configSet: $scope.newSchema, copyFrom: $scope.copyFrom}, null, function (data) {
+      $scope.initDesignerSettingsFromResponse(data);
+    }, $scope.errorHandler);
+  };
+
+  $scope.cancelAddSchema = function () {
+    delete $scope.addMessage;
+    delete $scope.sampleMessage;
+
+    $scope.showNewSchema = false
+  };
+
+  $scope.hideAll = function () {
+    delete $scope.helpId;
+    $scope.showPublish = false;
+    $scope.showDiff = false;
+    $scope.showNewSchema = false;
+    $scope.showAddField = false;
+    $scope.showAddDynamicField = false;
+    $scope.showAddCopyField = false;
+    $scope.showAnalysis = false;
+    // add more dialogs here
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.hideData = function () {
+    $scope.showData = false;
+  };
+
+  $scope.rootChanged = function () {
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  $scope.updateUniqueKey = function () {
+    delete $scope.schemaRootMessage;
+    var jst = $('#schemaJsTree').jstree();
+    if (jst) {
+      var node = jst.get_node("field/" + $scope.updateUniqueKeyField);
+      if (node && node.a_attr) {
+        var attrs = node.a_attr;
+        if (attrs.multiValued || attrs.tokenized || !attrs.stored || !attrs.indexed) {
+          $scope.schemaRootMessage = "Field '" + $scope.updateUniqueKeyField +
+              "' cannot be used as the uniqueKey field! Must be single-valued, stored, indexed, and not tokenized.";
+          $scope.updateUniqueKeyField = $scope.uniqueKeyField;
+          return;
+        }
+      }
+    }
+    $scope.uniqueKeyField = $scope.updateUniqueKeyField;
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  $scope.resetSchema = function () {
+    $scope.hideAll();
+    $scope.analysisVerbose = false;
+    $scope.showSchemaActions = false;
+    $scope.showAnalysis = false;
+    $scope.showFieldDetails = false;
+    $scope.hasDocsOnServer = false;
+    $scope.query = {q: '*:*', sortBy: 'score', sortDir: 'desc'};
+    $scope.schemaVersion = -1;
+
+    $scope.updateWorking = false;
+    $scope.isVersionMismatch = false;
+    delete $scope.updateStatusMessage;
+    delete $scope.designerAPIError;
+    delete $scope.designerAPIErrorDetails;
+    delete $scope.selectedFacets;
+    delete $scope.sampleDocuments;
+    delete $scope.selectedNode;
+    delete $scope.queryResultsTree;
+  };
+
+  $scope.onSchemaUpdated = function (schema, data, nodeId) {
+    $scope.hasDocsOnServer = data.numDocs && data.numDocs > 0;
+    $scope.uniqueKeyField = data.uniqueKeyField;
+    $scope.updateUniqueKeyField = $scope.uniqueKeyField;
+    $scope.initDesignerSettingsFromResponse(data);
+
+    var fieldTypes = fieldTypesToTree(data.fieldTypes);
+    var files = filesToTree(data.files);
+
+    var rootChildren = [];
+    $scope.fieldsSrc = fieldsToTree(data.fields);
+    $scope.fieldsNode = {
+      "id": "fields",
+      "text": "Fields",
+      "state": {"opened": true},
+      "a_attr": {"href": "fields"},
+      "children": $scope.fieldsSrc
+    };
+    rootChildren.push($scope.fieldsNode);
+
+    if ($scope.enableDynamicFields === "true") {
+      $scope.dynamicFieldsSrc = fieldsToTree(data.dynamicFields);
+      $scope.dynamicFieldsNode = {
+        "id": "dynamicFields",
+        "text": "Dynamic Fields",
+        "a_attr": {"href": "dynamicFields"},
+        "children": $scope.dynamicFieldsSrc
+      };
+      rootChildren.push($scope.dynamicFieldsNode);
+    } else {
+      delete $scope.dynamicFieldsNode;
+      delete $scope.dynamicFieldsSrc;
+    }
+
+    rootChildren.push({"id":"fieldTypes", "text": "Field Types", "a_attr": {"href": "fieldTypes"}, "children": fieldTypes});
+    rootChildren.push(files);
+
+    var tree = [{"id":"/", "text": schema, "a_attr": {"href": "/"}, "state":{"opened": true}, "children": rootChildren}];
+
+    $scope.fields = data.fields;
+    $scope.fieldNames = data.fields.map(f => f.name).sort();
+    $scope.possibleIdFields = data.fields.filter(f => f.indexed && f.stored && !f.tokenized).map(f => f.name).sort();
+    $scope.sortableFields = data.fields.filter(f => (f.indexed || f.docValues) && !f.multiValued && !f.tokenized).map(f => f.name).sort();
+    $scope.sortableFields.push("score");
+
+    $scope.facetFields = data.fields.filter(f => (f.indexed || f.docValues) && !f.tokenized && f.name !== '_version_').map(f => f.name).sort();
+    $scope.hlFields = data.fields.filter(f => f.stored && f.tokenized).map(f => f.name).sort();
+
+    $scope.schemaVersion = data.schemaVersion;
+    $scope.currentSchema = data.configSet;
+    $scope.fieldTypes = fieldTypes;
+    $scope.core = data.core;
+    $scope.schemaTree = tree;
+    $scope.refreshTree();
+
+    $scope.collectionsForConfig = data.collectionsForConfig;
+
+    if (data.docIds) {
+      $scope.sampleDocIds = data.docIds;
+    }
+
+    // re-apply the filters on the updated schema
+    $scope.onTreeFilterOptionChanged();
+
+    // Load the Luke schema
+    Luke.schema({core: data.core}, function (schema) {
+      Luke.raw({core: data.core}, function (index) {
+        $scope.luke = mergeIndexAndSchemaData(index, schema.schema);
+        $scope.types = Object.keys(schema.schema.types);
+        $scope.showSchemaActions = true;
+        if (!nodeId) {
+          nodeId = "/";
+        }
+        $scope.onSelectSchemaTreeNode(nodeId);
+
+        $scope.updateWorking = false;
+
+        if (data.updateError != null) {
+          $scope.onError(data.updateError, data.updateErrorCode, data.errorDetails);
+        } else {
+          if ($scope.selectedUpdated) {
+            $scope.selectedUpdated = false;
+            $scope.updateStatusMessage = "Changes applied successfully.";
+            var waitMs = 3000;
+            if (data.rebuild) {
+              $scope.updateStatusMessage += " Did full re-index of sample docs due to incompatible update.";
+              waitMs = 5000; // longer message, more time to read
+            }
+
+            if (data.analysisError) {
+              var updateType = data["updateType"];
+              var updatedObject = data[updateType];
+              var updatedName = updatedObject && updatedObject.name ? updatedObject.name : "";
+              var warnMsg = "Changes to "+updateType+" "+updatedName+" applied, but required the temp collection to be deleted " +
+                  "and re-created due to an incompatible Lucene change, see details below.";
+              $scope.onWarning(warnMsg, data.analysisError);
+            } else {
+              $timeout(function () { delete $scope.updateStatusMessage; }, waitMs);
+            }
+          } else {
+            var source = data.sampleSource;
+            if (source) {
+              if (source === "paste") {
+                source = "pasted sample"
+              } else if (source === "blob") {
+                source = "previous upload stored on the server"
+              }
+              if (data.numDocs > 0) {
+                $scope.updateStatusMessage = "Analyzed "+data.numDocs+" docs from "+source;
+              } else {
+                $scope.updateStatusMessage = "Schema '"+$scope.currentSchema+"' loaded.";
+              }
+            }
+            $timeout(function () {
+              delete $scope.updateStatusMessage;
+            }, 5000);
+          }
+        }
+
+        // re-fire the current query to reflect the updated schema
+        $scope.doQuery();
+        $scope.selectNodeInTree(nodeId);
+      });
+    });
+  };
+
+  $scope.toggleAddField = function (type) {
+    if ($scope.showAddField) {
+      $scope.hideAll();
+    } else {
+      $scope.hideAll();
+      $scope.showAddField = true;
+      $scope.adding = type;
+
+      $scope.newField = {
+        stored: "true",
+        indexed: "true",
+        uninvertible: "true",
+        docValues: "true"
+      }
+
+      if (type === "field") {
+        $scope.newField.type = "string";
+      }
+
+      delete $scope.addErrors;
+    }
+  };
+
+  function applyConstraintsOnField(f) {
+
+    if (!f.docValues) {
+      delete f.useDocValuesAsStored;
+    }
+
+    if (!f.docValues && !f.uninvertible) {
+      delete f.sortMissingLast; // remove this setting if no docValues / uninvertible
+    }
+
+    if (f.indexed) {
+      if (f.omitTermFreqAndPositions && !f.omitPositions) {
+        delete f.omitPositions; // :shrug ~ see SchemaField ln 295
+      }
+      if (!f.termVectors) {
+        delete f.termPositions;
+        delete f.termOffsets;
+        delete f.termPayloads;
+      }
+    } else {
+      // if not indexed, a bunch of fields are false
+      f.tokenized = false;
+      f.uninvertible = false;
+
+      // drop these from the request
+      delete f.termVectors;
+      delete f.termPositions;
+      delete f.termOffsets;
+      delete f.termPayloads;
+      delete f.omitNorms;
+      delete f.omitPositions;
+      delete f.omitTermFreqAndPositions;
+      delete f.storeOffsetsWithPositions;
+    }
+
+    return f;
+  }
+
+  $scope.addField = function () {
+
+    // validate the form input
+    $scope.addErrors = [];
+    if (!$scope.newField.name) {
+      $scope.addErrors.push($scope.adding + " name is required!");
+    }
+
+    if ($scope.newField.name.indexOf(" ") != -1) {
+      $scope.addErrors.push($scope.adding + " name should not have whitespace");
+    }
+
+    var command = "add-field-type";
+    if ("field" === $scope.adding) {
+
+      if ($scope.fieldNames.includes($scope.newField.name)) {
+        $scope.addErrors.push("Field '" + $scope.newField.name + "' already exists!");
+        return;
+      }
+
+      // TODO: is this the correct logic for detecting dynamic? Probably good enough for the designer
+      var isDynamic = $scope.newField.name.startsWith("*") || $scope.newField.name.endsWith("*");
+      if (isDynamic) {
+        if ($scope.luke && $scope.luke.dynamic_fields[$scope.newField.name]) {
+          $scope.addErrors.push("dynamic field '" + $scope.newField.name + "' already exists!");
+        }
+      } else {
+        if ($scope.luke && $scope.luke.fields[$scope.newField.name]) {
+          $scope.addErrors.push("field '" + $scope.newField.name + "' already exists!");
+        }
+      }
+
+      if (!$scope.newField.type) {
+        $scope.addErrors.push("field type is required!");
+      }
+
+      command = isDynamic ? "add-dynamic-field" : "add-field";
+    } else if ("type" === $scope.adding) {
+      if ($scope.types.includes($scope.newField.name)) {
+        $scope.addErrors.push("Type '" + $scope.newField.name + "' already exists!");
+      }
+
+      if (!$scope.newField.class) {
+        $scope.addErrors.push("class is required when creating a new field type!");
+      }
+    }
+
+    var addData = {};
+    addData[command] = applyConstraintsOnField($scope.newField);
+    if ($scope.textAnalysisJson) {
+      var text = $scope.textAnalysisJson.trim();
+      if (text.length > 0) {
+        text = text.replace(/\s+/g, ' ');
+        if (!text.startsWith("{")) {
+          text = "{ " + text + " }";
+        }
+        try {
+          var textJson = JSON.parse(text);
+          if (textJson.analyzer) {
+            addData[command].analyzer = textJson.analyzer;
+          } else {
+            if (!textJson.indexAnalyzer || !textJson.queryAnalyzer) {
+              $scope.addErrors.push("Text analysis JSON should define either an 'analyzer' or an 'indexAnalyzer' and 'queryAnalyzer'");
+              return;
+            }
+            addData[command].indexAnalyzer = textJson.indexAnalyzer;
+            addData[command].queryAnalyzer = textJson.queryAnalyzer;
+          }
+        } catch (e) {
+          $scope.addErrors.push("Failed to parse analysis as JSON due to: " + e.message +
+              "; expected JSON object with either an 'analyzer' or 'indexAnalyzer' and 'queryAnalyzer'");
+          return;
+        }
+      }
+    }
+
+    if ($scope.addErrors.length > 0) {
+      return;
+    }
+    delete $scope.addErrors; // no errors!
+
+    SchemaDesigner.post({
+      path: "add",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, addData, function (data) {
+      if (data.errors) {
+        $scope.addErrors = data.errors[0].errorMessages;
+        if (typeof $scope.addErrors === "string") {
+          $scope.addErrors = [$scope.addErrors];
+        }
+      } else {
+        delete $scope.textAnalysisJson;
+        $scope.added = true;
+        $timeout(function () {
+          $scope.showAddField = false;
+          $scope.added = false;
+          var nodeId = "/";
+          if ("field" === $scope.adding) {
+            nodeId = "field/" + data[command];
+          } else if ("type" === $scope.adding) {
+            nodeId = "type/" + data[command];
+          }
+          $scope.onSchemaUpdated(data.configSet, data, nodeId);
+        }, 500);
+      }
+    }, $scope.errorHandler);
+  }
+
+  function toSortedNameAndTypeList(fields, typeAttr) {
+    var list = [];
+    var keys = Object.keys(fields);
+    for (var f in keys) {
+      var field = fields[keys[f]];
+      var type = field[typeAttr];
+      if (type) {
+        list.push(field.name + ": "+type);
+      } else {
+        list.push(field.name);
+      }
+    }
+    return list.sort();
+  }
+
+  function toSortedFieldList(fields) {
+    var list = [];
+    var keys = Object.keys(fields);
+    for (var f in keys) {
+      list.push(fields[keys[f]]);
+    }
+    return list.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  $scope.toggleDiff = function (event) {
+    if ($scope.showDiff) {
+      // toggle, close dialog
+      $scope.showDiff = false;
+      return;
+    }
+
+    if (event) {
+      var t = event.target || event.currentTarget;
+      var leftPos = t.getBoundingClientRect().left - 600;
+      if (leftPos < 0) leftPos = 0;
+      $('#show-diff-dialog').css({left: leftPos});
+    }
+
+    SchemaDesigner.get({ path: "diff", configSet: $scope.currentSchema }, function (data) {
+      var diff = data.diff;
+
+      var dynamicFields = diff.dynamicFields;
+      var enableDynamicFields = data.enableDynamicFields !== null ? data.enableDynamicFields : true;
+      if (!enableDynamicFields) {
+        dynamicFields = null;
+      }
+
+      $scope.diffSource = data["diff-source"];
+      $scope.schemaDiff = {
+        "fieldsDiff": diff.fields,
+        "addedFields": [],
+        "removedFields": [],
+        "fieldTypesDiff": diff.fieldTypes,
+        "removedTypes": [],
+        "dynamicFieldsDiff": dynamicFields,
+        "copyFieldsDiff": diff.copyFields
+      }
+      if (diff.fields && diff.fields.added) {
+        $scope.schemaDiff.addedFields = toSortedFieldList(diff.fields.added);
+      }
+      if (diff.fields && diff.fields.removed) {
+        $scope.schemaDiff.removedFields = toSortedNameAndTypeList(diff.fields.removed, "type");
+      }
+      if (diff.fieldTypes && diff.fieldTypes.removed) {
+        $scope.schemaDiff.removedTypes = toSortedNameAndTypeList(diff.fieldTypes.removed, "class");
+      }
+
+      $scope.schemaDiffExists = !(diff.fields == null && diff.fieldTypes == null && dynamicFields == null && diff.copyFields == null);
+      $scope.showDiff = true;
+    });
+  }
+
+  $scope.togglePublish = function (event) {
+    if (event) {
+      var t = event.target || event.currentTarget;
+      var leftPos = t.getBoundingClientRect().left - 515;
+      if (leftPos < 0) leftPos = 0;
+      $('#publish-dialog').css({left: leftPos});
+    }
+
+    $scope.showDiff = false;
+    $scope.showPublish = !$scope.showPublish;
+    delete $scope.publishErrors;
+
+    $scope.disableDesigner = "false";
+
+    if ($scope.showPublish && !$scope.newCollection) {
+      $scope.newCollection = {numShards: 1, replicationFactor: 1, indexToCollection: "true"};
+    }
+  };
+
+  $scope.toggleAddCopyField = function () {
+    if ($scope.showAddCopyField) {
+      $scope.hideAll();
+      $scope.showFieldDetails = true;
+    } else {
+      $scope.hideAll();
+      $scope.showAddCopyField = true;
+      $scope.showFieldDetails = false;
+
+      $scope.copyField = {};
+      delete $scope.addCopyFieldErrors;
+    }
+  }
+  $scope.addCopyField = function () {
+    delete $scope.addCopyFieldErrors;
+    var data = {"add-copy-field": $scope.copyField};
+    SchemaDesigner.post({
+      path: "add",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, data, function (data) {
+      if (data.errors) {
+        $scope.addCopyFieldErrors = data.errors[0].errorMessages;
+        if (typeof $scope.addCopyFieldErrors === "string") {
+          $scope.addCopyFieldErrors = [$scope.addCopyFieldErrors];
+        }
+      } else {
+        $scope.showAddCopyField = false;
+        // TODO:
+        //$timeout($scope.refresh, 1500);
+      }
+    }, $scope.errorHandler);
+  }
+
+  $scope.toggleAnalyzer = function (analyzer) {
+    analyzer.show = !analyzer.show;
+  }
+
+  $scope.initTypeAnalysisInfo = function (typeName) {
+    $scope.analysis = getAnalysisInfo($scope.luke, {type: true}, typeName);
+    if ($scope.analysis && $scope.analysis.data) {
+      $scope.className = $scope.analysis.data.className
+    }
+    $scope.editAnalysis = "Edit JSON";
+    $scope.showAnalysisJson = false;
+    delete $scope.analysisJsonText;
+  };
+
+  $scope.toggleVerbose = function () {
+    $scope.analysisVerbose = !$scope.analysisVerbose;
+  };
+
+  $scope.updateSampleDocId = function () {
+    $scope.indexText = "";
+    $scope.result = {};
+
+    if (!$scope.selectedNode) {
+      return;
+    }
+
+    var field = $scope.selectedNode.name;
+    var params = {path: "sample"};
+    params.configSet = $scope.currentSchema;
+    params.uniqueKeyField = $scope.uniqueKeyField;
+    params.field = field;
+
+    if ($scope.sampleDocId) {
+      params.docId = $scope.sampleDocId;
+    } // else the server will pick the first doc with a non-empty text value for the desired field
+
+    SchemaDesigner.get(params, function (data) {
+      $scope.sampleDocId = data[$scope.uniqueKeyField];
+      $scope.indexText = data[field];
+      if (data.analysis && data.analysis["field_names"]) {
+        $scope.result = processFieldAnalysisData(data.analysis["field_names"][field]);
+      }
+    });
+  };
+
+  $scope.changeLanguages = function () {
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  function getType(typeName) {
+    if ($scope.fieldTypes) {
+      for (i in $scope.fieldTypes) {
+        if ($scope.fieldTypes[i].text === typeName) {
+          return $scope.fieldTypes[i];
+        }
+      }
+    }
+    return null;
+  }
+
+  $scope.refreshTree = function() {
+    var jst = $('#schemaJsTree').jstree(true);
+    if (jst) {
+      jst.refresh();
+    }
+  };
+
+  $scope.onSchemaTreeLoaded = function (id) {
+    //console.log(">> on tree loaded");
+  };
+
+  $scope.updateFile = function () {
+    var nodeId = "files/" + $scope.selectedFile;
+    var params = {path: "file", file: $scope.selectedFile, configSet: $scope.currentSchema};
+
+    $scope.updateWorking = true;
+    $scope.updateStatusMessage = "Updating file ...";
+
+    SchemaDesigner.post(params, $scope.fileNodeText, function (data) {
+      if (data.updateFileError) {
+        if (data[$scope.selectedFile]) {
+          $scope.fileNodeText = data[$scope.selectedFile];
+        }
+        $scope.updateFileError = data.updateFileError;
+      } else {
+        delete $scope.updateFileError;
+        $scope.updateStatusMessage = "File '"+$scope.selectedFile+"' updated.";
+        $scope.onSchemaUpdated(data.configSet, data, nodeId);
+      }
+    }, $scope.errorHandler);
+  };
+
+  $scope.onSelectFileNode = function (id, doSelectOnTree) {
+    $scope.selectedFile = id.startsWith("files/") ? id.substring("files/".length) : id;
+
+    var params = {path: "file", file: $scope.selectedFile, configSet: $scope.currentSchema};
+    SchemaDesigner.get(params, function (data) {
+      $scope.fileNodeText = data[$scope.selectedFile];
+      $scope.isLeafNode = false;
+      if (doSelectOnTree) {
+        delete $scope.selectedNode;
+        $scope.isLeafNode = false;
+        $scope.showFieldDetails = true;
+        delete $scope.sampleDocId;
+        $scope.showAnalysis = false;
+        $scope.selectNodeInTree(id);
+      }
+    });
+  };
+  
+  function fieldNodes(src, type) {
+    var nodes = [];
+    for (var c in src) {
+      var childNode = src[c];
+      if (childNode && childNode.a_attr) {
+        var a = childNode.a_attr;
+        var stored = a.stored || (a.docValues && a.useDocValuesAsStored);
+        var obj = {"name":a.name, "indexed":a.indexed, "docValues": a.docValues,
+          "multiValued":a.multiValued, "stored":stored, "tokenized": a.tokenized};
+        if (type === "field" || type === "dynamicField") {
+          obj.type = a.type;
+        } else if (type === "type") {
+          obj.class = a.class;
+        }
+        nodes.push(obj);
+      }
+    }
+    return nodes;
+  }
+
+  function stripAnchorSuffix(id) {
+    if (id && id.endsWith("_anchor")) {
+      id = id.substring(0, id.length - "_anchor".length);
+    }
+    return id;
+  }
+
+  $scope.onSelectSchemaTreeNode = function (id) {
+    id = stripAnchorSuffix(id);
+    $scope.showFieldDetails = false;
+    $scope.isSchemaRoot = false;
+    $scope.isLeafNode = false;
+    delete $scope.containerNodeLabel;
+    delete $scope.containerNode;
+    delete $scope.containerNodes;
+    delete $scope.selectedFile;
+
+    if (id === "/") {
+      $scope.selectedType = "Schema";
+      $scope.selectedNode = null;
+      $scope.isSchemaRoot = true;
+      $scope.isLeafNode = false;
+      $scope.isContainerNode = false;
+      $scope.showFieldDetails = true;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+
+      if (!$scope.treeFilter) {
+        $scope.treeFilter = "type";
+        $scope.treeFilterOption = "*";
+        $scope.initTreeFilters();
+      }
+      return;
+    }
+
+    var jst = $('#schemaJsTree').jstree(true);
+    if (!jst) {
+      return;
+    }
+
+    var node = jst.get_node(id);
+    if (!node) {
+      return;
+    }
+
+    if (id === "files") {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      return;
+    }
+
+    if (id.indexOf("/") === -1) {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      $scope.containerNode = id;
+
+      if (id === "fields") {
+        $scope.containerNodes = fieldNodes($scope.fieldsNode ? $scope.fieldsNode.children : $scope.fieldsSrc, "field");
+      } else if (id === "dynamicFields") {
+        $scope.containerNodes = fieldNodes($scope.dynamicFieldsNode ? $scope.dynamicFieldsNode.children : $scope.dynamicFieldsSrc, "dynamicField");
+      } else if (id === "fieldTypes") {
+        $scope.containerNodes = fieldNodes($scope.fieldTypes, "type");
+      }
+
+      $scope.containerNodeLabel = node.text;
+      $scope.showFieldDetails = true;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+      return;
+    }
+
+    if (id.startsWith("files/")) {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+      if (node.children.length === 0) {
+        // file
+        $scope.showFieldDetails = true;
+        $scope.onSelectFileNode(id, false);
+      } else {
+        // folder
+        $scope.showFieldDetails = false;
+        delete $scope.selectedFile;
+      }
+      return;
+    }
+
+    delete $scope.selectedFile;
+    $scope.selectedNode = node["a_attr"]; // all the info we need is in the a_attr object
+    if (!$scope.selectedNode) {
+      // a node in the tree that isn't a field was selected, just ignore
+      return;
+    }
+
+    $scope.selectedNode.fieldType = getType($scope.selectedNode.type);
+    $scope.isLeafNode = true;
+
+    var nodeType = id.substring(0, id.indexOf("/"));
+    var name = null;
+    if (nodeType === "field") {
+      $scope.selectedType = "Field";
+      name = $scope.selectedNode.type;
+    } else if (nodeType === "dynamic") {
+      $scope.selectedType = "Dynamic Field";
+    } else if (nodeType === "type") {
+      $scope.selectedType = "Type";
+      name = $scope.selectedNode.name;
+    }
+
+    if (name) {
+      $scope.initTypeAnalysisInfo(name, "type");
+    }
+
+    // apply some sanity to the checkboxes
+    $scope.selectedNode = applyConstraintsOnField($scope.selectedNode);
+    $scope.showFieldDetails = true;
+
+    if (nodeType === "field" && $scope.selectedNode.tokenized && $scope.selectedNode.stored) {
+      $scope.showAnalysis = true;
+      $scope.updateSampleDocId();
+    } else {
+      $scope.showAnalysis = false;
+      $scope.indexText = "";
+      $scope.result = {};
+    }
+  };
+
+  function addFileNode(dirs, parent, f) {
+    var path = f.split("/");
+    if (path.length === 1) {
+      if (!parent.children) {
+        parent.children = [];
+        dirs.push(parent); // now parent has children, so track in dirs ...
+      }
+      var nodeId = parent.id + "/" + f;
+      parent.children.push({"text": f, "id": nodeId, "a_attr": {"href": nodeId}});
+    } else {
+      // get the parent for this path
+      var parentId = "files/" + path.slice(0, path.length - 1).join("/");
+      var dir = null;
+      for (var d in dirs) {
+        if (dirs[d].id === parentId) {
+          dir = dirs[d];
+          break;
+        }
+      }
+      if (!dir) {
+        dir = {"text": path[0], "id": parentId, "a_attr": {"href": parentId}, "children": []};
+        dirs.push(dir);
+        parent.children.push(dir);
+      }
+
+      // walk down the next level in this path
+      addFileNode(dirs, dir, path.slice(1).join("/"));
+    }
+  }
+
+  // transform a flat list structure into the nested tree structure
+  function filesToTree(files) {
+    var filesNode = {"text": "Files", "a_attr": {"href": "files"}, "id": "files", "children": []};
+    if (files) {
+      var dirs = []; // lookup for known dirs by path since nodes don't keep a ref to their parent node
+      for (var i in files) {
+        // hide the configoverlay.json from the UI
+        if (files[i] === "configoverlay.json") {
+          continue;
+        }
+
+        addFileNode(dirs, filesNode, files[i]);
+      }
+      delete dirs;
+    }
+    return filesNode;
+  }
+
+  function fieldsToTree(fields) {
+    var children = [];
+    if (fields) {
+      for (var i in fields) {
+        var id = "field/" + fields[i].name;
+        fields[i].href = id;
+        var text = fields[i].name;
+        if (fields[i].name === $scope.uniqueKeyField) {
+          text += "*"; // unique key field
+        }
+        children.push({"text": text, "a_attr": fields[i], "id": id});
+      }
+    }
+    return children;
+  }
+
+  function fieldTypesToTree(types) {
+    var children = [];
+    for (var i in types) {
+      var ft = types[i]
+      var id = "type/" + ft.name;
+      ft.href = id;
+      children.push({"text": ft.name, "a_attr": ft, "id": id});
+    }
+    return children;
+  }
+
+  $scope.onSampleDocumentsChanged = function () {
+    $scope.hasDocsOnServer = false; // so the updates get sent on next analyze action
+  };
+
+  $scope.initDesignerSettingsFromResponse = function (data) {
+    $scope.enableDynamicFields = data.enableDynamicFields !== null ? "" + data.enableDynamicFields : "true";
+    $scope.enableFieldGuessing = data.enableFieldGuessing !== null ? "" + data.enableFieldGuessing : "true";
+    $scope.enableNestedDocs = data.enableNestedDocs !== null ? "" + data.enableNestedDocs : "false";
+    $scope.languages = data.languages !== null && data.languages.length > 0 ? data.languages : ["*"];
+    $scope.copyFrom = data.copyFrom !== null ? data.copyFrom : "_default";
+  };
+
+  $scope.doAnalyze = function (nodeId) {
+    delete $scope.sampleMessage;
+
+    var schema = $scope.currentSchema;
+    if (schema) {
+      delete $scope.copyFrom;
+    } else {
+      schema = $scope.newSchema;
+      if (!$scope.copyFrom) {
+        $scope.copyFrom = "_default";
+      }
+    }
+
+    if (!schema) {
+      return;
+    }
+
+    var params = {path: "analyze", configSet: schema};
+    if ($scope.schemaVersion && $scope.schemaVersion !== -1) {
+      params.schemaVersion = $scope.schemaVersion;
+    }
+
+    if ($scope.enableDynamicFields) {
+      params.enableDynamicFields = $scope.enableDynamicFields;
+    }
+    if ($scope.enableFieldGuessing) {
+      params.enableFieldGuessing = $scope.enableFieldGuessing;
+    }
+    if ($scope.enableNestedDocs) {
+      params.enableNestedDocs = $scope.enableNestedDocs;
+    }
+
+    if ($scope.languages && $scope.languages.length > 0) {
+      params.languages = $scope.languages;
+    }
+
+    if ($scope.copyFrom) {
+      params.copyFrom = $scope.copyFrom;
+    }
+
+    $scope.updateWorking = true;
+    if ($scope.selectedUpdated) {
+      $scope.updateStatusMessage = "Applying " + $scope.selectedType + " updates ..."
+    } else {
+      $scope.updateStatusMessage = "Analyzing your sample data, schema will load momentarily ..."
+    }
+
+    if (!nodeId && $scope.selectedNode) {
+      nodeId = $scope.selectedNode.id;
+    }
+
+    // a bit tricky ...
+    // so users can upload a file or paste in docs
+    // if they upload a file containing a small (<15K) of text data, then we'll show it in the textarea
+    // they can change the text and re-analyze too
+    // if no changes or nothing uploaded, the server-side uses the latest sample data stored in the blob store
+    if ($scope.fileUpload) {
+      var file = $scope.fileUpload;
+      var fd = new FormData();
+      fd.append('file', file);
+      SchemaDesigner.upload(params, fd, function (data) {
+        $("#upload-file").val("");
+        delete $scope.fileUpload;
+        $scope.onSchemaUpdated(schema, data, nodeId);
+      }, $scope.errorHandler);
+    } else {
+      // don't need to keep re-posting the same sample if already stored in the blob store
+      var postData = null;
+      if (!$scope.hasDocsOnServer) {
+        postData = $scope.sampleDocuments;
+        if (!postData && !$scope.published) {
+          return;
+        }
+      }
+
+      var respHandler = function (data) {
+        $scope.onSchemaUpdated(schema, data, nodeId);
+      };
+
+      // TODO: need a better approach to detecting the content type from text content
+      var contentType = "text/plain";
+      if (postData != null) {
+        var txt = postData.trim();
+        if ((txt.startsWith("[") && txt.includes("]")) || (txt.startsWith("{") && txt.includes("}"))) {
+          contentType = "application/json"
+        } else if (txt.startsWith("<") || txt.includes("<add>") || txt.includes("<!--")) {
+          contentType = "text/xml";
+        } else {
+          contentType = "application/csv";
+        }
+      }
+
+      if (contentType === "text/xml") {
+        SchemaDesigner.postXml(params, postData, respHandler, $scope.errorHandler);
+      } else if (contentType === "application/csv") {
+        SchemaDesigner.postCsv(params, postData, respHandler, $scope.errorHandler);
+      } else {
+        SchemaDesigner.post(params, postData, respHandler, $scope.errorHandler);
+      }
+    }
+  };
+
+  $scope.onFieldTypeChanged = function () {
+
+    var copyFromType = ["stored", "indexed", "multiValued", "docValues", "useDocValuesAsStored", "tokenized", "uninvertible", "termVectors", "termPositions", "termOffsets",
+      "omitNorms", "omitTermFreqAndPositions", "omitPositions", "storeOffsetsWithPositions"];
+
+    var type = $scope.selectedNode.type
+
+    // when the user updates the selected field's type, we go apply the
+    // new field type's properties to the selected node
+    for (var i in $scope.fieldTypes) {
+      if ($scope.fieldTypes[i].text == type) {
+        var ft = $scope.fieldTypes[i];
+        $scope.selectedNode.fieldType = ft;
+        for (var i in copyFromType) {
+          var x = copyFromType[i];
+          if (ft.a_attr[x] !== null) {
+            $scope.selectedNode[x] = ft.a_attr[x];
+          } else {
+            delete $scope.selectedNode[x];
+          }
+        }
+        $scope.selectedUpdated = true;
+        break;
+      }
+    }
+
+    $scope.selectedNode = applyConstraintsOnField($scope.selectedNode);
+    if ($scope.selectedUpdated) {
+      // for luke analysis, we need the type info here, not the specific field into b/c it just changed.
+      $scope.initTypeAnalysisInfo(type, "type");
+    }
+  };
+
+  $scope.isDisabled = function (dep) {
+    if (!$scope.selectedNode) {
+      return false;
+    }
+
+    if (dep === "termVectors") {
+      // termVectors dependency depends on indexed
+      return !($scope.selectedNode.indexed && $scope.selectedNode.termVectors);
+    }
+
+    if (dep === "not-text" || dep === "docValues") {
+      if ($scope.selectedNode.fieldType && $scope.selectedNode.fieldType.a_attr.class === "solr.TextField") {
+        // no doc values for TextField
+        return true;
+      }
+    }
+
+    return $scope.selectedNode[dep] === false;
+  };
+
+  // this updates checkboxes based on the current settings
+  $scope.markSelectedUpdated = function (event) {
+    delete $scope.updateStatusMessage;
+    $scope.selectedUpdated = true; // enable the update button for this field
+  };
+
+  $scope.deleteSelected = function () {
+    // console.log(">> deleteSelected");
+  };
+
+  $scope.updateSelected = function () {
+    if (!$scope.selectedNode) {
+
+      if ($scope.selectedUpdated) {
+        // some root level property changed ... re-analyze
+        $scope.doAnalyze("/");
+      }
+
+      return;
+    }
+
+    delete $scope.updateSelectedError;
+
+    // make a copy for the PUT
+    var href = $scope.selectedNode.href;
+    var id = $scope.selectedNode.id;
+
+    var putData = JSON.parse(JSON.stringify($scope.selectedNode));
+    if ($scope.selectedType === "Field" && putData.copyDest) {
+      var fields = putData.copyDest.split(",");
+      for (var f in fields) {
+        var name = fields[f].trim();
+        if (!$scope.fieldNames.includes(name)) {
+          $scope.updateSelectedError = "Copy to field '" + name + "' doesn't exist!";
+          return;
+        }
+        if (name === $scope.selectedNode.name) {
+          $scope.updateSelectedError = "Cannot copy a field to itself!";
+          return;
+        }
+      }
+    } else {
+      delete putData.copyDest;
+    }
+
+    delete putData.fieldType;
+    delete putData.href;
+    delete putData.id;
+
+    putData = applyConstraintsOnField(putData);
+
+    if ($scope.analysisJsonText && !$scope.selectedNode.type) {
+      var text = $scope.analysisJsonText.trim();
+      if (text.length > 0) {
+        text = text.replace(/\s+/g, ' ');
+        if (!text.startsWith("{")) {
+          text = "{ " + text + " }";
+        }
+        try {
+          var textJson = JSON.parse(text);
+          if (textJson.analyzer) {
+            putData.analyzer = textJson.analyzer;
+          } else {
+            if (textJson.indexAnalyzer && textJson.queryAnalyzer) {
+              putData.indexAnalyzer = textJson.indexAnalyzer;
+              putData.queryAnalyzer = textJson.queryAnalyzer;
+            }
+          }
+        } catch (e) {
+          $scope.updateSelectedError = "Failed to parse analysis as JSON due to: " + e.message +
+              "; expected JSON object with either an 'analyzer' or 'indexAnalyzer' and 'queryAnalyzer'";
+          return;
+        }
+      }
+    }
+
+    $scope.updateWorking = true;
+    $scope.updateStatusMessage = "Updating " + $scope.selectedType + " ...";
+
+    SchemaDesigner.put({
+      path: "update",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, putData, function (data) {
+
+      var nodeType = data.updateType;
+      $scope.schemaVersion = data.schemaVersion;
+      $scope.currentSchema = data.configSet;
+      $scope.core = data.core;
+
+      $scope.selectedNode = data[nodeType];
+      $scope.selectedNode.href = href;
+      $scope.selectedNode.id = id;
+
+      var name = nodeType === "field" ? $scope.selectedNode.type : $scope.selectedNode.name;
+      $scope.initTypeAnalysisInfo(name, "type");
+      $scope.showFieldDetails = true;
+
+      if (nodeType === "field" && $scope.selectedNode.tokenized) {
+        $scope.showAnalysis = true;
+        $scope.updateSampleDocId();
+      }
+
+      $scope.onSchemaUpdated($scope.currentSchema, data, href);
+    }, $scope.errorHandler);
+  };
+
+  // TODO: These are copied from analysis.js, so move to a shared location for both vs. duplicating
+  var getShortComponentName = function (longname) {
+    var short = -1 !== longname.indexOf('$')
+        ? longname.split('$')[1]
+        : longname.split('.').pop();
+    return short.match(/[A-Z]/g).join('');
+  };
+
+  var getCaptionsForComponent = function (data) {
+    var captions = [];
+    for (var key in data[0]) {
+      key = key.replace(/.*#/, '');
+      if (key != "match" && key != "positionHistory") {
+        captions.push(key.replace(/.*#/, ''));
+      }
+    }
+    return captions;
+  };
+
+  var getTokensForComponent = function (data) {
+    var tokens = [];
+    var previousPosition = 0;
+    var index = 0;
+    for (var i in data) {
+      var tokenhash = data[i];
+      var positionDifference = tokenhash.position - previousPosition;
+      for (var j = positionDifference; j > 1; j--) {
+        tokens.push({position: tokenhash.position - j + 1, blank: true, index: index++});
+      }
+
+      var token = {position: tokenhash.position, keys: [], index: index++};
+
+      for (key in tokenhash) {
+        if (key == "match" || key == "positionHistory") {
+          //skip, to not display these keys in the UI
+        } else {
+          var tokenInfo = new Object();
+          tokenInfo.name = key;
+          tokenInfo.value = tokenhash[key];
+          if ('text' === key || 'raw_bytes' === key) {
+            if (tokenhash.match) {
+              tokenInfo.extraclass = 'match'; //to highlight matching text strings
+            }
+          }
+          token.keys.push(tokenInfo);
+        }
+      }
+      tokens.push(token);
+      previousPosition = tokenhash.position;
+    }
+    return tokens;
+  };
+
+  var extractComponents = function (data, result, name) {
+    if (data) {
+      result[name] = [];
+      for (var i = 0; i < data.length; i += 2) {
+        var component = {
+          name: data[i],
+          short: getShortComponentName(data[i]),
+          captions: getCaptionsForComponent(data[i + 1]),
+          tokens: getTokensForComponent(data[i + 1])
+        };
+        result[name].push(component);
+      }
+    }
+  };
+
+  var processFieldAnalysisData = function (analysis) {
+    var response = {};
+    extractComponents(analysis.index, response, "index");
+    return response;
+  };
+
+  $scope.doPublish = function () {
+    var params = {
+      path: "publish",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion,
+      reloadCollections: $scope.reloadOnPublish,
+      cleanupTemp: true,
+      disableDesigner: $scope.disableDesigner
+    };
+    if ($scope.newCollection && $scope.newCollection.name) {
+      params.newCollection = $scope.newCollection.name;
+      params.numShards = $scope.newCollection.numShards;
+      params.replicationFactor = $scope.newCollection.replicationFactor;
+      params.indexToCollection = $scope.newCollection.indexToCollection;
+    }
+    SchemaDesigner.put(params, null, function (data) {
+      $scope.schemaVersion = data.schemaVersion;
+      $scope.currentSchema = data.configSet;
+
+      delete $scope.selectedNode;
+      $scope.currentSchema = "";
+      delete $scope.newSchema;
+      $scope.showPublish = false;
+      $scope.refresh();
+
+      if (data.newCollection) {
+        $window.location.href = "#/" + data.newCollection + "/collection-overview";
+      }
+    }, $scope.errorHandler);
+  };
+
+  $scope.downloadConfig = function () {
+    // have to use an AJAX request so we can supply the Authorization header
+    if (sessionStorage.getItem("auth.header")) {
+      var fileName = $scope.currentSchema+"_configset.zip";
+      var xhr = new XMLHttpRequest();
+      xhr.open("GET", "/api/schema-designer/download/"+fileName+"?wt=raw&configSet="+$scope.currentSchema, true);
+      xhr.setRequestHeader('Authorization', sessionStorage.getItem("auth.header"));
+      xhr.responseType = 'blob';
+      xhr.addEventListener('load',function() {
+        if (xhr.status === 200) {
+          var url = window.URL.createObjectURL(xhr.response);
+          var a = document.createElement('a');
+          a.href = url;
+          a.download = fileName;
+          document.body.append(a);
+          a.click();
+          a.remove();
+          window.URL.revokeObjectURL(url);
+        }
+      })
+      xhr.send();
+    } else {
+      location.href = "/api/schema-designer/download/"+$scope.currentSchema+"_configset.zip?wt=raw&configSet=" + $scope.currentSchema;
+    }
+  };
+
+  function docsToTree(docs) {
+    var children = [];
+    for (var i in docs) {
+      var id = docs[i][$scope.uniqueKeyField];
+      if (!id) {
+        id = "" + i; // id not in results so use the position in results as the value
+      }
+      var nodeId = "doc/" + id;
+      docs[i].href = nodeId;
+      children.push({"text": id, "a_attr": docs[i], "id": nodeId});
+    }
+    return children;
+  }
+
+  function debugToTree(debugObj) {
+    var children = [];
+    for (var x in debugObj) {
+      if (typeof debugObj[x] === 'object') {
+        var obj = debugObj[x];
+        var nodeId = "debug/" + x;
+        var tdata = [];
+        for (var a in obj) {
+          if (typeof obj[a] !== 'object') {
+            tdata.push({name: a, value: obj[a]});
+          }
+        }
+        children.push({"text": x, "a_attr": {"href": nodeId}, "tdata": tdata, "id": nodeId, "children": []});
+      }
+    }
+    return children;
+  }
+
+  function facetsToTree(ff) {
+    var children = [];
+    for (var f in ff) {
+      var nodeId = "facet/" + f;
+      if (ff[f] && ff[f].length > 0) {
+        var facet = ff[f];
+        var tdata = [];
+        for (let i = 0; i < facet.length; i += 2) {
+          tdata.push({name: facet[i], value: facet[i + 1]});
+        }
+        children.push({"text": f, "a_attr": {"href": nodeId}, "tdata": tdata, "id": nodeId, "children": []});
+      }
+    }
+    return children;
+  }
+
+  function hlToTree(hl) {
+    var children = [];
+    for (var f in hl) {
+      var nodeId = "hl/" + f;
+      var tdata = [];
+      var obj = hl[f];
+      for (var a in obj) {
+        var v = obj[a];
+        if (v && v.length > 0) {
+          tdata.push({name: a, value: v[0]});
+        }
+      }
+      if (tdata.length > 0) {
+        children.push({"text": f, "a_attr": {"href": nodeId}, "tdata": tdata, "id": nodeId, "children": []});
+      }
+    }
+    return children;
+  }
+
+  $scope.selectField = function (event) {
+    var t = event.target || event.srcElement || event.currentTarget;
+    if (t && t.text) {
+      var nodeId = "field/" + t.text;
+      $scope.onSelectSchemaTreeNode(nodeId);
+      $scope.selectNodeInTree(nodeId);
+    }
+  };
+
+  $scope.selectFieldType = function (event) {
+    var t = event.target || event.srcElement || event.currentTarget;
+    if (t && t.text) {
+      var nodeId = "type/" + t.text;
+      $scope.onSelectSchemaTreeNode(nodeId);
+      $scope.selectNodeInTree(nodeId);
+    }
+  };
+
+  function omitVersionField(key,value) {
+    return (key === "_version_") ? undefined : value;
+  }
+
+  $scope.editDocuments = function() {
+    delete $scope.helpId;
+    if ($scope.queryDocs) {
+      $scope.hasDocsOnServer = false; // so the updated docs apply
+      $("#upload-file").val("");
+      delete $scope.fileUpload;
+      $scope.sampleDocuments = $scope.queryDocs;
+      $scope.onSampleDocumentsChanged();
+    }
+  };
+
+  $scope.renderResultsTree = function (data) {
+    var h = data.responseHeader;
+    var sort = h.params.sort;
+    if (!sort) {
+      sort = "score desc";
+    }
+
+    if (data.response && data.response.docs && data.response.docs.length > 0) {
+      $scope.queryDocs = JSON.stringify(data.response.docs, omitVersionField, 2);
+    } else {
+      delete $scope.queryDocs;
+    }
+
+    $scope.resultsMeta = [
+      {name: "Query", value: h.params.q},
+      {name: "QTime", value: h.QTime},
+      {name: "Hits", value: data.response.numFound},
+      {name: "sort", value: sort},
+    ];
+
+    var excParams = ["q", "handler", "debug", "configSet", "wt", "version", "_", "sort"];
+    for (var p in h.params) {
+      if (!excParams.includes(p)) {
+        $scope.resultsMeta.push({name: p, value: h.params[p]});
+      }
+    }
+
+    $scope.debugMeta = [];
+    for (var d in data.debug) {
+      if (typeof data.debug[d] !== 'object') {
+        var nvp = {name: d, value: data.debug[d]};
+        $scope.debugMeta.push(nvp);
+        $scope.resultsMeta.push(nvp);
+      }
+    }
+
+    var rootChildren = [{
+      "id":"docs",
+      "text": "Documents",
+      "state": {"opened": true},
+      "a_attr": {"href": "docs"},
+      "children": docsToTree(data.response.docs)
+    }];
+
+    if (data.facet_counts && data.facet_counts.facet_fields) {
+      rootChildren.push({
+        "id":"facets",
+        "text": "Facets",
+        "state": {"opened": true},
+        "a_attr": {"href": "facets"},
+        "children": facetsToTree(data.facet_counts.facet_fields)
+      });
+    }
+
+    if (data.highlighting) {
+      var hlNodes = hlToTree(data.highlighting);
+      if (hlNodes.length > 0) {
+        rootChildren.push({
+          "id":"hl",
+          "text": "Highlighting",
+          "state": {"opened": true},
+          "a_attr": {"href": "hl"},
+          "children": hlNodes
+        });
+      }
+    }
+
+    if (data.debug) {
+      rootChildren.push({"id":"debug", "text": "Debug", "a_attr": {"href": "debug"}, "children": debugToTree(data.debug)});
+    }
+
+    var tree = [{"id":"/","text": "Results", "a_attr": {"href": "/"}, "children": rootChildren}];
+    $scope.queryResultsTree = tree;
+  };
+
+  $scope.onSelectQueryResultsNode = function (id) {
+    $scope.selectedResultsNode = id;
+
+    if (id === "/" || id === "docs") {
+      $scope.resultsData = $scope.resultsMeta;
+      return;
+    }
+
+    if (id === "debug") {
+      $scope.resultsData = $scope.debugMeta;
+      return;
+    }
+
+    var jst = $('#queryResultsJsTree').jstree();
+    var node = jst.get_node(id);
+    if (!node || !node.a_attr) {
+      $scope.resultsData = $scope.resultsMeta;
+      return;
+    }
+
+    if (node.original && node.original.tdata) {
+      $scope.resultsData = node.original.tdata;
+    } else {
+      $scope.resultsData = [];
+      for (var a in node.a_attr) {
+        if (a === "href") continue;
+        var row = {name: a, value: node.a_attr[a]};
+        if (id.startsWith("doc/")) {
+          row.type = "f"; // so we can link to fields in the schema tree from results!
+        }
+
+        $scope.resultsData.push(row);
+      }
+    }
+
+    if (id.startsWith("doc/")) {
+      $scope.sampleDocId = id.substring(4);
+      $scope.updateSampleDocId();
+    }
+  };
+
+  $scope.doQuery = function () {
+
+    delete $scope.queryDocs;
+
+    var params = {path: "query", configSet: $scope.currentSchema, debug: "true", "wt": "json"};
+
+    if ($scope.selectedFacets && $scope.selectedFacets.length > 0) {
+      params["facet"] = true;
+      params["facet.field"] = $scope.selectedFacets;
+      params["facet.limit"] = 20;
+      params["facet.mincount"] = 1;
+    } else {
+      params["facet"] = false;
+      delete params["facet.field"];
+    }
+
+    var set = function (key, value) {
+      if (params[key]) {
+        params[key].push(value);
+      } else {
+        params[key] = [value];
+      }
+    }
+
+    params["sort"] = $scope.query.sortBy + " " + $scope.query.sortDir;
+    params["q"] = $scope.query.q.trim();
+    if (!params["q"]) {
+      params["q"] = "*:*";
+    }
+
+    if ($scope.rawParams) {
+      var rawParams = $scope.rawParams.split(/[&\n]/);
+      for (var i in rawParams) {
+        var param = rawParams[i];
+        var equalPos = param.indexOf("=");
+        if (equalPos > -1) {
+          set(param.substring(0, equalPos), param.substring(equalPos + 1));
+        } else {
+          set(param, ""); // Use empty value for params without "="
+        }
+      }
+    }
+
+    if (params["q"] !== '*:*' && $scope.query.highlight) {
+      params["hl"] = true;
+      params["hl.fl"] = $scope.query.highlight;
+      if (!params["hl.method"]) {
+        // lookup the field props
+        var method = "unified";
+        var field = $scope.fields.find(f => f.name === $scope.query.highlight);
+        if (field) {
+          if (field.termVectors && field.termOffsets && field.termPositions) {
+            method = "fastVector";
+          }
+        }
+        params["hl.method"] = method;
+      }
+    } else {
+      delete params["hl"];
+      delete params["hl.fl"];
+    }
+
+    var qt = params["qt"] ? params["qt"] : "/select";
+    if (qt[0] === '/') {
+      params.handler = qt.substring(1);
+    } else { // Support legacy style handleSelect=true configs
+      params.handler = "select";
+      params["qt"] = qt;
+    }
+
+    SchemaDesigner.get(params, function (data) {
+      $("#sort").trigger("chosen:updated");
+      $("#ff").trigger("chosen:updated");
+      $("#hl").trigger("chosen:updated");
+
+      $scope.renderResultsTree(data);
+
+      var nodeId = "/";
+      if ($scope.sampleDocId) {
+        if (data.response.docs) {
+          var hit = data.response.docs.find(d => d[$scope.uniqueKeyField] === $scope.sampleDocId);
+          if (hit) {
+            nodeId = "doc/"+$scope.sampleDocId;
+          }
+        }
+      }
+      $scope.onSelectQueryResultsNode(nodeId);
+    });
+  };
+
+  $scope.toggleShowAnalysisJson = function () {
+    if ($scope.showAnalysisJson) {
+      $scope.showAnalysisJson = false;
+      $scope.editAnalysis = "Edit JSON";
+    } else {
+      $scope.showAnalysisJson = true;
+      $scope.editAnalysis = "Hide JSON";
+
+      var node = $scope.selectedNode;
+      var analysisJson = {};
+      if (node.analyzer) {
+        analysisJson.analyzer = node.analyzer;
+      } else {
+        if (node.indexAnalyzer) {
+          analysisJson.indexAnalyzer = node.indexAnalyzer;
+        }
+        if (node.queryAnalyzer) {
+          analysisJson.queryAnalyzer = node.queryAnalyzer;
+        }
+      }
+      $scope.analysisJsonText = JSON.stringify(analysisJson, null, 2);
+    }
+  };
+
+  function filterFieldsByType(fieldsSrc, typeFilter) {
+    var children = [];
+    for (var f in fieldsSrc) {
+      var field = fieldsSrc[f];
+      if (field.a_attr && field.a_attr.type === typeFilter) {
+        children.push(field);
+      }
+    }
+    return children;
+  }
+
+  function filterFieldsByFeature(fieldsSrc, opt, enabled) {
+    var children = [];
+    var isEnabled = enabled === "true";
+    for (var f in fieldsSrc) {
+      var field = fieldsSrc[f];
+      if (!field.a_attr) {
+        continue;
+      }
+      var attr = field.a_attr;
+      if (opt === "indexed") {
+        if (attr.indexed === isEnabled) {
+          children.push(field);
+        }
+      } else if (opt === "text") {
+        if (attr.tokenized === isEnabled) {
+          children.push(field);
+        }
+      } else if (opt === "facet") {
+        if (((attr.indexed || attr.docValues) && !attr.tokenized && attr.name !== '_version_') === isEnabled) {
+          children.push(field);
+        }
+      } else if (opt === "highlight") {
+        if ((attr.stored && attr.tokenized) === isEnabled) {
+          children.push(field);
+        }
+      } else if (opt === "sortable") {
+        if (((attr.indexed || attr.docValues) && !attr.multiValued) === isEnabled) {
+          children.push(field);
+        }
+      } else if (opt === "docValues") {
+        if (attr.docValues === isEnabled) {
+          children.push(field);
+        }
+      } else if (opt === "stored") {
+        if ((attr.stored || (attr.docValues && attr.useDocValuesAsStored)) === isEnabled) {
+          children.push(field);
+        }
+      }
+    }
+    return children;
+  }
+
+  $scope.findSelectedNodeId = function() {
+    var nodeId = "/";
+    if ($scope.fieldsNode.children.length > 0) {
+      if ($scope.selectedNode) {
+        var found = $scope.fieldsNode.children.find(n => n.a_attr.name === $scope.selectedNode.name);
+        if (found) {
+          nodeId = $scope.selectedNode.href;
+        } else {
+          delete $scope.selectedNode;
+          nodeId = $scope.fieldsNode.children[0].a_attr.href;
+        }
+      } else {
+        nodeId = $scope.fieldsNode.children[0].a_attr.href;
+      }
+    }
+    return nodeId;
+  };
+
+  $scope.onTreeFilterOptionChanged = function() {
+    if (!$scope.fieldsNode) {
+      return;
+    }
+
+    if (!$scope.treeFilter || !$scope.treeFilterOption || $scope.treeFilterOption === "*") {
+      // restore tree to unfiltered state
+      $scope.fieldsNode.children = $scope.fieldsSrc;
+      if ($scope.dynamicFieldsNode) {
+        $scope.dynamicFieldsNode.children = $scope.dynamicFieldsSrc;
+      }
+      $scope.refreshTree();
+      return;
+    }
+
+    if ($scope.treeFilter === "type") {
+      $scope.fieldsNode.children = filterFieldsByType($scope.fieldsSrc, $scope.treeFilterOption);
+      if ($scope.dynamicFieldsNode) {
+        $scope.dynamicFieldsNode.children = filterFieldsByType($scope.dynamicFieldsSrc, $scope.treeFilterOption);
+      }
+    } else if ($scope.treeFilter === "feature") {
+      $scope.fieldsNode.children = filterFieldsByFeature($scope.fieldsSrc, $scope.treeFilterOption, $scope.treeFilterFeatureEnabled);
+      if ($scope.dynamicFieldsNode) {
+        $scope.dynamicFieldsNode.children = filterFieldsByFeature($scope.dynamicFieldsSrc, $scope.treeFilterOption, $scope.treeFilterFeatureEnabled);
+      }
+    } else {
+      // otherwise, restore the tree to original state
+      $scope.fieldsNode.children = $scope.fieldsSrc;
+      if ($scope.dynamicFieldsNode) {
+        $scope.dynamicFieldsNode.children = $scope.dynamicFieldsSrc;
+      }
+    }
+    $scope.refreshTree();
+
+    var nodeId = $scope.findSelectedNodeId();
+    $scope.onSelectSchemaTreeNode(nodeId);
+    $scope.selectNodeInTree(nodeId);
+  };
+
+  $scope.initTreeFilters = function() {
+    $scope.treeFilterFeatureEnabled = "true";
+    $scope.treeFilterOptions = [];
+    $scope.treeFilterOption = "";
+    if ($scope.treeFilter === "type") {
+      var usedFieldTypes = [];
+      if ($scope.fields) {
+        var usedTypes = $scope.fields.map(f => f.type);
+        for (var t in usedTypes) {
+          if (!usedFieldTypes.includes(usedTypes[t])) {
+            usedFieldTypes.push(usedTypes[t]);
+          }
+        }
+      }
+      $scope.treeFilterOptions = usedFieldTypes.sort();
+      $scope.treeFilterOptions.unshift("*");
+      $scope.treeFilterOption = "*";
+    } else if ($scope.treeFilter === "feature") {
+      $scope.treeFilterOptions = ["indexed","text","facet","highlight","sortable","docValues","stored"].sort();
+      $scope.treeFilterOptions.unshift("*");
+      $scope.treeFilterOption = "*";
+    }
+  };
+
+  $scope.applyTreeFilterOption = function() {
+    if (!$scope.fields) {
+      return;
+    }
+    $scope.initTreeFilters();
+    $scope.onTreeFilterOptionChanged();
+  };
+
+  $scope.refresh();
+})

Review comment:
       *W033:*  Missing semicolon.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/security.js
##########
@@ -0,0 +1,1124 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cookies, $window, Constants, System, Security) {
+  $scope.resetMenu("security", Constants.IS_ROOT_PAGE);
+
+  $scope.params = [];
+
+  var strongPasswordRegex = /^(?=.*[0-9])(?=.*[!@#$%^&*\-_()[\]])[a-zA-Z0-9!@#$%^&*\-_()[\]]{8,30}$/;
+
+  function toList(str) {
+    if (Array.isArray(str)) {
+      return str; // already a list
+    }
+    return str.trim().split(",").map(s => s.trim()).filter(s => s !== "");
+  }
+
+  function asList(listOrStr) {
+    return Array.isArray(listOrStr) ? listOrStr : (listOrStr ? [listOrStr] : []);
+  }
+
+  function transposeUserRoles(userRoles) {
+    var roleUsers = {};
+    for (var u in userRoles) {
+      var roleList = asList(userRoles[u]);
+      for (var i in roleList) {
+        var role = roleList[i];
+        if (!roleUsers[role]) roleUsers[role] = []
+        roleUsers[role].push(u);
+      }
+    }
+
+    var roles = [];
+    for (var r in roleUsers) {
+      roles.push({"name":r, "users":Array.from(new Set(roleUsers[r]))});
+    }
+    return roles.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  function roleMatch(roles, rolesForUser) {
+    for (r in rolesForUser) {
+      if (roles.includes(rolesForUser[r]))
+        return true;
+    }
+    return false;
+  }
+
+  function permRow(perm, i) {
+    var roles = asList(perm.role);
+    var paths = asList(perm.path);
+
+    var collectionNames = "";
+    var collections = [];
+    if ("collection" in perm) {
+      if (perm["collection"] == null) {
+        collectionNames = "null";
+      } else {
+        collections = asList(perm.collection);
+        collectionNames = collections.sort().join(", ");
+      }
+    } else {
+      // no collection property on the perm, so the default "*" applies
+      collectionNames = "";
+      collections.push("*");
+    }
+
+    var method = asList(perm.method);
+
+    // perms don't always have an index ?!?
+    var index = "index" in perm ? perm["index"] : ""+i;
+
+    return { "index": index, "name": perm.name, "collectionNames": collectionNames, "collections": collections,
+      "roles": roles, "paths": paths, "method": method, "params": perm.params };
+  }
+
+  function checkError(data) {
+    var cause = null;
+    if ("errorMessages" in data && Array.isArray(data["errorMessages"]) && data["errorMessages"].length > 0) {
+      cause = "?";
+      if ("errorMessages" in data["errorMessages"][0] && Array.isArray(data["errorMessages"][0]["errorMessages"]) && data["errorMessages"][0]["errorMessages"].length > 0) {
+        cause = data["errorMessages"][0]["errorMessages"][0];
+      }
+    }
+    return cause;
+  }
+
+  function truncateTo(str, maxLen, delim) {
+    // allow for a little on either side of maxLen for better display
+    var varLen = Math.min(Math.round(maxLen * 0.1), 15);
+    if (str.length <= maxLen + varLen) {
+      return str;
+    }
+
+    var total = str.split(delim).length;
+    var at = str.indexOf(delim, maxLen - varLen);
+    str = (at !== -1 && at < maxLen + varLen) ? str.substring(0, at) : str.substring(0, maxLen);
+    var trimmed = str.split(delim).length;
+    var diff = total - trimmed;
+    str += " ... "+(diff > 1 ? "(+"+diff+" more)" : "");
+    return str;
+  }
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.securityAPIError;
+    delete $scope.securityAPIErrorDetails;
+  };
+
+  $scope.displayList = function(listOrStr) {
+    if (!listOrStr) return "";
+    var str = Array.isArray(listOrStr) ? listOrStr.sort().join(", ") : (""+listOrStr).trim();
+    return truncateTo(str, 160, ", ");
+  };
+
+  $scope.displayParams = function(obj) {
+    if (!obj) return "";
+    if (Array.isArray(obj)) return obj.sort().join(", ");
+
+    var display = "";
+    for (const [key, value] of Object.entries(obj)) {
+      if (display.length > 0) display += "; ";
+      display += (key + "=" + (Array.isArray(value)?value.sort().join(","):value+""));
+    }
+    return truncateTo(display, 160, "; ");
+  };
+
+  $scope.displayRoles = function(obj) {
+    return (!obj || (Array.isArray(obj) && obj.length === 0)) ? "null" : $scope.displayList(obj);
+  };
+
+  $scope.predefinedPermissions = ["collection-admin-edit", "collection-admin-read", "core-admin-read", "core-admin-edit", "zk-read",
+    "read", "update", "all", "config-edit", "config-read", "schema-read", "schema-edit", "security-edit", "security-read",
+    "metrics-read", "filestore-read", "filestore-write", "package-edit", "package-read"].sort();
+
+  $scope.predefinedPermissionCollection = {"read":"*", "update":"*", "config-edit":"*", "config-read":"*", "schema-edit":"*", "schema-read":"*"};
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error && error.msg) {
+      $scope.securityAPIError = error.msg;
+      $scope.securityAPIErrorDetails = e.data.errorDetails;
+    } else if (e.data && e.data.message) {
+      $scope.securityAPIError = e.data.message;
+      $scope.securityAPIErrorDetails = JSON.stringify(e.data);
+    }
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.hideAll();
+
+    $scope.tls = false;
+    $scope.blockUnknown = "false"; // default setting
+    $scope.realmName = "solr";
+    $scope.forwardCredentials = "false";
+
+    $scope.currentUser = sessionStorage.getItem("auth.username");
+
+    $scope.userFilter = "";
+    $scope.userFilterOption = "";
+    $scope.userFilterText = "";
+    $scope.userFilterOptions = [];
+
+    $scope.permFilter = "";
+    $scope.permFilterOption = "";
+    $scope.permFilterOptions = [];
+    $scope.permFilterTypes = ["", "name", "role", "path", "collection"];
+
+    System.get(function(data) {
+      $scope.tls = data.security ? data.security["tls"] : false;
+      $scope.authenticationPlugin = data.security ? data.security["authenticationPlugin"] : null;
+      $scope.authorizationPlugin = data.security ? data.security["authorizationPlugin"] : null;
+      $scope.myRoles = data.security ? data.security["roles"] : [];
+      $scope.isSecurityAdminEnabled = $scope.authenticationPlugin != null;
+      $scope.isCloudMode = data.mode.match( /solrcloud/i ) != null;
+      $scope.zkHost = $scope.isCloudMode ? data["zkHost"] : "";
+      $scope.solrHome = data["solr_home"];
+      $scope.refreshSecurityPanel();
+    }, function(e) {
+      if (e.status === 401 || e.status === 403) {
+        $scope.isSecurityAdminEnabled = true;
+        $scope.hasSecurityEditPerm = false;
+        $scope.hideAll();
+      }
+    });
+  };
+
+  $scope.hideAll = function () {
+    // add more dialogs here
+    delete $scope.validationError;
+    $scope.showUserDialog = false;
+    $scope.showPermDialog = false;
+    delete $scope.helpId;
+  };
+
+  $scope.getCurrentUserRoles = function() {
+    if ($scope.manageUserRolesEnabled) {
+      return Array.isArray($scope.userRoles[$scope.currentUser]) ? $scope.userRoles[$scope.currentUser] : [$scope.userRoles[$scope.currentUser]];
+    } else {
+      return $scope.myRoles;
+    }
+  };
+
+  $scope.hasPermission = function(permissionName) {
+    var rolesForPermission = $scope.permissionsTable.filter(p => permissionName === p.name).flatMap(p => p.roles);
+    return (rolesForPermission.length > 0 && roleMatch(rolesForPermission, $scope.getCurrentUserRoles()));
+  };
+
+  $scope.refreshSecurityPanel = function() {
+
+    // determine if the authorization plugin supports CRUD permissions
+    $scope.managePermissionsEnabled =
+        ($scope.authorizationPlugin === "org.apache.solr.security.RuleBasedAuthorizationPlugin" ||
+            $scope.authorizationPlugin === "org.apache.solr.security.ExternalRoleRuleBasedAuthorizationPlugin");
+
+    // don't allow CRUD on roles if using external
+    $scope.manageUserRolesEnabled = $scope.authorizationPlugin === "org.apache.solr.security.RuleBasedAuthorizationPlugin";
+
+    Security.get({path: "authorization"}, function (data) {
+      if (!data.authorization) {
+        $scope.isSecurityAdminEnabled = false;
+        $scope.hasSecurityEditPerm = false;
+        return;
+      }
+
+      if ($scope.manageUserRolesEnabled) {
+        $scope.userRoles = data.authorization["user-role"];
+        $scope.roles = transposeUserRoles($scope.userRoles);
+        $scope.filteredRoles = $scope.roles;
+        $scope.roleNames = $scope.roles.map(r => r.name).sort();
+        $scope.roleNamesWithWildcard = ["*"].concat($scope.roleNames);
+        if (!$scope.permFilterTypes.includes("user")) {
+          $scope.permFilterTypes.push("user"); // can only filter perms by user if we have a role to user mapping
+        }
+      } else {
+        $scope.userRoles = {};
+        $scope.roles = [];
+        $scope.filteredRoles = [];
+        $scope.roleNames = [];
+      }
+
+      $scope.permissions = data.authorization["permissions"];
+      $scope.permissionsTable = [];
+      for (p in $scope.permissions) {
+        $scope.permissionsTable.push(permRow($scope.permissions[p], parseInt(p)+1));
+      }
+      $scope.filteredPerms = $scope.permissionsTable;
+
+      $scope.hasSecurityEditPerm = $scope.hasPermission("security-edit");
+      $scope.hasSecurityReadPerm = $scope.hasSecurityEditPerm || $scope.hasPermission("security-read");
+
+      if ($scope.authenticationPlugin === "org.apache.solr.security.BasicAuthPlugin") {
+        $scope.manageUsersEnabled = true;
+
+        Security.get({path: "authentication"}, function (data) {
+          if (!data.authentication) {
+            // TODO: error msg
+            $scope.manageUsersEnabled = false;
+          }
+
+          $scope.blockUnknown = data.authentication["blockUnknown"] === true ? "true" : "false";
+          $scope.forwardCredentials = data.authentication["forwardCredentials"] === true ? "true" : "false";
+
+          if ("realm" in data.authentication) {
+            $scope.realmName = data.authentication["realm"];
+          }
+
+          var users = [];
+          if (data.authentication.credentials) {
+            for (var u in data.authentication.credentials) {
+              var roles = $scope.userRoles[u];
+              if (!roles) roles = [];
+              users.push({"username":u, "roles":roles});
+            }
+          }
+          $scope.users = users.sort((a, b) => (a.username > b.username) ? 1 : -1);
+          $scope.filteredUsers = $scope.users.slice(0,100); // only display first 100
+        }, $scope.errorHandler);
+      } else {
+        $scope.users = [];
+        $scope.filteredUsers = $scope.users;
+        $scope.manageUsersEnabled = false;
+      }
+    }, $scope.errorHandler);
+  };
+
+  $scope.validatePassword = function() {
+    var password = $scope.upsertUser.password.trim();
+    var password2 = $scope.upsertUser.password2 ? $scope.upsertUser.password2.trim() : "";
+    if (password !== password2) {
+      $scope.validationError = "Passwords do not match!";
+      return false;
+    }
+
+    if (!password.match(strongPasswordRegex)) {
+      $scope.validationError = "Password not strong enough! Must contain at least one lowercase letter, one uppercase letter, one digit, and one of these special characters: !@#$%^&*_-[]()";
+      return false;
+    }
+
+    return true;
+  };
+
+  $scope.updateUserRoles = function() {
+    var setUserRoles = {};
+    var roles = [];
+    if ($scope.upsertUser.selectedRoles) {
+      roles = roles.concat($scope.upsertUser.selectedRoles);
+    }
+    if ($scope.upsertUser.newRole && $scope.upsertUser.newRole.trim() !== "") {
+      var newRole = $scope.upsertUser.newRole.trim();
+      if (newRole !== "null" && newRole !== "*" && newRole.length <= 30) {
+        roles.push(newRole);
+      } // else, no new role for you!
+    }
+    var userRoles = Array.from(new Set(roles));
+    setUserRoles[$scope.upsertUser.username] = userRoles.length > 0 ? userRoles : null;
+    Security.post({path: "authorization"}, { "set-user-role": setUserRoles }, function (data) {
+      $scope.toggleUserDialog();
+      $scope.refreshSecurityPanel();
+    });
+  };
+
+  $scope.doUpsertUser = function() {
+    if (!$scope.upsertUser) {
+      delete $scope.validationError;
+      $scope.showUserDialog = false;
+      return;
+    }
+
+    if (!$scope.upsertUser.username || $scope.upsertUser.username.trim() === "") {
+      $scope.validationError = "Username is required!";
+      return;
+    }
+
+    // keep username to a reasonable length? but allow for email addresses
+    var username = $scope.upsertUser.username.trim();
+    if (username.length > 30) {
+      $scope.validationError = "Username must be 30 characters or less!";
+      return;
+    }
+
+    var doSetUser = false;
+    if ($scope.userDialogMode === 'add') {
+      if ($scope.users) {
+        var existing = $scope.users.find(u => u.username === username);
+        if (existing) {
+          $scope.validationError = "User '"+username+"' already exists!";
+          return;
+        }
+      }
+
+      if (!$scope.upsertUser.password) {
+        $scope.validationError = "Password is required!";
+        return;
+      }
+
+      if (!$scope.validatePassword()) {
+        return;
+      }
+      doSetUser = true;
+    } else {
+      if ($scope.upsertUser.password) {
+        if ($scope.validatePassword()) {
+          doSetUser = true;
+        } else {
+          return; // update to password is invalid
+        }
+      } // else no update to password
+    }
+
+    if ($scope.upsertUser.newRole && $scope.upsertUser.newRole.trim() !== "") {
+      var newRole = $scope.upsertUser.newRole.trim();
+      if (newRole === "null" || newRole === "*" || newRole.length > 30) {
+        $scope.validationError = "Invalid new role: "+newRole;
+        return;
+      }
+    }
+
+    delete $scope.validationError;
+
+    if (doSetUser) {
+      var setUserJson = {};
+      setUserJson[username] = $scope.upsertUser.password.trim();
+      Security.post({path: "authentication"}, { "set-user": setUserJson }, function (data) {
+
+        var errorCause = checkError(data);
+        if (errorCause != null) {
+          $scope.securityAPIError = "create user "+username+" failed due to: "+errorCause;
+          $scope.securityAPIErrorDetails = JSON.stringify(data);
+          return;
+        }
+
+        $scope.updateUserRoles();
+      });
+    } else {
+      $scope.updateUserRoles();
+    }
+  };
+
+  $scope.confirmDeleteUser = function() {
+    if (window.confirm("Confirm delete the '"+$scope.upsertUser.username+"' user?")) {
+      // remove all roles for the user and the delete the user
+      var removeRoles = {};
+      removeRoles[$scope.upsertUser.username] = null;
+      Security.post({path: "authorization"}, { "set-user-role": removeRoles }, function (data) {
+        Security.post({path: "authentication"}, {"delete-user": [$scope.upsertUser.username]}, function (data2) {
+          $scope.toggleUserDialog();
+          $scope.refreshSecurityPanel();
+        });
+      });
+    }
+  };
+
+  $scope.showAddUserDialog = function() {
+    $scope.userDialogMode = "add";
+    $scope.userDialogHeader = "Add New User";
+    $scope.userDialogAction = "Add User";
+    $scope.upsertUser = {};
+    $scope.toggleUserDialog();
+  };
+
+  $scope.toggleUserDialog = function() {
+    if ($scope.showUserDialog) {
+      delete $scope.upsertUser;
+      delete $scope.validationError;
+      $scope.showUserDialog = false;
+      return;
+    }
+
+    $scope.hideAll();
+    $('#user-dialog').css({left: 132, top: 132});
+    $scope.showUserDialog = true;
+  };
+
+  $scope.onPredefinedChanged = function() {
+    if (!$scope.upsertPerm) {
+      return;
+    }
+
+    if ($scope.upsertPerm.name && $scope.upsertPerm.name.trim() !== "") {
+      delete $scope.selectedPredefinedPermission;
+    } else {
+      $scope.upsertPerm.name = "";
+    }
+
+    if ($scope.selectedPredefinedPermission && $scope.selectedPredefinedPermission in $scope.predefinedPermissionCollection) {
+      $scope.upsertPerm.collection = $scope.predefinedPermissionCollection[$scope.selectedPredefinedPermission];
+    }
+
+    $scope.isPermFieldDisabled = ($scope.upsertPerm.name === "" && $scope.selectedPredefinedPermission);
+  };
+
+  $scope.showAddPermDialog = function() {
+    $scope.permDialogMode = "add";
+    $scope.permDialogHeader = "Add New Permission";
+    $scope.permDialogAction = "Add Permission";
+    $scope.upsertPerm = {};
+    $scope.upsertPerm.name = "";
+    $scope.upsertPerm.index = "";
+    $scope.upsertPerm["method"] = {"get":"true", "post":"true", "put":"true", "delete":"true"};
+    $scope.isPermFieldDisabled = false;
+    delete $scope.selectedPredefinedPermission;
+
+    $scope.params = [{"name":"", "value":""}];
+
+    var permissionNames = $scope.permissions.map(p => p.name);
+    $scope.filteredPredefinedPermissions = $scope.predefinedPermissions.filter(p => !permissionNames.includes(p));
+
+    $scope.togglePermDialog();
+  };
+
+  $scope.togglePermDialog = function() {
+    if ($scope.showPermDialog) {
+      delete $scope.upsertPerm;
+      delete $scope.validationError;
+      $scope.showPermDialog = false;
+      $scope.isPermFieldDisabled = false;
+      delete $scope.selectedPredefinedPermission;
+      return;
+    }
+
+    $scope.hideAll();
+
+    var leftPos = $scope.permDialogMode === "add" ? 500 : 100;
+    var topPos = $('#permissions').offset().top - 320;
+    if (topPos < 0) topPos = 0;
+    $('#add-permission-dialog').css({left: leftPos, top: topPos});
+
+    $scope.showPermDialog = true;
+  };
+
+  $scope.getMethods = function() {
+    var methods = [];
+    if ($scope.upsertPerm.method.get === "true") {
+      methods.push("GET");
+    }
+    if ($scope.upsertPerm.method.put === "true") {
+      methods.push("PUT");
+    }
+    if ($scope.upsertPerm.method.post === "true") {
+      methods.push("POST");
+    }
+    if ($scope.upsertPerm.method.delete === "true") {
+      methods.push("DELETE");
+    }
+    return methods;
+  };
+
+  $scope.confirmDeletePerm = function() {
+    var permName = $scope.selectedPredefinedPermission ? $scope.selectedPredefinedPermission : $scope.upsertPerm.name.trim();
+    if (window.confirm("Confirm delete the '"+permName+"' permission?")) {
+      var index = parseInt($scope.upsertPerm.index);
+      Security.post({path: "authorization"}, { "delete-permission": index }, function (data) {
+        $scope.togglePermDialog();
+        $scope.refreshSecurityPanel();
+      });
+    }
+  };
+
+  $scope.doUpsertPermission = function() {
+    if (!$scope.upsertPerm) {
+      $scope.upsertPerm = {};
+    }
+
+    var isAdd = $scope.permDialogMode === "add";
+    var name = $scope.selectedPredefinedPermission ? $scope.selectedPredefinedPermission : $scope.upsertPerm.name.trim();
+
+    if (isAdd) {
+      if (!name) {
+        $scope.validationError = "Either select a predefined permission or provide a name for a custom permission";
+        return;
+      }
+      var permissionNames = $scope.permissions.map(p => p.name);
+      if (permissionNames.includes(name)) {
+        $scope.validationError = "Permission '"+name+"' already exists!";
+        return;
+      }
+
+      if (name === "*") {
+        $scope.validationError = "Invalid permission name!";
+        return;
+      }
+    }
+
+    var role = null;
+    if ($scope.manageUserRolesEnabled) {
+      role = $scope.upsertPerm.selectedRoles;
+      if (!role || role.length === 0) {
+        role = null;
+      } else if (role.includes("*")) {
+        role = ["*"];
+      }
+    } else if ($scope.upsertPerm.manualRoles && $scope.upsertPerm.manualRoles.trim() !== "") {
+      var manualRoles = $scope.upsertPerm.manualRoles.trim();
+      role = (manualRoles === "null") ? null : toList(manualRoles);
+    }
+
+    var setPermJson = {"name": name, "role": role };
+
+    if ($scope.selectedPredefinedPermission) {
+      $scope.params = [{"name":"","value":""}];
+    } else {
+      // collection
+      var coll = null;
+      if ($scope.upsertPerm.collection != null && $scope.upsertPerm.collection !== "null") {
+        if ($scope.upsertPerm.collection === "*") {
+          coll = "*";
+        } else {
+          coll = $scope.upsertPerm.collection && $scope.upsertPerm.collection.trim() !== "" ? toList($scope.upsertPerm.collection) : "";
+        }
+      }
+      setPermJson["collection"] = coll;
+
+      // path
+      if (!$scope.upsertPerm.path || (Array.isArray($scope.upsertPerm.path) && $scope.upsertPerm.path.length === 0)) {
+        $scope.validationError = "Path is required for custom permissions!";
+        return;
+      }
+
+      setPermJson["path"] = toList($scope.upsertPerm.path);
+
+      if ($scope.upsertPerm.method) {
+        var methods = $scope.getMethods();
+        if (methods.length === 0) {
+          $scope.validationError = "Must specify at least one request method for a custom permission!";
+          return;
+        }
+
+        if (methods.length < 4) {
+          setPermJson["method"] = methods;
+        } // else no need to specify, rule applies to all methods
+      }
+
+      // params
+      var params = {};
+      if ($scope.params && $scope.params.length > 0) {
+        for (i in $scope.params) {
+          var p = $scope.params[i];
+          var name = p.name.trim();
+          if (name !== "" && p.value) {
+            if (name in params) {
+              params[name].push(p.value);
+            } else {
+              params[name] = [p.value];
+            }
+          }
+        }
+      }
+      setPermJson["params"] = params;
+    }
+
+    var indexUpdated = false;
+    if ($scope.upsertPerm.index) {
+      var indexOrBefore = isAdd ? "before" : "index";
+      var indexInt = parseInt($scope.upsertPerm.index);
+      if (indexInt < 1) indexInt = 1;
+      if (indexInt >= $scope.permissions.length) indexInt = null;
+      if (indexInt != null) {
+        setPermJson[indexOrBefore] = indexInt;
+      }
+      indexUpdated = (!isAdd && indexInt !== parseInt($scope.upsertPerm.originalIndex));
+    }
+
+    if (indexUpdated) {
+      // changing position is a delete + re-add in new position
+      Security.post({path: "authorization"}, { "delete-permission": parseInt($scope.upsertPerm.originalIndex) }, function (remData) {
+        if (setPermJson.index) {
+          var before = setPermJson.index;
+          delete setPermJson.index;
+          setPermJson["before"] = before;
+        }
+
+        // add perm back in new position
+        Security.post({path: "authorization"}, { "set-permission": setPermJson }, function (data) {
+          var errorCause = checkError(data);
+          if (errorCause != null) {
+            $scope.securityAPIError = "set-permission "+name+" failed due to: "+errorCause;
+            $scope.securityAPIErrorDetails = JSON.stringify(data);
+            return;
+          }
+          $scope.togglePermDialog();
+          $scope.refreshSecurityPanel();
+        });
+      });
+    } else {
+      var action = isAdd ? "set-permission" : "update-permission";
+      var postBody = {};
+      postBody[action] = setPermJson;
+      Security.post({path: "authorization"}, postBody, function (data) {
+        var errorCause = checkError(data);
+        if (errorCause != null) {
+          $scope.securityAPIError = action+" "+name+" failed due to: "+errorCause;
+          $scope.securityAPIErrorDetails = JSON.stringify(data);
+          return;
+        }
+
+        $scope.togglePermDialog();
+        $scope.refreshSecurityPanel();
+      });
+    }
+  };
+
+  $scope.applyUserFilter = function() {
+    $scope.userFilterText = "";
+    $scope.userFilterOption = "";
+    $scope.userFilterOptions = [];
+    $scope.filteredUsers = $scope.users; // reset the filtered when the filter type changes
+
+    if ($scope.userFilter === "name" || $scope.userFilter === "path") {
+      // no-op: filter is text input
+    } else if ($scope.userFilter === "role") {
+      $scope.userFilterOptions = $scope.roleNames;
+    } else if ($scope.userFilter === "perm") {
+      $scope.userFilterOptions = $scope.permissions.map(p => p.name).sort();
+    } else {
+      $scope.userFilter = "";
+    }
+  };
+
+  $scope.onUserFilterTextChanged = function() {
+    // don't fire until we have at least 2 chars ...
+    if ($scope.userFilterText && $scope.userFilterText.trim().length >= 2) {
+      $scope.userFilterOption = $scope.userFilterText.toLowerCase();
+      $scope.onUserFilterOptionChanged();
+    } else {
+      $scope.filteredUsers = $scope.users;
+    }
+  };
+
+  function pathMatch(paths, filter) {
+    for (p in paths) {
+      if (paths[p].includes(filter)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  $scope.onUserFilterOptionChanged = function() {
+    var filter = $scope.userFilterOption ? $scope.userFilterOption.trim() : "";
+    if (filter.length === 0) {
+      $scope.filteredUsers = $scope.users;
+      return;
+    }
+
+    if ($scope.userFilter === "name") {
+      $scope.filteredUsers = $scope.users.filter(u => u.username.toLowerCase().includes(filter));
+    } else if ($scope.userFilter === "role") {
+      $scope.filteredUsers = $scope.users.filter(u => u.roles.includes(filter));
+    } else if ($scope.userFilter === "path") {
+      var rolesForPath = Array.from(new Set($scope.permissionsTable.filter(p => p.roles && pathMatch(p.paths, filter)).flatMap(p => p.roles)));
+      var usersForPath = Array.from(new Set($scope.roles.filter(r => r.users && r.users.length > 0 && rolesForPath.includes(r.name)).flatMap(r => r.users)));
+      $scope.filteredUsers = $scope.users.filter(u => usersForPath.includes(u.username));
+    } else if ($scope.userFilter === "perm") {
+      var rolesForPerm = Array.from(new Set($scope.permissionsTable.filter(p => p.name === filter).flatMap(p => p.roles)));
+      var usersForPerm = Array.from(new Set($scope.roles.filter(r => r.users && r.users.length > 0 && rolesForPerm.includes(r.name)).flatMap(r => r.users)));
+      $scope.filteredUsers = $scope.users.filter(u => usersForPerm.includes(u.username));
+    } else {
+      // reset
+      $scope.userFilter = "";
+      $scope.userFilterOption = "";
+      $scope.userFilterText = "";
+      $scope.filteredUsers = $scope.users;
+    }
+  };
+
+  $scope.applyPermFilter = function() {
+    $scope.permFilterText = "";
+    $scope.permFilterOption = "";
+    $scope.permFilterOptions = [];
+    $scope.filteredPerms = $scope.permissionsTable;
+
+    if ($scope.permFilter === "name" || $scope.permFilter === "path") {
+      // no-op: filter is text input
+    } else if ($scope.permFilter === "role") {
+      var roles = $scope.manageUserRolesEnabled ? $scope.roleNames : Array.from(new Set($scope.permissionsTable.flatMap(p => p.roles))).sort();
+      $scope.permFilterOptions = ["*", "null"].concat(roles);
+    } else if ($scope.permFilter === "user") {
+      $scope.permFilterOptions = Array.from(new Set($scope.roles.flatMap(r => r.users))).sort();
+    } else if ($scope.permFilter === "collection") {
+      $scope.permFilterOptions = Array.from(new Set($scope.permissionsTable.flatMap(p => p.collections))).sort();
+      $scope.permFilterOptions.push("null");
+    } else {
+      // no perm filtering
+      $scope.permFilter = "";
+    }
+  };
+
+  $scope.onPermFilterTextChanged = function() {
+    // don't fire until we have at least 2 chars ...
+    if ($scope.permFilterText && $scope.permFilterText.trim().length >= 2) {
+      $scope.permFilterOption = $scope.permFilterText.trim().toLowerCase();
+      $scope.onPermFilterOptionChanged();
+    } else {
+      $scope.filteredPerms = $scope.permissionsTable;
+    }
+  };
+
+  $scope.onPermFilterOptionChanged = function() {
+    var filterCriteria = $scope.permFilterOption ? $scope.permFilterOption.trim() : "";
+    if (filterCriteria.length === 0) {
+      $scope.filteredPerms = $scope.permissionsTable;
+      return;
+    }
+
+    if ($scope.permFilter === "name") {
+      $scope.filteredPerms = $scope.permissionsTable.filter(p => p.name.toLowerCase().includes(filterCriteria));
+    } else if ($scope.permFilter === "role") {
+      if (filterCriteria === "null") {
+        $scope.filteredPerms = $scope.permissionsTable.filter(p => p.roles.length === 0);
+      } else {
+        $scope.filteredPerms = $scope.permissionsTable.filter(p => p.roles.includes(filterCriteria));
+      }
+    } else if ($scope.permFilter === "path") {
+      $scope.filteredPerms = $scope.permissionsTable.filter(p => pathMatch(p.paths, filterCriteria));
+    } else if ($scope.permFilter === "user") {
+      // get the user's roles and then find all the permissions mapped to each role
+      var rolesForUser = $scope.roles.filter(r => r.users.includes(filterCriteria)).map(r => r.name);
+      $scope.filteredPerms = $scope.permissionsTable.filter(p => p.roles.length > 0 && roleMatch(p.roles, rolesForUser));
+    } else if ($scope.permFilter === "collection") {
+      function collectionMatch(collNames, colls, filter) {
+        return (filter === "null") ?collNames === "null" : colls.includes(filter);
+      }
+      $scope.filteredPerms = $scope.permissionsTable.filter(p => collectionMatch(p.collectionNames, p.collections, filterCriteria));
+    } else {
+      // reset
+      $scope.permFilter = "";
+      $scope.permFilterOption = "";
+      $scope.permFilterText = "";
+      $scope.filteredPerms = $scope.permissionsTable;
+    }
+  };
+
+  $scope.editUser = function(row) {
+    if (!row || !$scope.hasSecurityEditPerm) {
+      return;
+    }
+
+    var userId = row.username;
+    $scope.userDialogMode = "edit";
+    $scope.userDialogHeader = "Edit User: "+userId;
+    $scope.userDialogAction = "Update";
+    var userRoles = userId in $scope.userRoles ? $scope.userRoles[userId] : [];
+    if (!Array.isArray(userRoles)) {
+      userRoles = [userRoles];
+    }
+
+    $scope.upsertUser = { username: userId, selectedRoles: userRoles };
+    $scope.toggleUserDialog();
+  };
+
+  function buildMethods(m) {
+    return {"get":""+m.includes("GET"), "post":""+m.includes("POST"), "put":""+m.includes("PUT"), "delete":""+m.includes("DELETE")};
+  }
+
+  $scope.editPerm = function(row) {
+    if (!$scope.managePermissionsEnabled || !$scope.hasSecurityEditPerm || !row) {
+      return;
+    }
+
+    var name = row.name;
+    $scope.permDialogMode = "edit";
+    $scope.permDialogHeader = "Edit Permission: "+name;
+    $scope.permDialogAction = "Update";
+
+    var perm = $scope.permissionsTable.find(p => p.name === name);
+    var isPredefined = $scope.predefinedPermissions.includes(name);
+    if (isPredefined) {
+      $scope.selectedPredefinedPermission = name;
+      $scope.upsertPerm = { };
+      $scope.filteredPredefinedPermissions = [];
+      $scope.filteredPredefinedPermissions.push(name);
+      if ($scope.selectedPredefinedPermission && $scope.selectedPredefinedPermission in $scope.predefinedPermissionCollection) {
+        $scope.upsertPerm.collection = $scope.predefinedPermissionCollection[$scope.selectedPredefinedPermission];
+      }
+      $scope.isPermFieldDisabled = true;
+    } else {
+      $scope.upsertPerm = { name: name, collection: perm.collectionNames, path: perm.paths };
+      $scope.params = [];
+      if (perm.params) {
+        for (const [key, value] of Object.entries(perm.params)) {
+          if (Array.isArray(value)) {
+            for (i in value) {
+              $scope.params.push({"name":key, "value":value[i]});
+            }
+          } else {
+            $scope.params.push({"name":key, "value":value});
+          }
+        }
+      }
+      if ($scope.params.length === 0) {
+        $scope.params = [{"name":"","value":""}];
+      }
+
+      $scope.upsertPerm["method"] = perm.method.length === 0 ? {"get":"true", "post":"true", "put":"true", "delete":"true"} : buildMethods(perm.method);
+      $scope.isPermFieldDisabled = false;
+      delete $scope.selectedPredefinedPermission;
+    }
+
+    $scope.upsertPerm.index = perm["index"];
+    $scope.upsertPerm.originalIndex = perm["index"];
+
+    // roles depending on authz plugin support
+    if ($scope.manageUserRolesEnabled) {
+      $scope.upsertPerm["selectedRoles"] = asList(perm.roles);
+    } else {
+      $scope.upsertPerm["manualRoles"] = asList(perm.roles).sort().join(", ");
+    }
+
+    $scope.togglePermDialog();
+  };
+
+  $scope.applyRoleFilter = function() {
+    $scope.roleFilterText = "";
+    $scope.roleFilterOption = "";
+    $scope.roleFilterOptions = [];
+    $scope.filteredRoles = $scope.roles; // reset the filtered when the filter type changes
+
+    if ($scope.roleFilter === "name" || $scope.roleFilter === "path") {
+      // no-op: filter is text input
+    } else if ($scope.roleFilter === "user") {
+      $scope.roleFilterOptions = Array.from(new Set($scope.roles.flatMap(r => r.users))).sort();
+    } else if ($scope.roleFilter === "perm") {
+      $scope.roleFilterOptions = $scope.permissions.map(p => p.name).sort();
+    } else {
+      $scope.roleFilter = "";
+    }
+  };
+
+  $scope.onRoleFilterTextChanged = function() {
+    // don't fire until we have at least 2 chars ...
+    if ($scope.roleFilterText && $scope.roleFilterText.trim().length >= 2) {
+      $scope.roleFilterOption = $scope.roleFilterText.toLowerCase();
+      $scope.onRoleFilterOptionChanged();
+    } else {
+      $scope.filteredRoles = $scope.roles;
+    }
+  };
+
+  $scope.onRoleFilterOptionChanged = function() {
+    var filter = $scope.roleFilterOption ? $scope.roleFilterOption.trim() : "";
+    if (filter.length === 0) {
+      $scope.filteredRoles = $scope.roles;
+      return;
+    }
+
+    if ($scope.roleFilter === "name") {
+      $scope.filteredRoles = $scope.roles.filter(r => r.name.toLowerCase().includes(filter));
+    } else if ($scope.roleFilter === "user") {
+      $scope.filteredRoles = $scope.roles.filter(r => r.users.includes(filter));
+    } else if ($scope.roleFilter === "path") {
+      var rolesForPath = Array.from(new Set($scope.permissionsTable.filter(p => p.roles && pathMatch(p.paths, filter)).flatMap(p => p.roles)));
+      $scope.filteredRoles = $scope.roles.filter(r => rolesForPath.includes(r.name));
+    } else if ($scope.roleFilter === "perm") {
+      var rolesForPerm = Array.from(new Set($scope.permissionsTable.filter(p => p.name === filter).flatMap(p => p.roles)));
+      $scope.filteredRoles = $scope.roles.filter(r => rolesForPerm.includes(r.name));
+    } else {
+      // reset
+      $scope.roleFilter = "";
+      $scope.roleFilterOption = "";
+      $scope.roleFilterText = "";
+      $scope.filteredRoles = $scope.roles;
+    }
+  };
+
+  $scope.showAddRoleDialog = function() {
+    $scope.roleDialogMode = "add";
+    $scope.roleDialogHeader = "Add New Role";
+    $scope.roleDialogAction = "Add Role";
+    $scope.upsertRole = {};
+    $scope.userNames = $scope.users.map(u => u.username);
+    $scope.grantPermissionNames = Array.from(new Set($scope.predefinedPermissions.concat($scope.permissions.map(p => p.name)))).sort();
+    $scope.toggleRoleDialog();
+  };
+
+  $scope.toggleRoleDialog = function() {
+    if ($scope.showRoleDialog) {
+      delete $scope.upsertRole;
+      delete $scope.validationError;
+      delete $scope.userNames;
+      $scope.showRoleDialog = false;
+      return;
+    }
+    $scope.hideAll();
+    $('#role-dialog').css({left: 680, top: 139});
+    $scope.showRoleDialog = true;
+  };
+
+  $scope.doUpsertRole = function() {
+    if (!$scope.upsertRole) {
+      delete $scope.validationError;
+      $scope.showRoleDialog = false;
+      return;
+    }
+
+    if (!$scope.upsertRole.name || $scope.upsertRole.name.trim() === "") {
+      $scope.validationError = "Role name is required!";
+      return;
+    }
+
+    // keep role name to a reasonable length? but allow for email addresses
+    var name = $scope.upsertRole.name.trim();
+    if (name.length > 30) {
+      $scope.validationError = "Role name must be 30 characters or less!";
+      return;
+    }
+
+    if (name === "null" || name === "*") {
+      $scope.validationError = "Role name '"+name+"' is invalid!";
+      return;
+    }
+
+    if ($scope.roleDialogMode === "add") {
+      if ($scope.roleNames.includes(name)) {
+        $scope.validationError = "Role '"+name+"' already exists!";
+        return;
+      }
+    }
+
+    var usersForRole = [];
+    if ($scope.upsertRole.selectedUsers && $scope.upsertRole.selectedUsers.length > 0) {
+      usersForRole = usersForRole.concat($scope.upsertRole.selectedUsers);
+    }
+    usersForRole = Array.from(new Set(usersForRole));
+    if (usersForRole.length === 0) {
+      $scope.validationError = "Must assign new role '"+name+"' to at least one user.";
+      return;
+    }
+
+    var perms = [];
+    if ($scope.upsertRole.grantedPerms && Array.isArray($scope.upsertRole.grantedPerms) && $scope.upsertRole.grantedPerms.length > 0) {
+      perms = $scope.upsertRole.grantedPerms;
+    }
+
+    // go get the latest role mappings ...
+    Security.get({path: "authorization"}, function (data) {
+      var userRoles = data.authorization["user-role"];
+      var setUserRoles = {};
+      for (u in usersForRole) {
+        var user = usersForRole[u];
+        var currentRoles = user in userRoles ? asList(userRoles[user]) : [];
+        // add the new role for this user if needed
+        if (!currentRoles.includes(name)) {
+          currentRoles.push(name);
+        }
+        setUserRoles[user] = currentRoles;
+      }
+
+      Security.post({path: "authorization"}, { "set-user-role": setUserRoles }, function (data2) {
+
+        var errorCause = checkError(data2);
+        if (errorCause != null) {
+          $scope.securityAPIError = "set-user-role for "+username+" failed due to: "+errorCause;
+          $scope.securityAPIErrorDetails = JSON.stringify(data2);
+          return;
+        }
+
+        if (perms.length === 0) {
+          // close dialog and refresh the tables ...
+          $scope.toggleRoleDialog();
+          $scope.refreshSecurityPanel();
+          return;
+        }
+
+        var currentPerms = data.authorization["permissions"];
+        for (i in perms) {
+          var permName = perms[i];
+          var existingPerm = currentPerms.find(p => p.name === permName);
+
+          if (existingPerm) {
+            var roleList = [];
+            if (existingPerm.role) {
+              if (Array.isArray(existingPerm.role)) {
+                roleList = existingPerm.role;
+              } else {
+                roleList.push(existingPerm.role);
+              }
+            }
+            if (!roleList.includes(name)) {
+              roleList.push(name);
+            }
+            existingPerm.role = roleList;
+            Security.post({path: "authorization"}, { "update-permission": existingPerm }, function (data3) {
+              $scope.refreshSecurityPanel();
+            });
+          } else {
+            // new perm ... must be a predefined ...
+            if ($scope.predefinedPermissions.includes(permName)) {
+              var setPermission = {name: permName, role:[name]};
+              Security.post({path: "authorization"}, { "set-permission": setPermission }, function (data3) {
+                $scope.refreshSecurityPanel();
+              });
+            } // else ignore it
+          }
+        }
+        $scope.toggleRoleDialog();
+      });
+    });
+
+  };
+
+  $scope.editRole = function(row) {
+    if (!row || !$scope.hasSecurityEditPerm) {
+      return;
+    }
+
+    var roleName = row.name;
+    $scope.roleDialogMode = "edit";
+    $scope.roleDialogHeader = "Edit Role: "+roleName;
+    $scope.roleDialogAction = "Update";
+    var role = $scope.roles.find(r => r.name === roleName);
+    var perms = $scope.permissionsTable.filter(p => p.roles.includes(roleName)).map(p => p.name);
+    $scope.upsertRole = { name: roleName, selectedUsers: role.users, grantedPerms: perms };
+    $scope.userNames = $scope.users.map(u => u.username);
+    $scope.grantPermissionNames = Array.from(new Set($scope.predefinedPermissions.concat($scope.permissions.map(p => p.name)))).sort();
+    $scope.toggleRoleDialog();
+  };
+
+  $scope.onBlockUnknownChange = function() {
+    Security.post({path: "authentication"}, { "set-property": { "blockUnknown": $scope.blockUnknown === "true" } }, function (data) {
+      $scope.refreshSecurityPanel();
+    });
+  };
+
+  $scope.onForwardCredsChange = function() {
+    Security.post({path: "authentication"}, { "set-property": { "forwardCredentials": $scope.forwardCredentials === "true" } }, function (data) {
+      $scope.refreshSecurityPanel();
+    });
+  };
+
+  $scope.removeParam= function(index) {
+    if ($scope.params.length === 1) {
+      $scope.params = [{"name":"","value":""}];
+    } else {
+      $scope.params.splice(index, 1);
+    }
+  };
+  
+  $scope.addParam = function(index) {
+    $scope.params.splice(index+1, 0, {"name":"","value":""});
+  };
+
+  $scope.refresh();
+})

Review comment:
       *W033:*  Missing semicolon.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/schema-designer.js
##########
@@ -0,0 +1,2014 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $cookies, $window, Constants, SchemaDesigner, Luke) {
+  $scope.resetMenu("schema-designer", Constants.IS_ROOT_PAGE);
+
+  $scope.onWarning = function (warnMsg, warnDetails) {
+    $scope.updateWorking = false;
+    delete $scope.updateStatusMessage;
+    $scope.apiWarning = warnMsg;
+    $scope.apiWarningDetails = warnDetails;
+  };
+  
+  $scope.onError = function (errorMsg, errorCode, errorDetails) {
+    $scope.updateWorking = false;
+    delete $scope.updateStatusMessage;
+    $scope.designerAPIError = errorMsg;
+    if (errorDetails) {
+      var errorDetailsStr = "";
+      if (errorDetails["error"]) {
+        errorDetailsStr = errorDetails["error"];
+      } else {
+        for (var id in errorDetails) {
+          var msg = errorDetails[id];
+          var at = msg.indexOf("ERROR: ");
+          if (at !== -1) {
+            msg = msg.substring(at+7);
+          }
+          if (!msg.includes(id)) {
+            msg = id+": "+msg;
+          }
+          errorDetailsStr += msg+"\n\n";
+        }
+      }
+      $scope.designerAPIErrorDetails = errorDetailsStr;
+    } else {
+      delete $scope.designerAPIErrorDetails;
+    }
+
+    if (errorCode === 409) {
+      $scope.schemaVersion = -1; // reset to get the latest
+      $scope.isVersionMismatch = true;
+      $scope.errorButton = "Reload Schema";
+    } else if (errorCode < 500) {
+      $scope.isVersionMismatch = false;
+      $scope.errorButton = "OK";
+    } // else 500 errors get the top-level error message
+  };
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error) {
+      $scope.onError(error.msg, error.code, e.data.errorDetails);
+    } else {
+      // when a timeout occurs, the error details are sparse so just give the user a hint that something was off
+      var path = e.config && e.config.url ? e.config.url : "/api/schema-designer";
+      var reloadMsg = "";
+      if (path.includes("/analyze")) {
+        reloadMsg = " Re-try analyzing your sample docs by clicking on 'Analyze Documents' again."
+      }
+      $scope.onError("Request to "+path+" failed!", 408,
+          {"error":"Most likely the request timed out; check server log for more details."+reloadMsg});
+    }
+  };
+
+  $scope.closeWarnDialog = function () {
+    delete $scope.apiWarning;
+    delete $scope.apiWarningDetails;
+  };
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.designerAPIError;
+    delete $scope.designerAPIErrorDetails;
+    if ($scope.isVersionMismatch) {
+      $scope.isVersionMismatch = false;
+      var nodeId = "/";
+      if ($scope.selectedNode) {
+        nodeId = $scope.selectedNode.href;
+      }
+      $scope.doAnalyze(nodeId);
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.isSchemaDesignerEnabled = true;
+
+    delete $scope.helpId;
+
+    $scope.updateStatusMessage = "";
+    $scope.analysisVerbose = false;
+    $scope.updateWorking = false;
+    $scope.currentSchema = "";
+
+    delete $scope.hasDocsOnServer;
+    delete $scope.queryResultsTree;
+
+    $scope.languages = ["*"];
+    $scope.copyFrom = "_default";
+    delete $scope.sampleMessage;
+    delete $scope.sampleDocuments;
+
+    $scope.schemaVersion = -1;
+    $scope.schemaTree = {};
+    $scope.showSchemaActions = false;
+    $scope.sampleDocIds = [];
+    $scope.isSchemaRoot = false;
+
+    delete $scope.enableNestedDocs;
+    delete $scope.enableDynamicFields;
+    delete $scope.enableFieldGuessing;
+
+    // schema editor
+    $scope.showFieldDetails = false;
+    $scope.selectedNode = null;
+    $scope.selectedUpdated = false;
+    delete $scope.updateStatusMessage;
+
+    // text field analysis
+    $scope.showAnalysis = false;
+    $scope.sampleDocId = null;
+    $scope.indexText = "";
+    $scope.result = {};
+
+    // publish vars
+    delete $scope.newCollection;
+    $scope.reloadOnPublish = "true";
+
+    // query form
+    $scope.query = {q: '*:*', sortBy: 'score', sortDir: 'desc'};
+
+    SchemaDesigner.get({path: "configs"}, function (data) {
+
+      $scope.schemas = [];
+      $scope.publishedSchemas = ["_default"];
+
+      for (var s in data.configSets) {
+        // 1 means published but not editable
+        if (data.configSets[s] !== 1) {
+          $scope.schemas.push(s);
+        }
+
+        // 0 means not published yet (so can't copy from it yet)
+        if (data.configSets[s] > 0) {
+          $scope.publishedSchemas.push(s);
+        }
+      }
+
+      $scope.schemas.sort();
+      $scope.publishedSchemas.sort();
+
+      // if no schemas available to select, open the pop-up immediately
+      if ($scope.schemas.length === 0) {
+        $scope.firstSchemaMessage = true;
+        $scope.showNewSchemaDialog();
+      }
+    }, function(e) {
+      if (e.status === 401 || e.status === 403) {
+        $scope.isSchemaDesignerEnabled = false;
+        $scope.hideAll();
+      }
+    });
+  };
+
+  $scope.selectNodeInTree = function(nodeId) {
+    nodeId = stripAnchorSuffix(nodeId);
+    if (!nodeId) return;
+
+    var jst = $('#schemaJsTree').jstree(true);
+    if (jst) {
+      var selectedId = null;
+      var selected_node = jst.get_selected();
+      if (selected_node && selected_node.length > 0) {
+        selectedId = selected_node[0];
+      }
+      if (selectedId) {
+        try {
+          jst.deselect_node(selectedId);
+        } catch (err) {
+          // just ignore
+          //console.log("error deselecting "+selectedId);
+        }
+      }
+
+      try {
+        jst.select_node(nodeId, true);
+      } catch (err) {
+        // just ignore, some low-level tree issue
+        //console.log("error selecting "+nodeId);
+      }
+    }
+  };
+
+  $scope.loadFile = function (event) {
+    var t = event.target || event.srcElement || event.currentTarget;
+    if (t && t.text) {
+      $scope.onSelectFileNode("files/" + t.text, true);
+    }
+  };
+
+  $scope.confirmEditSchema = function () {
+    $scope.showConfirmEditSchema = false;
+    if ($scope.hasDocsOnServer || $scope.published) {
+      $scope.doAnalyze();
+    } else {
+      $scope.sampleMessage = "Please upload or paste some sample documents to analyze for building the '" + $scope.currentSchema + "' schema.";
+    }
+  };
+
+  $scope.cancelEditSchema = function () {
+    $scope.currentSchema = "";
+    $scope.showConfirmEditSchema = false;
+  };
+
+  $scope.loadSchema = function () {
+
+    if (!$scope.currentSchema) {
+      return;
+    }
+
+    $scope.resetSchema();
+    var params = {path: "info", configSet: $scope.currentSchema};
+    SchemaDesigner.get(params, function (data) {
+      $scope.currentSchema = data.configSet;
+      $("#select-schema").trigger("chosen:updated");
+
+      $scope.confirmSchema = data.configSet;
+      $scope.collectionsForConfig = data.collections;
+      $scope.hasDocsOnServer = data.numDocs > 0;
+      $scope.published = data.published;
+      $scope.initDesignerSettingsFromResponse(data);
+      if ($scope.collectionsForConfig && $scope.collectionsForConfig.length > 0) {
+        $scope.showConfirmEditSchema = true;
+      } else {
+        if ($scope.hasDocsOnServer || $scope.published) {
+          $scope.doAnalyze();
+        } else {
+          $scope.sampleMessage = "Please upload or paste some sample documents to build the '" + $scope.currentSchema + "' schema.";
+        }
+      }
+    });
+  };
+
+  $scope.showNewSchemaDialog = function () {
+    $scope.hideAll();
+    $scope.showNewSchema = true;
+    $scope.newSchema = "";
+  };
+
+  $scope.addSchema = function () {
+    $scope.firstSchemaMessage = false;
+    delete $scope.addMessage;
+
+    if (!$scope.newSchema) {
+      $scope.addMessage = "Please provide a schema name!";
+      return;
+    }
+
+    $scope.newSchema = $scope.newSchema.trim();
+    if ($scope.newSchema.length > 50) {
+      $scope.addMessage = "Schema name be 50 characters or less";
+      return;
+    }
+
+    if ($scope.newSchema.indexOf(" ") !== -1 || $scope.newSchema.indexOf("/") !== -1) {
+      $scope.addMessage = "Schema name should not contain spaces or /";
+      return;
+    }
+
+    if ($scope.publishedSchemas.includes($scope.newSchema) || $scope.schemas.includes($scope.newSchema)) {
+      $scope.addMessage = "Schema '" + $scope.newSchema + "' already exists!";
+      return;
+    }
+
+    delete $scope.addMessage;
+    if (!$scope.copyFrom) {
+      $scope.copyFrom = "_default";
+    }
+
+    $scope.resetSchema();
+    $scope.schemas.push($scope.newSchema);
+    $scope.showNewSchema = false;
+    $scope.currentSchema = $scope.newSchema;
+    $scope.sampleMessage = "Please upload or paste some sample documents to analyze for building the '" + $scope.currentSchema + "' schema.";
+
+    SchemaDesigner.post({path: "prep", configSet: $scope.newSchema, copyFrom: $scope.copyFrom}, null, function (data) {
+      $scope.initDesignerSettingsFromResponse(data);
+    }, $scope.errorHandler);
+  };
+
+  $scope.cancelAddSchema = function () {
+    delete $scope.addMessage;
+    delete $scope.sampleMessage;
+
+    $scope.showNewSchema = false
+  };
+
+  $scope.hideAll = function () {
+    delete $scope.helpId;
+    $scope.showPublish = false;
+    $scope.showDiff = false;
+    $scope.showNewSchema = false;
+    $scope.showAddField = false;
+    $scope.showAddDynamicField = false;
+    $scope.showAddCopyField = false;
+    $scope.showAnalysis = false;
+    // add more dialogs here
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.hideData = function () {
+    $scope.showData = false;
+  };
+
+  $scope.rootChanged = function () {
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  $scope.updateUniqueKey = function () {
+    delete $scope.schemaRootMessage;
+    var jst = $('#schemaJsTree').jstree();
+    if (jst) {
+      var node = jst.get_node("field/" + $scope.updateUniqueKeyField);
+      if (node && node.a_attr) {
+        var attrs = node.a_attr;
+        if (attrs.multiValued || attrs.tokenized || !attrs.stored || !attrs.indexed) {
+          $scope.schemaRootMessage = "Field '" + $scope.updateUniqueKeyField +
+              "' cannot be used as the uniqueKey field! Must be single-valued, stored, indexed, and not tokenized.";
+          $scope.updateUniqueKeyField = $scope.uniqueKeyField;
+          return;
+        }
+      }
+    }
+    $scope.uniqueKeyField = $scope.updateUniqueKeyField;
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  $scope.resetSchema = function () {
+    $scope.hideAll();
+    $scope.analysisVerbose = false;
+    $scope.showSchemaActions = false;
+    $scope.showAnalysis = false;
+    $scope.showFieldDetails = false;
+    $scope.hasDocsOnServer = false;
+    $scope.query = {q: '*:*', sortBy: 'score', sortDir: 'desc'};
+    $scope.schemaVersion = -1;
+
+    $scope.updateWorking = false;
+    $scope.isVersionMismatch = false;
+    delete $scope.updateStatusMessage;
+    delete $scope.designerAPIError;
+    delete $scope.designerAPIErrorDetails;
+    delete $scope.selectedFacets;
+    delete $scope.sampleDocuments;
+    delete $scope.selectedNode;
+    delete $scope.queryResultsTree;
+  };
+
+  $scope.onSchemaUpdated = function (schema, data, nodeId) {
+    $scope.hasDocsOnServer = data.numDocs && data.numDocs > 0;
+    $scope.uniqueKeyField = data.uniqueKeyField;
+    $scope.updateUniqueKeyField = $scope.uniqueKeyField;
+    $scope.initDesignerSettingsFromResponse(data);
+
+    var fieldTypes = fieldTypesToTree(data.fieldTypes);
+    var files = filesToTree(data.files);
+
+    var rootChildren = [];
+    $scope.fieldsSrc = fieldsToTree(data.fields);
+    $scope.fieldsNode = {
+      "id": "fields",
+      "text": "Fields",
+      "state": {"opened": true},
+      "a_attr": {"href": "fields"},
+      "children": $scope.fieldsSrc
+    };
+    rootChildren.push($scope.fieldsNode);
+
+    if ($scope.enableDynamicFields === "true") {
+      $scope.dynamicFieldsSrc = fieldsToTree(data.dynamicFields);
+      $scope.dynamicFieldsNode = {
+        "id": "dynamicFields",
+        "text": "Dynamic Fields",
+        "a_attr": {"href": "dynamicFields"},
+        "children": $scope.dynamicFieldsSrc
+      };
+      rootChildren.push($scope.dynamicFieldsNode);
+    } else {
+      delete $scope.dynamicFieldsNode;
+      delete $scope.dynamicFieldsSrc;
+    }
+
+    rootChildren.push({"id":"fieldTypes", "text": "Field Types", "a_attr": {"href": "fieldTypes"}, "children": fieldTypes});
+    rootChildren.push(files);
+
+    var tree = [{"id":"/", "text": schema, "a_attr": {"href": "/"}, "state":{"opened": true}, "children": rootChildren}];
+
+    $scope.fields = data.fields;
+    $scope.fieldNames = data.fields.map(f => f.name).sort();
+    $scope.possibleIdFields = data.fields.filter(f => f.indexed && f.stored && !f.tokenized).map(f => f.name).sort();
+    $scope.sortableFields = data.fields.filter(f => (f.indexed || f.docValues) && !f.multiValued && !f.tokenized).map(f => f.name).sort();
+    $scope.sortableFields.push("score");
+
+    $scope.facetFields = data.fields.filter(f => (f.indexed || f.docValues) && !f.tokenized && f.name !== '_version_').map(f => f.name).sort();
+    $scope.hlFields = data.fields.filter(f => f.stored && f.tokenized).map(f => f.name).sort();
+
+    $scope.schemaVersion = data.schemaVersion;
+    $scope.currentSchema = data.configSet;
+    $scope.fieldTypes = fieldTypes;
+    $scope.core = data.core;
+    $scope.schemaTree = tree;
+    $scope.refreshTree();
+
+    $scope.collectionsForConfig = data.collectionsForConfig;
+
+    if (data.docIds) {
+      $scope.sampleDocIds = data.docIds;
+    }
+
+    // re-apply the filters on the updated schema
+    $scope.onTreeFilterOptionChanged();
+
+    // Load the Luke schema
+    Luke.schema({core: data.core}, function (schema) {
+      Luke.raw({core: data.core}, function (index) {
+        $scope.luke = mergeIndexAndSchemaData(index, schema.schema);
+        $scope.types = Object.keys(schema.schema.types);
+        $scope.showSchemaActions = true;
+        if (!nodeId) {
+          nodeId = "/";
+        }
+        $scope.onSelectSchemaTreeNode(nodeId);
+
+        $scope.updateWorking = false;
+
+        if (data.updateError != null) {
+          $scope.onError(data.updateError, data.updateErrorCode, data.errorDetails);
+        } else {
+          if ($scope.selectedUpdated) {
+            $scope.selectedUpdated = false;
+            $scope.updateStatusMessage = "Changes applied successfully.";
+            var waitMs = 3000;
+            if (data.rebuild) {
+              $scope.updateStatusMessage += " Did full re-index of sample docs due to incompatible update.";
+              waitMs = 5000; // longer message, more time to read
+            }
+
+            if (data.analysisError) {
+              var updateType = data["updateType"];
+              var updatedObject = data[updateType];
+              var updatedName = updatedObject && updatedObject.name ? updatedObject.name : "";
+              var warnMsg = "Changes to "+updateType+" "+updatedName+" applied, but required the temp collection to be deleted " +
+                  "and re-created due to an incompatible Lucene change, see details below.";
+              $scope.onWarning(warnMsg, data.analysisError);
+            } else {
+              $timeout(function () { delete $scope.updateStatusMessage; }, waitMs);
+            }
+          } else {
+            var source = data.sampleSource;
+            if (source) {
+              if (source === "paste") {
+                source = "pasted sample"
+              } else if (source === "blob") {
+                source = "previous upload stored on the server"
+              }
+              if (data.numDocs > 0) {
+                $scope.updateStatusMessage = "Analyzed "+data.numDocs+" docs from "+source;
+              } else {
+                $scope.updateStatusMessage = "Schema '"+$scope.currentSchema+"' loaded.";
+              }
+            }
+            $timeout(function () {
+              delete $scope.updateStatusMessage;
+            }, 5000);
+          }
+        }
+
+        // re-fire the current query to reflect the updated schema
+        $scope.doQuery();
+        $scope.selectNodeInTree(nodeId);
+      });
+    });
+  };
+
+  $scope.toggleAddField = function (type) {
+    if ($scope.showAddField) {
+      $scope.hideAll();
+    } else {
+      $scope.hideAll();
+      $scope.showAddField = true;
+      $scope.adding = type;
+
+      $scope.newField = {
+        stored: "true",
+        indexed: "true",
+        uninvertible: "true",
+        docValues: "true"
+      }
+
+      if (type === "field") {
+        $scope.newField.type = "string";
+      }
+
+      delete $scope.addErrors;
+    }
+  };
+
+  function applyConstraintsOnField(f) {
+
+    if (!f.docValues) {
+      delete f.useDocValuesAsStored;
+    }
+
+    if (!f.docValues && !f.uninvertible) {
+      delete f.sortMissingLast; // remove this setting if no docValues / uninvertible
+    }
+
+    if (f.indexed) {
+      if (f.omitTermFreqAndPositions && !f.omitPositions) {
+        delete f.omitPositions; // :shrug ~ see SchemaField ln 295
+      }
+      if (!f.termVectors) {
+        delete f.termPositions;
+        delete f.termOffsets;
+        delete f.termPayloads;
+      }
+    } else {
+      // if not indexed, a bunch of fields are false
+      f.tokenized = false;
+      f.uninvertible = false;
+
+      // drop these from the request
+      delete f.termVectors;
+      delete f.termPositions;
+      delete f.termOffsets;
+      delete f.termPayloads;
+      delete f.omitNorms;
+      delete f.omitPositions;
+      delete f.omitTermFreqAndPositions;
+      delete f.storeOffsetsWithPositions;
+    }
+
+    return f;
+  }
+
+  $scope.addField = function () {
+
+    // validate the form input
+    $scope.addErrors = [];
+    if (!$scope.newField.name) {
+      $scope.addErrors.push($scope.adding + " name is required!");
+    }
+
+    if ($scope.newField.name.indexOf(" ") != -1) {
+      $scope.addErrors.push($scope.adding + " name should not have whitespace");
+    }
+
+    var command = "add-field-type";
+    if ("field" === $scope.adding) {
+
+      if ($scope.fieldNames.includes($scope.newField.name)) {
+        $scope.addErrors.push("Field '" + $scope.newField.name + "' already exists!");
+        return;
+      }
+
+      // TODO: is this the correct logic for detecting dynamic? Probably good enough for the designer
+      var isDynamic = $scope.newField.name.startsWith("*") || $scope.newField.name.endsWith("*");
+      if (isDynamic) {
+        if ($scope.luke && $scope.luke.dynamic_fields[$scope.newField.name]) {
+          $scope.addErrors.push("dynamic field '" + $scope.newField.name + "' already exists!");
+        }
+      } else {
+        if ($scope.luke && $scope.luke.fields[$scope.newField.name]) {
+          $scope.addErrors.push("field '" + $scope.newField.name + "' already exists!");
+        }
+      }
+
+      if (!$scope.newField.type) {
+        $scope.addErrors.push("field type is required!");
+      }
+
+      command = isDynamic ? "add-dynamic-field" : "add-field";
+    } else if ("type" === $scope.adding) {
+      if ($scope.types.includes($scope.newField.name)) {
+        $scope.addErrors.push("Type '" + $scope.newField.name + "' already exists!");
+      }
+
+      if (!$scope.newField.class) {
+        $scope.addErrors.push("class is required when creating a new field type!");
+      }
+    }
+
+    var addData = {};
+    addData[command] = applyConstraintsOnField($scope.newField);
+    if ($scope.textAnalysisJson) {
+      var text = $scope.textAnalysisJson.trim();
+      if (text.length > 0) {
+        text = text.replace(/\s+/g, ' ');
+        if (!text.startsWith("{")) {
+          text = "{ " + text + " }";
+        }
+        try {
+          var textJson = JSON.parse(text);
+          if (textJson.analyzer) {
+            addData[command].analyzer = textJson.analyzer;
+          } else {
+            if (!textJson.indexAnalyzer || !textJson.queryAnalyzer) {
+              $scope.addErrors.push("Text analysis JSON should define either an 'analyzer' or an 'indexAnalyzer' and 'queryAnalyzer'");
+              return;
+            }
+            addData[command].indexAnalyzer = textJson.indexAnalyzer;
+            addData[command].queryAnalyzer = textJson.queryAnalyzer;
+          }
+        } catch (e) {
+          $scope.addErrors.push("Failed to parse analysis as JSON due to: " + e.message +
+              "; expected JSON object with either an 'analyzer' or 'indexAnalyzer' and 'queryAnalyzer'");
+          return;
+        }
+      }
+    }
+
+    if ($scope.addErrors.length > 0) {
+      return;
+    }
+    delete $scope.addErrors; // no errors!
+
+    SchemaDesigner.post({
+      path: "add",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, addData, function (data) {
+      if (data.errors) {
+        $scope.addErrors = data.errors[0].errorMessages;
+        if (typeof $scope.addErrors === "string") {
+          $scope.addErrors = [$scope.addErrors];
+        }
+      } else {
+        delete $scope.textAnalysisJson;
+        $scope.added = true;
+        $timeout(function () {
+          $scope.showAddField = false;
+          $scope.added = false;
+          var nodeId = "/";
+          if ("field" === $scope.adding) {
+            nodeId = "field/" + data[command];
+          } else if ("type" === $scope.adding) {
+            nodeId = "type/" + data[command];
+          }
+          $scope.onSchemaUpdated(data.configSet, data, nodeId);
+        }, 500);
+      }
+    }, $scope.errorHandler);
+  }
+
+  function toSortedNameAndTypeList(fields, typeAttr) {
+    var list = [];
+    var keys = Object.keys(fields);
+    for (var f in keys) {
+      var field = fields[keys[f]];
+      var type = field[typeAttr];
+      if (type) {
+        list.push(field.name + ": "+type);
+      } else {
+        list.push(field.name);
+      }
+    }
+    return list.sort();
+  }
+
+  function toSortedFieldList(fields) {
+    var list = [];
+    var keys = Object.keys(fields);
+    for (var f in keys) {
+      list.push(fields[keys[f]]);
+    }
+    return list.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  $scope.toggleDiff = function (event) {
+    if ($scope.showDiff) {
+      // toggle, close dialog
+      $scope.showDiff = false;
+      return;
+    }
+
+    if (event) {
+      var t = event.target || event.currentTarget;
+      var leftPos = t.getBoundingClientRect().left - 600;
+      if (leftPos < 0) leftPos = 0;
+      $('#show-diff-dialog').css({left: leftPos});
+    }
+
+    SchemaDesigner.get({ path: "diff", configSet: $scope.currentSchema }, function (data) {
+      var diff = data.diff;
+
+      var dynamicFields = diff.dynamicFields;
+      var enableDynamicFields = data.enableDynamicFields !== null ? data.enableDynamicFields : true;
+      if (!enableDynamicFields) {
+        dynamicFields = null;
+      }
+
+      $scope.diffSource = data["diff-source"];
+      $scope.schemaDiff = {
+        "fieldsDiff": diff.fields,
+        "addedFields": [],
+        "removedFields": [],
+        "fieldTypesDiff": diff.fieldTypes,
+        "removedTypes": [],
+        "dynamicFieldsDiff": dynamicFields,
+        "copyFieldsDiff": diff.copyFields
+      }
+      if (diff.fields && diff.fields.added) {
+        $scope.schemaDiff.addedFields = toSortedFieldList(diff.fields.added);
+      }
+      if (diff.fields && diff.fields.removed) {
+        $scope.schemaDiff.removedFields = toSortedNameAndTypeList(diff.fields.removed, "type");
+      }
+      if (diff.fieldTypes && diff.fieldTypes.removed) {
+        $scope.schemaDiff.removedTypes = toSortedNameAndTypeList(diff.fieldTypes.removed, "class");
+      }
+
+      $scope.schemaDiffExists = !(diff.fields == null && diff.fieldTypes == null && dynamicFields == null && diff.copyFields == null);
+      $scope.showDiff = true;
+    });
+  }
+
+  $scope.togglePublish = function (event) {
+    if (event) {
+      var t = event.target || event.currentTarget;
+      var leftPos = t.getBoundingClientRect().left - 515;
+      if (leftPos < 0) leftPos = 0;
+      $('#publish-dialog').css({left: leftPos});
+    }
+
+    $scope.showDiff = false;
+    $scope.showPublish = !$scope.showPublish;
+    delete $scope.publishErrors;
+
+    $scope.disableDesigner = "false";
+
+    if ($scope.showPublish && !$scope.newCollection) {
+      $scope.newCollection = {numShards: 1, replicationFactor: 1, indexToCollection: "true"};
+    }
+  };
+
+  $scope.toggleAddCopyField = function () {
+    if ($scope.showAddCopyField) {
+      $scope.hideAll();
+      $scope.showFieldDetails = true;
+    } else {
+      $scope.hideAll();
+      $scope.showAddCopyField = true;
+      $scope.showFieldDetails = false;
+
+      $scope.copyField = {};
+      delete $scope.addCopyFieldErrors;
+    }
+  }
+  $scope.addCopyField = function () {
+    delete $scope.addCopyFieldErrors;
+    var data = {"add-copy-field": $scope.copyField};
+    SchemaDesigner.post({
+      path: "add",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, data, function (data) {
+      if (data.errors) {
+        $scope.addCopyFieldErrors = data.errors[0].errorMessages;
+        if (typeof $scope.addCopyFieldErrors === "string") {
+          $scope.addCopyFieldErrors = [$scope.addCopyFieldErrors];
+        }
+      } else {
+        $scope.showAddCopyField = false;
+        // TODO:
+        //$timeout($scope.refresh, 1500);
+      }
+    }, $scope.errorHandler);
+  }
+
+  $scope.toggleAnalyzer = function (analyzer) {
+    analyzer.show = !analyzer.show;
+  }
+
+  $scope.initTypeAnalysisInfo = function (typeName) {
+    $scope.analysis = getAnalysisInfo($scope.luke, {type: true}, typeName);
+    if ($scope.analysis && $scope.analysis.data) {
+      $scope.className = $scope.analysis.data.className
+    }
+    $scope.editAnalysis = "Edit JSON";
+    $scope.showAnalysisJson = false;
+    delete $scope.analysisJsonText;
+  };
+
+  $scope.toggleVerbose = function () {
+    $scope.analysisVerbose = !$scope.analysisVerbose;
+  };
+
+  $scope.updateSampleDocId = function () {
+    $scope.indexText = "";
+    $scope.result = {};
+
+    if (!$scope.selectedNode) {
+      return;
+    }
+
+    var field = $scope.selectedNode.name;
+    var params = {path: "sample"};
+    params.configSet = $scope.currentSchema;
+    params.uniqueKeyField = $scope.uniqueKeyField;
+    params.field = field;
+
+    if ($scope.sampleDocId) {
+      params.docId = $scope.sampleDocId;
+    } // else the server will pick the first doc with a non-empty text value for the desired field
+
+    SchemaDesigner.get(params, function (data) {
+      $scope.sampleDocId = data[$scope.uniqueKeyField];
+      $scope.indexText = data[field];
+      if (data.analysis && data.analysis["field_names"]) {
+        $scope.result = processFieldAnalysisData(data.analysis["field_names"][field]);
+      }
+    });
+  };
+
+  $scope.changeLanguages = function () {
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  function getType(typeName) {
+    if ($scope.fieldTypes) {
+      for (i in $scope.fieldTypes) {
+        if ($scope.fieldTypes[i].text === typeName) {
+          return $scope.fieldTypes[i];
+        }
+      }
+    }
+    return null;
+  }
+
+  $scope.refreshTree = function() {
+    var jst = $('#schemaJsTree').jstree(true);
+    if (jst) {
+      jst.refresh();
+    }
+  };
+
+  $scope.onSchemaTreeLoaded = function (id) {
+    //console.log(">> on tree loaded");
+  };
+
+  $scope.updateFile = function () {
+    var nodeId = "files/" + $scope.selectedFile;
+    var params = {path: "file", file: $scope.selectedFile, configSet: $scope.currentSchema};
+
+    $scope.updateWorking = true;
+    $scope.updateStatusMessage = "Updating file ...";
+
+    SchemaDesigner.post(params, $scope.fileNodeText, function (data) {
+      if (data.updateFileError) {
+        if (data[$scope.selectedFile]) {
+          $scope.fileNodeText = data[$scope.selectedFile];
+        }
+        $scope.updateFileError = data.updateFileError;
+      } else {
+        delete $scope.updateFileError;
+        $scope.updateStatusMessage = "File '"+$scope.selectedFile+"' updated.";
+        $scope.onSchemaUpdated(data.configSet, data, nodeId);
+      }
+    }, $scope.errorHandler);
+  };
+
+  $scope.onSelectFileNode = function (id, doSelectOnTree) {
+    $scope.selectedFile = id.startsWith("files/") ? id.substring("files/".length) : id;
+
+    var params = {path: "file", file: $scope.selectedFile, configSet: $scope.currentSchema};
+    SchemaDesigner.get(params, function (data) {
+      $scope.fileNodeText = data[$scope.selectedFile];
+      $scope.isLeafNode = false;
+      if (doSelectOnTree) {
+        delete $scope.selectedNode;
+        $scope.isLeafNode = false;
+        $scope.showFieldDetails = true;
+        delete $scope.sampleDocId;
+        $scope.showAnalysis = false;
+        $scope.selectNodeInTree(id);
+      }
+    });
+  };
+  
+  function fieldNodes(src, type) {
+    var nodes = [];
+    for (var c in src) {
+      var childNode = src[c];
+      if (childNode && childNode.a_attr) {
+        var a = childNode.a_attr;
+        var stored = a.stored || (a.docValues && a.useDocValuesAsStored);
+        var obj = {"name":a.name, "indexed":a.indexed, "docValues": a.docValues,
+          "multiValued":a.multiValued, "stored":stored, "tokenized": a.tokenized};
+        if (type === "field" || type === "dynamicField") {
+          obj.type = a.type;
+        } else if (type === "type") {
+          obj.class = a.class;
+        }
+        nodes.push(obj);
+      }
+    }
+    return nodes;
+  }
+
+  function stripAnchorSuffix(id) {
+    if (id && id.endsWith("_anchor")) {
+      id = id.substring(0, id.length - "_anchor".length);
+    }
+    return id;
+  }
+
+  $scope.onSelectSchemaTreeNode = function (id) {
+    id = stripAnchorSuffix(id);
+    $scope.showFieldDetails = false;
+    $scope.isSchemaRoot = false;
+    $scope.isLeafNode = false;
+    delete $scope.containerNodeLabel;
+    delete $scope.containerNode;
+    delete $scope.containerNodes;
+    delete $scope.selectedFile;
+
+    if (id === "/") {
+      $scope.selectedType = "Schema";
+      $scope.selectedNode = null;
+      $scope.isSchemaRoot = true;
+      $scope.isLeafNode = false;
+      $scope.isContainerNode = false;
+      $scope.showFieldDetails = true;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+
+      if (!$scope.treeFilter) {
+        $scope.treeFilter = "type";
+        $scope.treeFilterOption = "*";
+        $scope.initTreeFilters();
+      }
+      return;
+    }
+
+    var jst = $('#schemaJsTree').jstree(true);
+    if (!jst) {
+      return;
+    }
+
+    var node = jst.get_node(id);
+    if (!node) {
+      return;
+    }
+
+    if (id === "files") {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      return;
+    }
+
+    if (id.indexOf("/") === -1) {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      $scope.containerNode = id;
+
+      if (id === "fields") {
+        $scope.containerNodes = fieldNodes($scope.fieldsNode ? $scope.fieldsNode.children : $scope.fieldsSrc, "field");
+      } else if (id === "dynamicFields") {
+        $scope.containerNodes = fieldNodes($scope.dynamicFieldsNode ? $scope.dynamicFieldsNode.children : $scope.dynamicFieldsSrc, "dynamicField");
+      } else if (id === "fieldTypes") {
+        $scope.containerNodes = fieldNodes($scope.fieldTypes, "type");
+      }
+
+      $scope.containerNodeLabel = node.text;
+      $scope.showFieldDetails = true;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+      return;
+    }
+
+    if (id.startsWith("files/")) {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+      if (node.children.length === 0) {
+        // file
+        $scope.showFieldDetails = true;
+        $scope.onSelectFileNode(id, false);
+      } else {
+        // folder
+        $scope.showFieldDetails = false;
+        delete $scope.selectedFile;
+      }
+      return;
+    }
+
+    delete $scope.selectedFile;
+    $scope.selectedNode = node["a_attr"]; // all the info we need is in the a_attr object
+    if (!$scope.selectedNode) {
+      // a node in the tree that isn't a field was selected, just ignore
+      return;
+    }
+
+    $scope.selectedNode.fieldType = getType($scope.selectedNode.type);
+    $scope.isLeafNode = true;
+
+    var nodeType = id.substring(0, id.indexOf("/"));
+    var name = null;
+    if (nodeType === "field") {
+      $scope.selectedType = "Field";
+      name = $scope.selectedNode.type;
+    } else if (nodeType === "dynamic") {
+      $scope.selectedType = "Dynamic Field";
+    } else if (nodeType === "type") {
+      $scope.selectedType = "Type";
+      name = $scope.selectedNode.name;
+    }
+
+    if (name) {
+      $scope.initTypeAnalysisInfo(name, "type");
+    }
+
+    // apply some sanity to the checkboxes
+    $scope.selectedNode = applyConstraintsOnField($scope.selectedNode);
+    $scope.showFieldDetails = true;
+
+    if (nodeType === "field" && $scope.selectedNode.tokenized && $scope.selectedNode.stored) {
+      $scope.showAnalysis = true;
+      $scope.updateSampleDocId();
+    } else {
+      $scope.showAnalysis = false;
+      $scope.indexText = "";
+      $scope.result = {};
+    }
+  };
+
+  function addFileNode(dirs, parent, f) {
+    var path = f.split("/");
+    if (path.length === 1) {
+      if (!parent.children) {
+        parent.children = [];
+        dirs.push(parent); // now parent has children, so track in dirs ...
+      }
+      var nodeId = parent.id + "/" + f;
+      parent.children.push({"text": f, "id": nodeId, "a_attr": {"href": nodeId}});
+    } else {
+      // get the parent for this path
+      var parentId = "files/" + path.slice(0, path.length - 1).join("/");
+      var dir = null;
+      for (var d in dirs) {
+        if (dirs[d].id === parentId) {
+          dir = dirs[d];
+          break;
+        }
+      }
+      if (!dir) {
+        dir = {"text": path[0], "id": parentId, "a_attr": {"href": parentId}, "children": []};
+        dirs.push(dir);
+        parent.children.push(dir);
+      }
+
+      // walk down the next level in this path
+      addFileNode(dirs, dir, path.slice(1).join("/"));
+    }
+  }
+
+  // transform a flat list structure into the nested tree structure
+  function filesToTree(files) {
+    var filesNode = {"text": "Files", "a_attr": {"href": "files"}, "id": "files", "children": []};
+    if (files) {
+      var dirs = []; // lookup for known dirs by path since nodes don't keep a ref to their parent node
+      for (var i in files) {
+        // hide the configoverlay.json from the UI
+        if (files[i] === "configoverlay.json") {
+          continue;
+        }
+
+        addFileNode(dirs, filesNode, files[i]);
+      }
+      delete dirs;

Review comment:
       *W051:*  Variables should not be deleted.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/query.js
##########
@@ -19,17 +19,80 @@ solrAdminApp.controller('QueryController',
   function($scope, $routeParams, $location, Query, Constants){
     $scope.resetMenu("query", Constants.IS_COLLECTION_PAGE);
 
-    // @todo read URL parameters into scope
-    $scope.query = {q:'*:*'};
+    $scope._models = [];
     $scope.filters = [{fq:""}];
-    $scope.dismax = {defType: "dismax"};
-    $scope.edismax = {defType: "edismax", stopwords: true, lowercaseOperators: false};
-    $scope.hl = {hl:"on"};
-    $scope.facet = {facet: "on"};
-    $scope.spatial = {};
-    $scope.spellcheck = {spellcheck:"on"};
-    $scope.debugQuery = {debugQuery: "on"};
-    $scope.qt = "/select";
+    $scope.val = {};
+    $scope.val['q'] = "*:*";
+    $scope.val['q.op'] = "OR";
+    $scope.val['defType'] = "";

Review comment:
       *W069:*  ['defType'] is better written in dot notation.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/query.js
##########
@@ -19,17 +19,80 @@ solrAdminApp.controller('QueryController',
   function($scope, $routeParams, $location, Query, Constants){
     $scope.resetMenu("query", Constants.IS_COLLECTION_PAGE);
 
-    // @todo read URL parameters into scope
-    $scope.query = {q:'*:*'};
+    $scope._models = [];
     $scope.filters = [{fq:""}];
-    $scope.dismax = {defType: "dismax"};
-    $scope.edismax = {defType: "edismax", stopwords: true, lowercaseOperators: false};
-    $scope.hl = {hl:"on"};
-    $scope.facet = {facet: "on"};
-    $scope.spatial = {};
-    $scope.spellcheck = {spellcheck:"on"};
-    $scope.debugQuery = {debugQuery: "on"};
-    $scope.qt = "/select";
+    $scope.val = {};
+    $scope.val['q'] = "*:*";
+    $scope.val['q.op'] = "OR";
+    $scope.val['defType'] = "";
+    $scope.val['indent'] = true;

Review comment:
       *W069:*  ['indent'] is better written in dot notation.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/query.js
##########
@@ -87,32 +166,55 @@ solrAdminApp.controller('QueryController',
         params.handler = "select";
         set("qt", qt);
       }
+      // create rest result url
       var url = Query.url(params);
+
+      // create admin page url
+      var adminParams = {...params};
+      delete adminParams.handler;
+      delete adminParams.core
+      if( $scope.qt != null ) {
+        adminParams.qt = [$scope.qt];
+      }
+
       Query.query(params, function(data) {
-        $scope.lang = $scope.query.wt;
+        $scope.lang = $scope.val['wt'];

Review comment:
       *W069:*  ['wt'] is better written in dot notation.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/schema-designer.js
##########
@@ -0,0 +1,2014 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $cookies, $window, Constants, SchemaDesigner, Luke) {
+  $scope.resetMenu("schema-designer", Constants.IS_ROOT_PAGE);
+
+  $scope.onWarning = function (warnMsg, warnDetails) {
+    $scope.updateWorking = false;
+    delete $scope.updateStatusMessage;
+    $scope.apiWarning = warnMsg;
+    $scope.apiWarningDetails = warnDetails;
+  };
+  
+  $scope.onError = function (errorMsg, errorCode, errorDetails) {
+    $scope.updateWorking = false;
+    delete $scope.updateStatusMessage;
+    $scope.designerAPIError = errorMsg;
+    if (errorDetails) {
+      var errorDetailsStr = "";
+      if (errorDetails["error"]) {
+        errorDetailsStr = errorDetails["error"];
+      } else {
+        for (var id in errorDetails) {
+          var msg = errorDetails[id];
+          var at = msg.indexOf("ERROR: ");
+          if (at !== -1) {
+            msg = msg.substring(at+7);
+          }
+          if (!msg.includes(id)) {
+            msg = id+": "+msg;
+          }
+          errorDetailsStr += msg+"\n\n";
+        }
+      }
+      $scope.designerAPIErrorDetails = errorDetailsStr;
+    } else {
+      delete $scope.designerAPIErrorDetails;
+    }
+
+    if (errorCode === 409) {
+      $scope.schemaVersion = -1; // reset to get the latest
+      $scope.isVersionMismatch = true;
+      $scope.errorButton = "Reload Schema";
+    } else if (errorCode < 500) {
+      $scope.isVersionMismatch = false;
+      $scope.errorButton = "OK";
+    } // else 500 errors get the top-level error message
+  };
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error) {
+      $scope.onError(error.msg, error.code, e.data.errorDetails);
+    } else {
+      // when a timeout occurs, the error details are sparse so just give the user a hint that something was off
+      var path = e.config && e.config.url ? e.config.url : "/api/schema-designer";
+      var reloadMsg = "";
+      if (path.includes("/analyze")) {
+        reloadMsg = " Re-try analyzing your sample docs by clicking on 'Analyze Documents' again."
+      }
+      $scope.onError("Request to "+path+" failed!", 408,
+          {"error":"Most likely the request timed out; check server log for more details."+reloadMsg});
+    }
+  };
+
+  $scope.closeWarnDialog = function () {
+    delete $scope.apiWarning;
+    delete $scope.apiWarningDetails;
+  };
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.designerAPIError;
+    delete $scope.designerAPIErrorDetails;
+    if ($scope.isVersionMismatch) {
+      $scope.isVersionMismatch = false;
+      var nodeId = "/";
+      if ($scope.selectedNode) {
+        nodeId = $scope.selectedNode.href;
+      }
+      $scope.doAnalyze(nodeId);
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.isSchemaDesignerEnabled = true;
+
+    delete $scope.helpId;
+
+    $scope.updateStatusMessage = "";
+    $scope.analysisVerbose = false;
+    $scope.updateWorking = false;
+    $scope.currentSchema = "";
+
+    delete $scope.hasDocsOnServer;
+    delete $scope.queryResultsTree;
+
+    $scope.languages = ["*"];
+    $scope.copyFrom = "_default";
+    delete $scope.sampleMessage;
+    delete $scope.sampleDocuments;
+
+    $scope.schemaVersion = -1;
+    $scope.schemaTree = {};
+    $scope.showSchemaActions = false;
+    $scope.sampleDocIds = [];
+    $scope.isSchemaRoot = false;
+
+    delete $scope.enableNestedDocs;
+    delete $scope.enableDynamicFields;
+    delete $scope.enableFieldGuessing;
+
+    // schema editor
+    $scope.showFieldDetails = false;
+    $scope.selectedNode = null;
+    $scope.selectedUpdated = false;
+    delete $scope.updateStatusMessage;
+
+    // text field analysis
+    $scope.showAnalysis = false;
+    $scope.sampleDocId = null;
+    $scope.indexText = "";
+    $scope.result = {};
+
+    // publish vars
+    delete $scope.newCollection;
+    $scope.reloadOnPublish = "true";
+
+    // query form
+    $scope.query = {q: '*:*', sortBy: 'score', sortDir: 'desc'};
+
+    SchemaDesigner.get({path: "configs"}, function (data) {
+
+      $scope.schemas = [];
+      $scope.publishedSchemas = ["_default"];
+
+      for (var s in data.configSets) {
+        // 1 means published but not editable
+        if (data.configSets[s] !== 1) {
+          $scope.schemas.push(s);
+        }
+
+        // 0 means not published yet (so can't copy from it yet)
+        if (data.configSets[s] > 0) {
+          $scope.publishedSchemas.push(s);
+        }
+      }
+
+      $scope.schemas.sort();
+      $scope.publishedSchemas.sort();
+
+      // if no schemas available to select, open the pop-up immediately
+      if ($scope.schemas.length === 0) {
+        $scope.firstSchemaMessage = true;
+        $scope.showNewSchemaDialog();
+      }
+    }, function(e) {
+      if (e.status === 401 || e.status === 403) {
+        $scope.isSchemaDesignerEnabled = false;
+        $scope.hideAll();
+      }
+    });
+  };
+
+  $scope.selectNodeInTree = function(nodeId) {
+    nodeId = stripAnchorSuffix(nodeId);
+    if (!nodeId) return;
+
+    var jst = $('#schemaJsTree').jstree(true);
+    if (jst) {
+      var selectedId = null;
+      var selected_node = jst.get_selected();
+      if (selected_node && selected_node.length > 0) {
+        selectedId = selected_node[0];
+      }
+      if (selectedId) {
+        try {
+          jst.deselect_node(selectedId);
+        } catch (err) {
+          // just ignore
+          //console.log("error deselecting "+selectedId);
+        }
+      }
+
+      try {
+        jst.select_node(nodeId, true);
+      } catch (err) {
+        // just ignore, some low-level tree issue
+        //console.log("error selecting "+nodeId);
+      }
+    }
+  };
+
+  $scope.loadFile = function (event) {
+    var t = event.target || event.srcElement || event.currentTarget;
+    if (t && t.text) {
+      $scope.onSelectFileNode("files/" + t.text, true);
+    }
+  };
+
+  $scope.confirmEditSchema = function () {
+    $scope.showConfirmEditSchema = false;
+    if ($scope.hasDocsOnServer || $scope.published) {
+      $scope.doAnalyze();
+    } else {
+      $scope.sampleMessage = "Please upload or paste some sample documents to analyze for building the '" + $scope.currentSchema + "' schema.";
+    }
+  };
+
+  $scope.cancelEditSchema = function () {
+    $scope.currentSchema = "";
+    $scope.showConfirmEditSchema = false;
+  };
+
+  $scope.loadSchema = function () {
+
+    if (!$scope.currentSchema) {
+      return;
+    }
+
+    $scope.resetSchema();
+    var params = {path: "info", configSet: $scope.currentSchema};
+    SchemaDesigner.get(params, function (data) {
+      $scope.currentSchema = data.configSet;
+      $("#select-schema").trigger("chosen:updated");
+
+      $scope.confirmSchema = data.configSet;
+      $scope.collectionsForConfig = data.collections;
+      $scope.hasDocsOnServer = data.numDocs > 0;
+      $scope.published = data.published;
+      $scope.initDesignerSettingsFromResponse(data);
+      if ($scope.collectionsForConfig && $scope.collectionsForConfig.length > 0) {
+        $scope.showConfirmEditSchema = true;
+      } else {
+        if ($scope.hasDocsOnServer || $scope.published) {
+          $scope.doAnalyze();
+        } else {
+          $scope.sampleMessage = "Please upload or paste some sample documents to build the '" + $scope.currentSchema + "' schema.";
+        }
+      }
+    });
+  };
+
+  $scope.showNewSchemaDialog = function () {
+    $scope.hideAll();
+    $scope.showNewSchema = true;
+    $scope.newSchema = "";
+  };
+
+  $scope.addSchema = function () {
+    $scope.firstSchemaMessage = false;
+    delete $scope.addMessage;
+
+    if (!$scope.newSchema) {
+      $scope.addMessage = "Please provide a schema name!";
+      return;
+    }
+
+    $scope.newSchema = $scope.newSchema.trim();
+    if ($scope.newSchema.length > 50) {
+      $scope.addMessage = "Schema name be 50 characters or less";
+      return;
+    }
+
+    if ($scope.newSchema.indexOf(" ") !== -1 || $scope.newSchema.indexOf("/") !== -1) {
+      $scope.addMessage = "Schema name should not contain spaces or /";
+      return;
+    }
+
+    if ($scope.publishedSchemas.includes($scope.newSchema) || $scope.schemas.includes($scope.newSchema)) {
+      $scope.addMessage = "Schema '" + $scope.newSchema + "' already exists!";
+      return;
+    }
+
+    delete $scope.addMessage;
+    if (!$scope.copyFrom) {
+      $scope.copyFrom = "_default";
+    }
+
+    $scope.resetSchema();
+    $scope.schemas.push($scope.newSchema);
+    $scope.showNewSchema = false;
+    $scope.currentSchema = $scope.newSchema;
+    $scope.sampleMessage = "Please upload or paste some sample documents to analyze for building the '" + $scope.currentSchema + "' schema.";
+
+    SchemaDesigner.post({path: "prep", configSet: $scope.newSchema, copyFrom: $scope.copyFrom}, null, function (data) {
+      $scope.initDesignerSettingsFromResponse(data);
+    }, $scope.errorHandler);
+  };
+
+  $scope.cancelAddSchema = function () {
+    delete $scope.addMessage;
+    delete $scope.sampleMessage;
+
+    $scope.showNewSchema = false
+  };
+
+  $scope.hideAll = function () {
+    delete $scope.helpId;
+    $scope.showPublish = false;
+    $scope.showDiff = false;
+    $scope.showNewSchema = false;
+    $scope.showAddField = false;
+    $scope.showAddDynamicField = false;
+    $scope.showAddCopyField = false;
+    $scope.showAnalysis = false;
+    // add more dialogs here
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.hideData = function () {
+    $scope.showData = false;
+  };
+
+  $scope.rootChanged = function () {
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  $scope.updateUniqueKey = function () {
+    delete $scope.schemaRootMessage;
+    var jst = $('#schemaJsTree').jstree();
+    if (jst) {
+      var node = jst.get_node("field/" + $scope.updateUniqueKeyField);
+      if (node && node.a_attr) {
+        var attrs = node.a_attr;
+        if (attrs.multiValued || attrs.tokenized || !attrs.stored || !attrs.indexed) {
+          $scope.schemaRootMessage = "Field '" + $scope.updateUniqueKeyField +
+              "' cannot be used as the uniqueKey field! Must be single-valued, stored, indexed, and not tokenized.";
+          $scope.updateUniqueKeyField = $scope.uniqueKeyField;
+          return;
+        }
+      }
+    }
+    $scope.uniqueKeyField = $scope.updateUniqueKeyField;
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  $scope.resetSchema = function () {
+    $scope.hideAll();
+    $scope.analysisVerbose = false;
+    $scope.showSchemaActions = false;
+    $scope.showAnalysis = false;
+    $scope.showFieldDetails = false;
+    $scope.hasDocsOnServer = false;
+    $scope.query = {q: '*:*', sortBy: 'score', sortDir: 'desc'};
+    $scope.schemaVersion = -1;
+
+    $scope.updateWorking = false;
+    $scope.isVersionMismatch = false;
+    delete $scope.updateStatusMessage;
+    delete $scope.designerAPIError;
+    delete $scope.designerAPIErrorDetails;
+    delete $scope.selectedFacets;
+    delete $scope.sampleDocuments;
+    delete $scope.selectedNode;
+    delete $scope.queryResultsTree;
+  };
+
+  $scope.onSchemaUpdated = function (schema, data, nodeId) {
+    $scope.hasDocsOnServer = data.numDocs && data.numDocs > 0;
+    $scope.uniqueKeyField = data.uniqueKeyField;
+    $scope.updateUniqueKeyField = $scope.uniqueKeyField;
+    $scope.initDesignerSettingsFromResponse(data);
+
+    var fieldTypes = fieldTypesToTree(data.fieldTypes);
+    var files = filesToTree(data.files);
+
+    var rootChildren = [];
+    $scope.fieldsSrc = fieldsToTree(data.fields);
+    $scope.fieldsNode = {
+      "id": "fields",
+      "text": "Fields",
+      "state": {"opened": true},
+      "a_attr": {"href": "fields"},
+      "children": $scope.fieldsSrc
+    };
+    rootChildren.push($scope.fieldsNode);
+
+    if ($scope.enableDynamicFields === "true") {
+      $scope.dynamicFieldsSrc = fieldsToTree(data.dynamicFields);
+      $scope.dynamicFieldsNode = {
+        "id": "dynamicFields",
+        "text": "Dynamic Fields",
+        "a_attr": {"href": "dynamicFields"},
+        "children": $scope.dynamicFieldsSrc
+      };
+      rootChildren.push($scope.dynamicFieldsNode);
+    } else {
+      delete $scope.dynamicFieldsNode;
+      delete $scope.dynamicFieldsSrc;
+    }
+
+    rootChildren.push({"id":"fieldTypes", "text": "Field Types", "a_attr": {"href": "fieldTypes"}, "children": fieldTypes});
+    rootChildren.push(files);
+
+    var tree = [{"id":"/", "text": schema, "a_attr": {"href": "/"}, "state":{"opened": true}, "children": rootChildren}];
+
+    $scope.fields = data.fields;
+    $scope.fieldNames = data.fields.map(f => f.name).sort();
+    $scope.possibleIdFields = data.fields.filter(f => f.indexed && f.stored && !f.tokenized).map(f => f.name).sort();
+    $scope.sortableFields = data.fields.filter(f => (f.indexed || f.docValues) && !f.multiValued && !f.tokenized).map(f => f.name).sort();
+    $scope.sortableFields.push("score");
+
+    $scope.facetFields = data.fields.filter(f => (f.indexed || f.docValues) && !f.tokenized && f.name !== '_version_').map(f => f.name).sort();
+    $scope.hlFields = data.fields.filter(f => f.stored && f.tokenized).map(f => f.name).sort();
+
+    $scope.schemaVersion = data.schemaVersion;
+    $scope.currentSchema = data.configSet;
+    $scope.fieldTypes = fieldTypes;
+    $scope.core = data.core;
+    $scope.schemaTree = tree;
+    $scope.refreshTree();
+
+    $scope.collectionsForConfig = data.collectionsForConfig;
+
+    if (data.docIds) {
+      $scope.sampleDocIds = data.docIds;
+    }
+
+    // re-apply the filters on the updated schema
+    $scope.onTreeFilterOptionChanged();
+
+    // Load the Luke schema
+    Luke.schema({core: data.core}, function (schema) {
+      Luke.raw({core: data.core}, function (index) {
+        $scope.luke = mergeIndexAndSchemaData(index, schema.schema);
+        $scope.types = Object.keys(schema.schema.types);
+        $scope.showSchemaActions = true;
+        if (!nodeId) {
+          nodeId = "/";
+        }
+        $scope.onSelectSchemaTreeNode(nodeId);
+
+        $scope.updateWorking = false;
+
+        if (data.updateError != null) {
+          $scope.onError(data.updateError, data.updateErrorCode, data.errorDetails);
+        } else {
+          if ($scope.selectedUpdated) {
+            $scope.selectedUpdated = false;
+            $scope.updateStatusMessage = "Changes applied successfully.";
+            var waitMs = 3000;
+            if (data.rebuild) {
+              $scope.updateStatusMessage += " Did full re-index of sample docs due to incompatible update.";
+              waitMs = 5000; // longer message, more time to read
+            }
+
+            if (data.analysisError) {
+              var updateType = data["updateType"];
+              var updatedObject = data[updateType];
+              var updatedName = updatedObject && updatedObject.name ? updatedObject.name : "";
+              var warnMsg = "Changes to "+updateType+" "+updatedName+" applied, but required the temp collection to be deleted " +
+                  "and re-created due to an incompatible Lucene change, see details below.";
+              $scope.onWarning(warnMsg, data.analysisError);
+            } else {
+              $timeout(function () { delete $scope.updateStatusMessage; }, waitMs);
+            }
+          } else {
+            var source = data.sampleSource;
+            if (source) {
+              if (source === "paste") {
+                source = "pasted sample"
+              } else if (source === "blob") {
+                source = "previous upload stored on the server"
+              }
+              if (data.numDocs > 0) {
+                $scope.updateStatusMessage = "Analyzed "+data.numDocs+" docs from "+source;
+              } else {
+                $scope.updateStatusMessage = "Schema '"+$scope.currentSchema+"' loaded.";
+              }
+            }
+            $timeout(function () {
+              delete $scope.updateStatusMessage;
+            }, 5000);
+          }
+        }
+
+        // re-fire the current query to reflect the updated schema
+        $scope.doQuery();
+        $scope.selectNodeInTree(nodeId);
+      });
+    });
+  };
+
+  $scope.toggleAddField = function (type) {
+    if ($scope.showAddField) {
+      $scope.hideAll();
+    } else {
+      $scope.hideAll();
+      $scope.showAddField = true;
+      $scope.adding = type;
+
+      $scope.newField = {
+        stored: "true",
+        indexed: "true",
+        uninvertible: "true",
+        docValues: "true"
+      }
+
+      if (type === "field") {
+        $scope.newField.type = "string";
+      }
+
+      delete $scope.addErrors;
+    }
+  };
+
+  function applyConstraintsOnField(f) {
+
+    if (!f.docValues) {
+      delete f.useDocValuesAsStored;
+    }
+
+    if (!f.docValues && !f.uninvertible) {
+      delete f.sortMissingLast; // remove this setting if no docValues / uninvertible
+    }
+
+    if (f.indexed) {
+      if (f.omitTermFreqAndPositions && !f.omitPositions) {
+        delete f.omitPositions; // :shrug ~ see SchemaField ln 295
+      }
+      if (!f.termVectors) {
+        delete f.termPositions;
+        delete f.termOffsets;
+        delete f.termPayloads;
+      }
+    } else {
+      // if not indexed, a bunch of fields are false
+      f.tokenized = false;
+      f.uninvertible = false;
+
+      // drop these from the request
+      delete f.termVectors;
+      delete f.termPositions;
+      delete f.termOffsets;
+      delete f.termPayloads;
+      delete f.omitNorms;
+      delete f.omitPositions;
+      delete f.omitTermFreqAndPositions;
+      delete f.storeOffsetsWithPositions;
+    }
+
+    return f;
+  }
+
+  $scope.addField = function () {
+
+    // validate the form input
+    $scope.addErrors = [];
+    if (!$scope.newField.name) {
+      $scope.addErrors.push($scope.adding + " name is required!");
+    }
+
+    if ($scope.newField.name.indexOf(" ") != -1) {
+      $scope.addErrors.push($scope.adding + " name should not have whitespace");
+    }
+
+    var command = "add-field-type";
+    if ("field" === $scope.adding) {
+
+      if ($scope.fieldNames.includes($scope.newField.name)) {
+        $scope.addErrors.push("Field '" + $scope.newField.name + "' already exists!");
+        return;
+      }
+
+      // TODO: is this the correct logic for detecting dynamic? Probably good enough for the designer
+      var isDynamic = $scope.newField.name.startsWith("*") || $scope.newField.name.endsWith("*");
+      if (isDynamic) {
+        if ($scope.luke && $scope.luke.dynamic_fields[$scope.newField.name]) {
+          $scope.addErrors.push("dynamic field '" + $scope.newField.name + "' already exists!");
+        }
+      } else {
+        if ($scope.luke && $scope.luke.fields[$scope.newField.name]) {
+          $scope.addErrors.push("field '" + $scope.newField.name + "' already exists!");
+        }
+      }
+
+      if (!$scope.newField.type) {
+        $scope.addErrors.push("field type is required!");
+      }
+
+      command = isDynamic ? "add-dynamic-field" : "add-field";
+    } else if ("type" === $scope.adding) {
+      if ($scope.types.includes($scope.newField.name)) {
+        $scope.addErrors.push("Type '" + $scope.newField.name + "' already exists!");
+      }
+
+      if (!$scope.newField.class) {
+        $scope.addErrors.push("class is required when creating a new field type!");
+      }
+    }
+
+    var addData = {};
+    addData[command] = applyConstraintsOnField($scope.newField);
+    if ($scope.textAnalysisJson) {
+      var text = $scope.textAnalysisJson.trim();
+      if (text.length > 0) {
+        text = text.replace(/\s+/g, ' ');
+        if (!text.startsWith("{")) {
+          text = "{ " + text + " }";
+        }
+        try {
+          var textJson = JSON.parse(text);
+          if (textJson.analyzer) {
+            addData[command].analyzer = textJson.analyzer;
+          } else {
+            if (!textJson.indexAnalyzer || !textJson.queryAnalyzer) {
+              $scope.addErrors.push("Text analysis JSON should define either an 'analyzer' or an 'indexAnalyzer' and 'queryAnalyzer'");
+              return;
+            }
+            addData[command].indexAnalyzer = textJson.indexAnalyzer;
+            addData[command].queryAnalyzer = textJson.queryAnalyzer;
+          }
+        } catch (e) {
+          $scope.addErrors.push("Failed to parse analysis as JSON due to: " + e.message +
+              "; expected JSON object with either an 'analyzer' or 'indexAnalyzer' and 'queryAnalyzer'");
+          return;
+        }
+      }
+    }
+
+    if ($scope.addErrors.length > 0) {
+      return;
+    }
+    delete $scope.addErrors; // no errors!
+
+    SchemaDesigner.post({
+      path: "add",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, addData, function (data) {
+      if (data.errors) {
+        $scope.addErrors = data.errors[0].errorMessages;
+        if (typeof $scope.addErrors === "string") {
+          $scope.addErrors = [$scope.addErrors];
+        }
+      } else {
+        delete $scope.textAnalysisJson;
+        $scope.added = true;
+        $timeout(function () {
+          $scope.showAddField = false;
+          $scope.added = false;
+          var nodeId = "/";
+          if ("field" === $scope.adding) {
+            nodeId = "field/" + data[command];
+          } else if ("type" === $scope.adding) {
+            nodeId = "type/" + data[command];
+          }
+          $scope.onSchemaUpdated(data.configSet, data, nodeId);
+        }, 500);
+      }
+    }, $scope.errorHandler);
+  }
+
+  function toSortedNameAndTypeList(fields, typeAttr) {
+    var list = [];
+    var keys = Object.keys(fields);
+    for (var f in keys) {
+      var field = fields[keys[f]];
+      var type = field[typeAttr];
+      if (type) {
+        list.push(field.name + ": "+type);
+      } else {
+        list.push(field.name);
+      }
+    }
+    return list.sort();
+  }
+
+  function toSortedFieldList(fields) {
+    var list = [];
+    var keys = Object.keys(fields);
+    for (var f in keys) {
+      list.push(fields[keys[f]]);
+    }
+    return list.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  $scope.toggleDiff = function (event) {
+    if ($scope.showDiff) {
+      // toggle, close dialog
+      $scope.showDiff = false;
+      return;
+    }
+
+    if (event) {
+      var t = event.target || event.currentTarget;
+      var leftPos = t.getBoundingClientRect().left - 600;
+      if (leftPos < 0) leftPos = 0;
+      $('#show-diff-dialog').css({left: leftPos});
+    }
+
+    SchemaDesigner.get({ path: "diff", configSet: $scope.currentSchema }, function (data) {
+      var diff = data.diff;
+
+      var dynamicFields = diff.dynamicFields;
+      var enableDynamicFields = data.enableDynamicFields !== null ? data.enableDynamicFields : true;
+      if (!enableDynamicFields) {
+        dynamicFields = null;
+      }
+
+      $scope.diffSource = data["diff-source"];
+      $scope.schemaDiff = {
+        "fieldsDiff": diff.fields,
+        "addedFields": [],
+        "removedFields": [],
+        "fieldTypesDiff": diff.fieldTypes,
+        "removedTypes": [],
+        "dynamicFieldsDiff": dynamicFields,
+        "copyFieldsDiff": diff.copyFields
+      }
+      if (diff.fields && diff.fields.added) {
+        $scope.schemaDiff.addedFields = toSortedFieldList(diff.fields.added);
+      }
+      if (diff.fields && diff.fields.removed) {
+        $scope.schemaDiff.removedFields = toSortedNameAndTypeList(diff.fields.removed, "type");
+      }
+      if (diff.fieldTypes && diff.fieldTypes.removed) {
+        $scope.schemaDiff.removedTypes = toSortedNameAndTypeList(diff.fieldTypes.removed, "class");
+      }
+
+      $scope.schemaDiffExists = !(diff.fields == null && diff.fieldTypes == null && dynamicFields == null && diff.copyFields == null);
+      $scope.showDiff = true;
+    });
+  }
+
+  $scope.togglePublish = function (event) {
+    if (event) {
+      var t = event.target || event.currentTarget;
+      var leftPos = t.getBoundingClientRect().left - 515;
+      if (leftPos < 0) leftPos = 0;
+      $('#publish-dialog').css({left: leftPos});
+    }
+
+    $scope.showDiff = false;
+    $scope.showPublish = !$scope.showPublish;
+    delete $scope.publishErrors;
+
+    $scope.disableDesigner = "false";
+
+    if ($scope.showPublish && !$scope.newCollection) {
+      $scope.newCollection = {numShards: 1, replicationFactor: 1, indexToCollection: "true"};
+    }
+  };
+
+  $scope.toggleAddCopyField = function () {
+    if ($scope.showAddCopyField) {
+      $scope.hideAll();
+      $scope.showFieldDetails = true;
+    } else {
+      $scope.hideAll();
+      $scope.showAddCopyField = true;
+      $scope.showFieldDetails = false;
+
+      $scope.copyField = {};
+      delete $scope.addCopyFieldErrors;
+    }
+  }
+  $scope.addCopyField = function () {
+    delete $scope.addCopyFieldErrors;
+    var data = {"add-copy-field": $scope.copyField};
+    SchemaDesigner.post({
+      path: "add",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, data, function (data) {
+      if (data.errors) {
+        $scope.addCopyFieldErrors = data.errors[0].errorMessages;
+        if (typeof $scope.addCopyFieldErrors === "string") {
+          $scope.addCopyFieldErrors = [$scope.addCopyFieldErrors];
+        }
+      } else {
+        $scope.showAddCopyField = false;
+        // TODO:
+        //$timeout($scope.refresh, 1500);
+      }
+    }, $scope.errorHandler);
+  }
+
+  $scope.toggleAnalyzer = function (analyzer) {
+    analyzer.show = !analyzer.show;
+  }
+
+  $scope.initTypeAnalysisInfo = function (typeName) {
+    $scope.analysis = getAnalysisInfo($scope.luke, {type: true}, typeName);
+    if ($scope.analysis && $scope.analysis.data) {
+      $scope.className = $scope.analysis.data.className
+    }
+    $scope.editAnalysis = "Edit JSON";
+    $scope.showAnalysisJson = false;
+    delete $scope.analysisJsonText;
+  };
+
+  $scope.toggleVerbose = function () {
+    $scope.analysisVerbose = !$scope.analysisVerbose;
+  };
+
+  $scope.updateSampleDocId = function () {
+    $scope.indexText = "";
+    $scope.result = {};
+
+    if (!$scope.selectedNode) {
+      return;
+    }
+
+    var field = $scope.selectedNode.name;
+    var params = {path: "sample"};
+    params.configSet = $scope.currentSchema;
+    params.uniqueKeyField = $scope.uniqueKeyField;
+    params.field = field;
+
+    if ($scope.sampleDocId) {
+      params.docId = $scope.sampleDocId;
+    } // else the server will pick the first doc with a non-empty text value for the desired field
+
+    SchemaDesigner.get(params, function (data) {
+      $scope.sampleDocId = data[$scope.uniqueKeyField];
+      $scope.indexText = data[field];
+      if (data.analysis && data.analysis["field_names"]) {
+        $scope.result = processFieldAnalysisData(data.analysis["field_names"][field]);
+      }
+    });
+  };
+
+  $scope.changeLanguages = function () {
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  function getType(typeName) {
+    if ($scope.fieldTypes) {
+      for (i in $scope.fieldTypes) {
+        if ($scope.fieldTypes[i].text === typeName) {
+          return $scope.fieldTypes[i];
+        }
+      }
+    }
+    return null;
+  }
+
+  $scope.refreshTree = function() {
+    var jst = $('#schemaJsTree').jstree(true);
+    if (jst) {
+      jst.refresh();
+    }
+  };
+
+  $scope.onSchemaTreeLoaded = function (id) {
+    //console.log(">> on tree loaded");
+  };
+
+  $scope.updateFile = function () {
+    var nodeId = "files/" + $scope.selectedFile;
+    var params = {path: "file", file: $scope.selectedFile, configSet: $scope.currentSchema};
+
+    $scope.updateWorking = true;
+    $scope.updateStatusMessage = "Updating file ...";
+
+    SchemaDesigner.post(params, $scope.fileNodeText, function (data) {
+      if (data.updateFileError) {
+        if (data[$scope.selectedFile]) {
+          $scope.fileNodeText = data[$scope.selectedFile];
+        }
+        $scope.updateFileError = data.updateFileError;
+      } else {
+        delete $scope.updateFileError;
+        $scope.updateStatusMessage = "File '"+$scope.selectedFile+"' updated.";
+        $scope.onSchemaUpdated(data.configSet, data, nodeId);
+      }
+    }, $scope.errorHandler);
+  };
+
+  $scope.onSelectFileNode = function (id, doSelectOnTree) {
+    $scope.selectedFile = id.startsWith("files/") ? id.substring("files/".length) : id;
+
+    var params = {path: "file", file: $scope.selectedFile, configSet: $scope.currentSchema};
+    SchemaDesigner.get(params, function (data) {
+      $scope.fileNodeText = data[$scope.selectedFile];
+      $scope.isLeafNode = false;
+      if (doSelectOnTree) {
+        delete $scope.selectedNode;
+        $scope.isLeafNode = false;
+        $scope.showFieldDetails = true;
+        delete $scope.sampleDocId;
+        $scope.showAnalysis = false;
+        $scope.selectNodeInTree(id);
+      }
+    });
+  };
+  
+  function fieldNodes(src, type) {
+    var nodes = [];
+    for (var c in src) {
+      var childNode = src[c];
+      if (childNode && childNode.a_attr) {
+        var a = childNode.a_attr;
+        var stored = a.stored || (a.docValues && a.useDocValuesAsStored);
+        var obj = {"name":a.name, "indexed":a.indexed, "docValues": a.docValues,
+          "multiValued":a.multiValued, "stored":stored, "tokenized": a.tokenized};
+        if (type === "field" || type === "dynamicField") {
+          obj.type = a.type;
+        } else if (type === "type") {
+          obj.class = a.class;
+        }
+        nodes.push(obj);
+      }
+    }
+    return nodes;
+  }
+
+  function stripAnchorSuffix(id) {
+    if (id && id.endsWith("_anchor")) {
+      id = id.substring(0, id.length - "_anchor".length);
+    }
+    return id;
+  }
+
+  $scope.onSelectSchemaTreeNode = function (id) {
+    id = stripAnchorSuffix(id);
+    $scope.showFieldDetails = false;
+    $scope.isSchemaRoot = false;
+    $scope.isLeafNode = false;
+    delete $scope.containerNodeLabel;
+    delete $scope.containerNode;
+    delete $scope.containerNodes;
+    delete $scope.selectedFile;
+
+    if (id === "/") {
+      $scope.selectedType = "Schema";
+      $scope.selectedNode = null;
+      $scope.isSchemaRoot = true;
+      $scope.isLeafNode = false;
+      $scope.isContainerNode = false;
+      $scope.showFieldDetails = true;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+
+      if (!$scope.treeFilter) {
+        $scope.treeFilter = "type";
+        $scope.treeFilterOption = "*";
+        $scope.initTreeFilters();
+      }
+      return;
+    }
+
+    var jst = $('#schemaJsTree').jstree(true);
+    if (!jst) {
+      return;
+    }
+
+    var node = jst.get_node(id);
+    if (!node) {
+      return;
+    }
+
+    if (id === "files") {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      return;
+    }
+
+    if (id.indexOf("/") === -1) {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      $scope.containerNode = id;
+
+      if (id === "fields") {
+        $scope.containerNodes = fieldNodes($scope.fieldsNode ? $scope.fieldsNode.children : $scope.fieldsSrc, "field");
+      } else if (id === "dynamicFields") {
+        $scope.containerNodes = fieldNodes($scope.dynamicFieldsNode ? $scope.dynamicFieldsNode.children : $scope.dynamicFieldsSrc, "dynamicField");
+      } else if (id === "fieldTypes") {
+        $scope.containerNodes = fieldNodes($scope.fieldTypes, "type");
+      }
+
+      $scope.containerNodeLabel = node.text;
+      $scope.showFieldDetails = true;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+      return;
+    }
+
+    if (id.startsWith("files/")) {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+      if (node.children.length === 0) {
+        // file
+        $scope.showFieldDetails = true;
+        $scope.onSelectFileNode(id, false);
+      } else {
+        // folder
+        $scope.showFieldDetails = false;
+        delete $scope.selectedFile;
+      }
+      return;
+    }
+
+    delete $scope.selectedFile;
+    $scope.selectedNode = node["a_attr"]; // all the info we need is in the a_attr object

Review comment:
       *W069:*  ['a_attr'] is better written in dot notation.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/schema-designer.js
##########
@@ -0,0 +1,2014 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $cookies, $window, Constants, SchemaDesigner, Luke) {
+  $scope.resetMenu("schema-designer", Constants.IS_ROOT_PAGE);
+
+  $scope.onWarning = function (warnMsg, warnDetails) {
+    $scope.updateWorking = false;
+    delete $scope.updateStatusMessage;
+    $scope.apiWarning = warnMsg;
+    $scope.apiWarningDetails = warnDetails;
+  };
+  
+  $scope.onError = function (errorMsg, errorCode, errorDetails) {
+    $scope.updateWorking = false;
+    delete $scope.updateStatusMessage;
+    $scope.designerAPIError = errorMsg;
+    if (errorDetails) {
+      var errorDetailsStr = "";
+      if (errorDetails["error"]) {
+        errorDetailsStr = errorDetails["error"];

Review comment:
       *W069:*  ['error'] is better written in dot notation.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/schema-designer.js
##########
@@ -0,0 +1,2014 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $cookies, $window, Constants, SchemaDesigner, Luke) {
+  $scope.resetMenu("schema-designer", Constants.IS_ROOT_PAGE);
+
+  $scope.onWarning = function (warnMsg, warnDetails) {
+    $scope.updateWorking = false;
+    delete $scope.updateStatusMessage;
+    $scope.apiWarning = warnMsg;
+    $scope.apiWarningDetails = warnDetails;
+  };
+  
+  $scope.onError = function (errorMsg, errorCode, errorDetails) {
+    $scope.updateWorking = false;
+    delete $scope.updateStatusMessage;
+    $scope.designerAPIError = errorMsg;
+    if (errorDetails) {
+      var errorDetailsStr = "";
+      if (errorDetails["error"]) {
+        errorDetailsStr = errorDetails["error"];
+      } else {
+        for (var id in errorDetails) {
+          var msg = errorDetails[id];
+          var at = msg.indexOf("ERROR: ");
+          if (at !== -1) {
+            msg = msg.substring(at+7);
+          }
+          if (!msg.includes(id)) {
+            msg = id+": "+msg;
+          }
+          errorDetailsStr += msg+"\n\n";
+        }
+      }
+      $scope.designerAPIErrorDetails = errorDetailsStr;
+    } else {
+      delete $scope.designerAPIErrorDetails;
+    }
+
+    if (errorCode === 409) {
+      $scope.schemaVersion = -1; // reset to get the latest
+      $scope.isVersionMismatch = true;
+      $scope.errorButton = "Reload Schema";
+    } else if (errorCode < 500) {
+      $scope.isVersionMismatch = false;
+      $scope.errorButton = "OK";
+    } // else 500 errors get the top-level error message
+  };
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error) {
+      $scope.onError(error.msg, error.code, e.data.errorDetails);
+    } else {
+      // when a timeout occurs, the error details are sparse so just give the user a hint that something was off
+      var path = e.config && e.config.url ? e.config.url : "/api/schema-designer";
+      var reloadMsg = "";
+      if (path.includes("/analyze")) {
+        reloadMsg = " Re-try analyzing your sample docs by clicking on 'Analyze Documents' again."
+      }
+      $scope.onError("Request to "+path+" failed!", 408,
+          {"error":"Most likely the request timed out; check server log for more details."+reloadMsg});
+    }
+  };
+
+  $scope.closeWarnDialog = function () {
+    delete $scope.apiWarning;
+    delete $scope.apiWarningDetails;
+  };
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.designerAPIError;
+    delete $scope.designerAPIErrorDetails;
+    if ($scope.isVersionMismatch) {
+      $scope.isVersionMismatch = false;
+      var nodeId = "/";
+      if ($scope.selectedNode) {
+        nodeId = $scope.selectedNode.href;
+      }
+      $scope.doAnalyze(nodeId);
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.isSchemaDesignerEnabled = true;
+
+    delete $scope.helpId;
+
+    $scope.updateStatusMessage = "";
+    $scope.analysisVerbose = false;
+    $scope.updateWorking = false;
+    $scope.currentSchema = "";
+
+    delete $scope.hasDocsOnServer;
+    delete $scope.queryResultsTree;
+
+    $scope.languages = ["*"];
+    $scope.copyFrom = "_default";
+    delete $scope.sampleMessage;
+    delete $scope.sampleDocuments;
+
+    $scope.schemaVersion = -1;
+    $scope.schemaTree = {};
+    $scope.showSchemaActions = false;
+    $scope.sampleDocIds = [];
+    $scope.isSchemaRoot = false;
+
+    delete $scope.enableNestedDocs;
+    delete $scope.enableDynamicFields;
+    delete $scope.enableFieldGuessing;
+
+    // schema editor
+    $scope.showFieldDetails = false;
+    $scope.selectedNode = null;
+    $scope.selectedUpdated = false;
+    delete $scope.updateStatusMessage;
+
+    // text field analysis
+    $scope.showAnalysis = false;
+    $scope.sampleDocId = null;
+    $scope.indexText = "";
+    $scope.result = {};
+
+    // publish vars
+    delete $scope.newCollection;
+    $scope.reloadOnPublish = "true";
+
+    // query form
+    $scope.query = {q: '*:*', sortBy: 'score', sortDir: 'desc'};
+
+    SchemaDesigner.get({path: "configs"}, function (data) {
+
+      $scope.schemas = [];
+      $scope.publishedSchemas = ["_default"];
+
+      for (var s in data.configSets) {
+        // 1 means published but not editable
+        if (data.configSets[s] !== 1) {
+          $scope.schemas.push(s);
+        }
+
+        // 0 means not published yet (so can't copy from it yet)
+        if (data.configSets[s] > 0) {
+          $scope.publishedSchemas.push(s);
+        }
+      }
+
+      $scope.schemas.sort();
+      $scope.publishedSchemas.sort();
+
+      // if no schemas available to select, open the pop-up immediately
+      if ($scope.schemas.length === 0) {
+        $scope.firstSchemaMessage = true;
+        $scope.showNewSchemaDialog();
+      }
+    }, function(e) {
+      if (e.status === 401 || e.status === 403) {
+        $scope.isSchemaDesignerEnabled = false;
+        $scope.hideAll();
+      }
+    });
+  };
+
+  $scope.selectNodeInTree = function(nodeId) {
+    nodeId = stripAnchorSuffix(nodeId);
+    if (!nodeId) return;
+
+    var jst = $('#schemaJsTree').jstree(true);
+    if (jst) {
+      var selectedId = null;
+      var selected_node = jst.get_selected();
+      if (selected_node && selected_node.length > 0) {
+        selectedId = selected_node[0];
+      }
+      if (selectedId) {
+        try {
+          jst.deselect_node(selectedId);
+        } catch (err) {
+          // just ignore
+          //console.log("error deselecting "+selectedId);
+        }
+      }
+
+      try {
+        jst.select_node(nodeId, true);
+      } catch (err) {
+        // just ignore, some low-level tree issue
+        //console.log("error selecting "+nodeId);
+      }
+    }
+  };
+
+  $scope.loadFile = function (event) {
+    var t = event.target || event.srcElement || event.currentTarget;
+    if (t && t.text) {
+      $scope.onSelectFileNode("files/" + t.text, true);
+    }
+  };
+
+  $scope.confirmEditSchema = function () {
+    $scope.showConfirmEditSchema = false;
+    if ($scope.hasDocsOnServer || $scope.published) {
+      $scope.doAnalyze();
+    } else {
+      $scope.sampleMessage = "Please upload or paste some sample documents to analyze for building the '" + $scope.currentSchema + "' schema.";
+    }
+  };
+
+  $scope.cancelEditSchema = function () {
+    $scope.currentSchema = "";
+    $scope.showConfirmEditSchema = false;
+  };
+
+  $scope.loadSchema = function () {
+
+    if (!$scope.currentSchema) {
+      return;
+    }
+
+    $scope.resetSchema();
+    var params = {path: "info", configSet: $scope.currentSchema};
+    SchemaDesigner.get(params, function (data) {
+      $scope.currentSchema = data.configSet;
+      $("#select-schema").trigger("chosen:updated");
+
+      $scope.confirmSchema = data.configSet;
+      $scope.collectionsForConfig = data.collections;
+      $scope.hasDocsOnServer = data.numDocs > 0;
+      $scope.published = data.published;
+      $scope.initDesignerSettingsFromResponse(data);
+      if ($scope.collectionsForConfig && $scope.collectionsForConfig.length > 0) {
+        $scope.showConfirmEditSchema = true;
+      } else {
+        if ($scope.hasDocsOnServer || $scope.published) {
+          $scope.doAnalyze();
+        } else {
+          $scope.sampleMessage = "Please upload or paste some sample documents to build the '" + $scope.currentSchema + "' schema.";
+        }
+      }
+    });
+  };
+
+  $scope.showNewSchemaDialog = function () {
+    $scope.hideAll();
+    $scope.showNewSchema = true;
+    $scope.newSchema = "";
+  };
+
+  $scope.addSchema = function () {
+    $scope.firstSchemaMessage = false;
+    delete $scope.addMessage;
+
+    if (!$scope.newSchema) {
+      $scope.addMessage = "Please provide a schema name!";
+      return;
+    }
+
+    $scope.newSchema = $scope.newSchema.trim();
+    if ($scope.newSchema.length > 50) {
+      $scope.addMessage = "Schema name be 50 characters or less";
+      return;
+    }
+
+    if ($scope.newSchema.indexOf(" ") !== -1 || $scope.newSchema.indexOf("/") !== -1) {
+      $scope.addMessage = "Schema name should not contain spaces or /";
+      return;
+    }
+
+    if ($scope.publishedSchemas.includes($scope.newSchema) || $scope.schemas.includes($scope.newSchema)) {
+      $scope.addMessage = "Schema '" + $scope.newSchema + "' already exists!";
+      return;
+    }
+
+    delete $scope.addMessage;
+    if (!$scope.copyFrom) {
+      $scope.copyFrom = "_default";
+    }
+
+    $scope.resetSchema();
+    $scope.schemas.push($scope.newSchema);
+    $scope.showNewSchema = false;
+    $scope.currentSchema = $scope.newSchema;
+    $scope.sampleMessage = "Please upload or paste some sample documents to analyze for building the '" + $scope.currentSchema + "' schema.";
+
+    SchemaDesigner.post({path: "prep", configSet: $scope.newSchema, copyFrom: $scope.copyFrom}, null, function (data) {
+      $scope.initDesignerSettingsFromResponse(data);
+    }, $scope.errorHandler);
+  };
+
+  $scope.cancelAddSchema = function () {
+    delete $scope.addMessage;
+    delete $scope.sampleMessage;
+
+    $scope.showNewSchema = false
+  };
+
+  $scope.hideAll = function () {
+    delete $scope.helpId;
+    $scope.showPublish = false;
+    $scope.showDiff = false;
+    $scope.showNewSchema = false;
+    $scope.showAddField = false;
+    $scope.showAddDynamicField = false;
+    $scope.showAddCopyField = false;
+    $scope.showAnalysis = false;
+    // add more dialogs here
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.hideData = function () {
+    $scope.showData = false;
+  };
+
+  $scope.rootChanged = function () {
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  $scope.updateUniqueKey = function () {
+    delete $scope.schemaRootMessage;
+    var jst = $('#schemaJsTree').jstree();
+    if (jst) {
+      var node = jst.get_node("field/" + $scope.updateUniqueKeyField);
+      if (node && node.a_attr) {
+        var attrs = node.a_attr;
+        if (attrs.multiValued || attrs.tokenized || !attrs.stored || !attrs.indexed) {
+          $scope.schemaRootMessage = "Field '" + $scope.updateUniqueKeyField +
+              "' cannot be used as the uniqueKey field! Must be single-valued, stored, indexed, and not tokenized.";
+          $scope.updateUniqueKeyField = $scope.uniqueKeyField;
+          return;
+        }
+      }
+    }
+    $scope.uniqueKeyField = $scope.updateUniqueKeyField;
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  $scope.resetSchema = function () {
+    $scope.hideAll();
+    $scope.analysisVerbose = false;
+    $scope.showSchemaActions = false;
+    $scope.showAnalysis = false;
+    $scope.showFieldDetails = false;
+    $scope.hasDocsOnServer = false;
+    $scope.query = {q: '*:*', sortBy: 'score', sortDir: 'desc'};
+    $scope.schemaVersion = -1;
+
+    $scope.updateWorking = false;
+    $scope.isVersionMismatch = false;
+    delete $scope.updateStatusMessage;
+    delete $scope.designerAPIError;
+    delete $scope.designerAPIErrorDetails;
+    delete $scope.selectedFacets;
+    delete $scope.sampleDocuments;
+    delete $scope.selectedNode;
+    delete $scope.queryResultsTree;
+  };
+
+  $scope.onSchemaUpdated = function (schema, data, nodeId) {
+    $scope.hasDocsOnServer = data.numDocs && data.numDocs > 0;
+    $scope.uniqueKeyField = data.uniqueKeyField;
+    $scope.updateUniqueKeyField = $scope.uniqueKeyField;
+    $scope.initDesignerSettingsFromResponse(data);
+
+    var fieldTypes = fieldTypesToTree(data.fieldTypes);
+    var files = filesToTree(data.files);
+
+    var rootChildren = [];
+    $scope.fieldsSrc = fieldsToTree(data.fields);
+    $scope.fieldsNode = {
+      "id": "fields",
+      "text": "Fields",
+      "state": {"opened": true},
+      "a_attr": {"href": "fields"},
+      "children": $scope.fieldsSrc
+    };
+    rootChildren.push($scope.fieldsNode);
+
+    if ($scope.enableDynamicFields === "true") {
+      $scope.dynamicFieldsSrc = fieldsToTree(data.dynamicFields);
+      $scope.dynamicFieldsNode = {
+        "id": "dynamicFields",
+        "text": "Dynamic Fields",
+        "a_attr": {"href": "dynamicFields"},
+        "children": $scope.dynamicFieldsSrc
+      };
+      rootChildren.push($scope.dynamicFieldsNode);
+    } else {
+      delete $scope.dynamicFieldsNode;
+      delete $scope.dynamicFieldsSrc;
+    }
+
+    rootChildren.push({"id":"fieldTypes", "text": "Field Types", "a_attr": {"href": "fieldTypes"}, "children": fieldTypes});
+    rootChildren.push(files);
+
+    var tree = [{"id":"/", "text": schema, "a_attr": {"href": "/"}, "state":{"opened": true}, "children": rootChildren}];
+
+    $scope.fields = data.fields;
+    $scope.fieldNames = data.fields.map(f => f.name).sort();
+    $scope.possibleIdFields = data.fields.filter(f => f.indexed && f.stored && !f.tokenized).map(f => f.name).sort();
+    $scope.sortableFields = data.fields.filter(f => (f.indexed || f.docValues) && !f.multiValued && !f.tokenized).map(f => f.name).sort();
+    $scope.sortableFields.push("score");
+
+    $scope.facetFields = data.fields.filter(f => (f.indexed || f.docValues) && !f.tokenized && f.name !== '_version_').map(f => f.name).sort();
+    $scope.hlFields = data.fields.filter(f => f.stored && f.tokenized).map(f => f.name).sort();
+
+    $scope.schemaVersion = data.schemaVersion;
+    $scope.currentSchema = data.configSet;
+    $scope.fieldTypes = fieldTypes;
+    $scope.core = data.core;
+    $scope.schemaTree = tree;
+    $scope.refreshTree();
+
+    $scope.collectionsForConfig = data.collectionsForConfig;
+
+    if (data.docIds) {
+      $scope.sampleDocIds = data.docIds;
+    }
+
+    // re-apply the filters on the updated schema
+    $scope.onTreeFilterOptionChanged();
+
+    // Load the Luke schema
+    Luke.schema({core: data.core}, function (schema) {
+      Luke.raw({core: data.core}, function (index) {
+        $scope.luke = mergeIndexAndSchemaData(index, schema.schema);
+        $scope.types = Object.keys(schema.schema.types);
+        $scope.showSchemaActions = true;
+        if (!nodeId) {
+          nodeId = "/";
+        }
+        $scope.onSelectSchemaTreeNode(nodeId);
+
+        $scope.updateWorking = false;
+
+        if (data.updateError != null) {
+          $scope.onError(data.updateError, data.updateErrorCode, data.errorDetails);
+        } else {
+          if ($scope.selectedUpdated) {
+            $scope.selectedUpdated = false;
+            $scope.updateStatusMessage = "Changes applied successfully.";
+            var waitMs = 3000;
+            if (data.rebuild) {
+              $scope.updateStatusMessage += " Did full re-index of sample docs due to incompatible update.";
+              waitMs = 5000; // longer message, more time to read
+            }
+
+            if (data.analysisError) {
+              var updateType = data["updateType"];
+              var updatedObject = data[updateType];
+              var updatedName = updatedObject && updatedObject.name ? updatedObject.name : "";
+              var warnMsg = "Changes to "+updateType+" "+updatedName+" applied, but required the temp collection to be deleted " +
+                  "and re-created due to an incompatible Lucene change, see details below.";
+              $scope.onWarning(warnMsg, data.analysisError);
+            } else {
+              $timeout(function () { delete $scope.updateStatusMessage; }, waitMs);
+            }
+          } else {
+            var source = data.sampleSource;
+            if (source) {
+              if (source === "paste") {
+                source = "pasted sample"
+              } else if (source === "blob") {
+                source = "previous upload stored on the server"
+              }
+              if (data.numDocs > 0) {
+                $scope.updateStatusMessage = "Analyzed "+data.numDocs+" docs from "+source;
+              } else {
+                $scope.updateStatusMessage = "Schema '"+$scope.currentSchema+"' loaded.";
+              }
+            }
+            $timeout(function () {
+              delete $scope.updateStatusMessage;
+            }, 5000);
+          }
+        }
+
+        // re-fire the current query to reflect the updated schema
+        $scope.doQuery();
+        $scope.selectNodeInTree(nodeId);
+      });
+    });
+  };
+
+  $scope.toggleAddField = function (type) {
+    if ($scope.showAddField) {
+      $scope.hideAll();
+    } else {
+      $scope.hideAll();
+      $scope.showAddField = true;
+      $scope.adding = type;
+
+      $scope.newField = {
+        stored: "true",
+        indexed: "true",
+        uninvertible: "true",
+        docValues: "true"
+      }
+
+      if (type === "field") {
+        $scope.newField.type = "string";
+      }
+
+      delete $scope.addErrors;
+    }
+  };
+
+  function applyConstraintsOnField(f) {
+
+    if (!f.docValues) {
+      delete f.useDocValuesAsStored;
+    }
+
+    if (!f.docValues && !f.uninvertible) {
+      delete f.sortMissingLast; // remove this setting if no docValues / uninvertible
+    }
+
+    if (f.indexed) {
+      if (f.omitTermFreqAndPositions && !f.omitPositions) {
+        delete f.omitPositions; // :shrug ~ see SchemaField ln 295
+      }
+      if (!f.termVectors) {
+        delete f.termPositions;
+        delete f.termOffsets;
+        delete f.termPayloads;
+      }
+    } else {
+      // if not indexed, a bunch of fields are false
+      f.tokenized = false;
+      f.uninvertible = false;
+
+      // drop these from the request
+      delete f.termVectors;
+      delete f.termPositions;
+      delete f.termOffsets;
+      delete f.termPayloads;
+      delete f.omitNorms;
+      delete f.omitPositions;
+      delete f.omitTermFreqAndPositions;
+      delete f.storeOffsetsWithPositions;
+    }
+
+    return f;
+  }
+
+  $scope.addField = function () {
+
+    // validate the form input
+    $scope.addErrors = [];
+    if (!$scope.newField.name) {
+      $scope.addErrors.push($scope.adding + " name is required!");
+    }
+
+    if ($scope.newField.name.indexOf(" ") != -1) {
+      $scope.addErrors.push($scope.adding + " name should not have whitespace");
+    }
+
+    var command = "add-field-type";
+    if ("field" === $scope.adding) {
+
+      if ($scope.fieldNames.includes($scope.newField.name)) {
+        $scope.addErrors.push("Field '" + $scope.newField.name + "' already exists!");
+        return;
+      }
+
+      // TODO: is this the correct logic for detecting dynamic? Probably good enough for the designer
+      var isDynamic = $scope.newField.name.startsWith("*") || $scope.newField.name.endsWith("*");
+      if (isDynamic) {
+        if ($scope.luke && $scope.luke.dynamic_fields[$scope.newField.name]) {
+          $scope.addErrors.push("dynamic field '" + $scope.newField.name + "' already exists!");
+        }
+      } else {
+        if ($scope.luke && $scope.luke.fields[$scope.newField.name]) {
+          $scope.addErrors.push("field '" + $scope.newField.name + "' already exists!");
+        }
+      }
+
+      if (!$scope.newField.type) {
+        $scope.addErrors.push("field type is required!");
+      }
+
+      command = isDynamic ? "add-dynamic-field" : "add-field";
+    } else if ("type" === $scope.adding) {
+      if ($scope.types.includes($scope.newField.name)) {
+        $scope.addErrors.push("Type '" + $scope.newField.name + "' already exists!");
+      }
+
+      if (!$scope.newField.class) {
+        $scope.addErrors.push("class is required when creating a new field type!");
+      }
+    }
+
+    var addData = {};
+    addData[command] = applyConstraintsOnField($scope.newField);
+    if ($scope.textAnalysisJson) {
+      var text = $scope.textAnalysisJson.trim();
+      if (text.length > 0) {
+        text = text.replace(/\s+/g, ' ');
+        if (!text.startsWith("{")) {
+          text = "{ " + text + " }";
+        }
+        try {
+          var textJson = JSON.parse(text);
+          if (textJson.analyzer) {
+            addData[command].analyzer = textJson.analyzer;
+          } else {
+            if (!textJson.indexAnalyzer || !textJson.queryAnalyzer) {
+              $scope.addErrors.push("Text analysis JSON should define either an 'analyzer' or an 'indexAnalyzer' and 'queryAnalyzer'");
+              return;
+            }
+            addData[command].indexAnalyzer = textJson.indexAnalyzer;
+            addData[command].queryAnalyzer = textJson.queryAnalyzer;
+          }
+        } catch (e) {
+          $scope.addErrors.push("Failed to parse analysis as JSON due to: " + e.message +
+              "; expected JSON object with either an 'analyzer' or 'indexAnalyzer' and 'queryAnalyzer'");
+          return;
+        }
+      }
+    }
+
+    if ($scope.addErrors.length > 0) {
+      return;
+    }
+    delete $scope.addErrors; // no errors!
+
+    SchemaDesigner.post({
+      path: "add",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, addData, function (data) {
+      if (data.errors) {
+        $scope.addErrors = data.errors[0].errorMessages;
+        if (typeof $scope.addErrors === "string") {
+          $scope.addErrors = [$scope.addErrors];
+        }
+      } else {
+        delete $scope.textAnalysisJson;
+        $scope.added = true;
+        $timeout(function () {
+          $scope.showAddField = false;
+          $scope.added = false;
+          var nodeId = "/";
+          if ("field" === $scope.adding) {
+            nodeId = "field/" + data[command];
+          } else if ("type" === $scope.adding) {
+            nodeId = "type/" + data[command];
+          }
+          $scope.onSchemaUpdated(data.configSet, data, nodeId);
+        }, 500);
+      }
+    }, $scope.errorHandler);
+  }
+
+  function toSortedNameAndTypeList(fields, typeAttr) {
+    var list = [];
+    var keys = Object.keys(fields);
+    for (var f in keys) {
+      var field = fields[keys[f]];
+      var type = field[typeAttr];
+      if (type) {
+        list.push(field.name + ": "+type);
+      } else {
+        list.push(field.name);
+      }
+    }
+    return list.sort();
+  }
+
+  function toSortedFieldList(fields) {
+    var list = [];
+    var keys = Object.keys(fields);
+    for (var f in keys) {
+      list.push(fields[keys[f]]);
+    }
+    return list.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  $scope.toggleDiff = function (event) {
+    if ($scope.showDiff) {
+      // toggle, close dialog
+      $scope.showDiff = false;
+      return;
+    }
+
+    if (event) {
+      var t = event.target || event.currentTarget;
+      var leftPos = t.getBoundingClientRect().left - 600;
+      if (leftPos < 0) leftPos = 0;
+      $('#show-diff-dialog').css({left: leftPos});
+    }
+
+    SchemaDesigner.get({ path: "diff", configSet: $scope.currentSchema }, function (data) {
+      var diff = data.diff;
+
+      var dynamicFields = diff.dynamicFields;
+      var enableDynamicFields = data.enableDynamicFields !== null ? data.enableDynamicFields : true;
+      if (!enableDynamicFields) {
+        dynamicFields = null;
+      }
+
+      $scope.diffSource = data["diff-source"];
+      $scope.schemaDiff = {
+        "fieldsDiff": diff.fields,
+        "addedFields": [],
+        "removedFields": [],
+        "fieldTypesDiff": diff.fieldTypes,
+        "removedTypes": [],
+        "dynamicFieldsDiff": dynamicFields,
+        "copyFieldsDiff": diff.copyFields
+      }
+      if (diff.fields && diff.fields.added) {
+        $scope.schemaDiff.addedFields = toSortedFieldList(diff.fields.added);
+      }
+      if (diff.fields && diff.fields.removed) {
+        $scope.schemaDiff.removedFields = toSortedNameAndTypeList(diff.fields.removed, "type");
+      }
+      if (diff.fieldTypes && diff.fieldTypes.removed) {
+        $scope.schemaDiff.removedTypes = toSortedNameAndTypeList(diff.fieldTypes.removed, "class");
+      }
+
+      $scope.schemaDiffExists = !(diff.fields == null && diff.fieldTypes == null && dynamicFields == null && diff.copyFields == null);
+      $scope.showDiff = true;
+    });
+  }
+
+  $scope.togglePublish = function (event) {
+    if (event) {
+      var t = event.target || event.currentTarget;
+      var leftPos = t.getBoundingClientRect().left - 515;
+      if (leftPos < 0) leftPos = 0;
+      $('#publish-dialog').css({left: leftPos});
+    }
+
+    $scope.showDiff = false;
+    $scope.showPublish = !$scope.showPublish;
+    delete $scope.publishErrors;
+
+    $scope.disableDesigner = "false";
+
+    if ($scope.showPublish && !$scope.newCollection) {
+      $scope.newCollection = {numShards: 1, replicationFactor: 1, indexToCollection: "true"};
+    }
+  };
+
+  $scope.toggleAddCopyField = function () {
+    if ($scope.showAddCopyField) {
+      $scope.hideAll();
+      $scope.showFieldDetails = true;
+    } else {
+      $scope.hideAll();
+      $scope.showAddCopyField = true;
+      $scope.showFieldDetails = false;
+
+      $scope.copyField = {};
+      delete $scope.addCopyFieldErrors;
+    }
+  }
+  $scope.addCopyField = function () {
+    delete $scope.addCopyFieldErrors;
+    var data = {"add-copy-field": $scope.copyField};
+    SchemaDesigner.post({
+      path: "add",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, data, function (data) {
+      if (data.errors) {
+        $scope.addCopyFieldErrors = data.errors[0].errorMessages;
+        if (typeof $scope.addCopyFieldErrors === "string") {
+          $scope.addCopyFieldErrors = [$scope.addCopyFieldErrors];
+        }
+      } else {
+        $scope.showAddCopyField = false;
+        // TODO:
+        //$timeout($scope.refresh, 1500);
+      }
+    }, $scope.errorHandler);
+  }
+
+  $scope.toggleAnalyzer = function (analyzer) {
+    analyzer.show = !analyzer.show;
+  }
+
+  $scope.initTypeAnalysisInfo = function (typeName) {
+    $scope.analysis = getAnalysisInfo($scope.luke, {type: true}, typeName);
+    if ($scope.analysis && $scope.analysis.data) {
+      $scope.className = $scope.analysis.data.className
+    }
+    $scope.editAnalysis = "Edit JSON";
+    $scope.showAnalysisJson = false;
+    delete $scope.analysisJsonText;
+  };
+
+  $scope.toggleVerbose = function () {
+    $scope.analysisVerbose = !$scope.analysisVerbose;
+  };
+
+  $scope.updateSampleDocId = function () {
+    $scope.indexText = "";
+    $scope.result = {};
+
+    if (!$scope.selectedNode) {
+      return;
+    }
+
+    var field = $scope.selectedNode.name;
+    var params = {path: "sample"};
+    params.configSet = $scope.currentSchema;
+    params.uniqueKeyField = $scope.uniqueKeyField;
+    params.field = field;
+
+    if ($scope.sampleDocId) {
+      params.docId = $scope.sampleDocId;
+    } // else the server will pick the first doc with a non-empty text value for the desired field
+
+    SchemaDesigner.get(params, function (data) {
+      $scope.sampleDocId = data[$scope.uniqueKeyField];
+      $scope.indexText = data[field];
+      if (data.analysis && data.analysis["field_names"]) {
+        $scope.result = processFieldAnalysisData(data.analysis["field_names"][field]);
+      }
+    });
+  };
+
+  $scope.changeLanguages = function () {
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  function getType(typeName) {
+    if ($scope.fieldTypes) {
+      for (i in $scope.fieldTypes) {
+        if ($scope.fieldTypes[i].text === typeName) {
+          return $scope.fieldTypes[i];
+        }
+      }
+    }
+    return null;
+  }
+
+  $scope.refreshTree = function() {
+    var jst = $('#schemaJsTree').jstree(true);
+    if (jst) {
+      jst.refresh();
+    }
+  };
+
+  $scope.onSchemaTreeLoaded = function (id) {
+    //console.log(">> on tree loaded");
+  };
+
+  $scope.updateFile = function () {
+    var nodeId = "files/" + $scope.selectedFile;
+    var params = {path: "file", file: $scope.selectedFile, configSet: $scope.currentSchema};
+
+    $scope.updateWorking = true;
+    $scope.updateStatusMessage = "Updating file ...";
+
+    SchemaDesigner.post(params, $scope.fileNodeText, function (data) {
+      if (data.updateFileError) {
+        if (data[$scope.selectedFile]) {
+          $scope.fileNodeText = data[$scope.selectedFile];
+        }
+        $scope.updateFileError = data.updateFileError;
+      } else {
+        delete $scope.updateFileError;
+        $scope.updateStatusMessage = "File '"+$scope.selectedFile+"' updated.";
+        $scope.onSchemaUpdated(data.configSet, data, nodeId);
+      }
+    }, $scope.errorHandler);
+  };
+
+  $scope.onSelectFileNode = function (id, doSelectOnTree) {
+    $scope.selectedFile = id.startsWith("files/") ? id.substring("files/".length) : id;
+
+    var params = {path: "file", file: $scope.selectedFile, configSet: $scope.currentSchema};
+    SchemaDesigner.get(params, function (data) {
+      $scope.fileNodeText = data[$scope.selectedFile];
+      $scope.isLeafNode = false;
+      if (doSelectOnTree) {
+        delete $scope.selectedNode;
+        $scope.isLeafNode = false;
+        $scope.showFieldDetails = true;
+        delete $scope.sampleDocId;
+        $scope.showAnalysis = false;
+        $scope.selectNodeInTree(id);
+      }
+    });
+  };
+  
+  function fieldNodes(src, type) {
+    var nodes = [];
+    for (var c in src) {
+      var childNode = src[c];
+      if (childNode && childNode.a_attr) {
+        var a = childNode.a_attr;
+        var stored = a.stored || (a.docValues && a.useDocValuesAsStored);
+        var obj = {"name":a.name, "indexed":a.indexed, "docValues": a.docValues,
+          "multiValued":a.multiValued, "stored":stored, "tokenized": a.tokenized};
+        if (type === "field" || type === "dynamicField") {
+          obj.type = a.type;
+        } else if (type === "type") {
+          obj.class = a.class;
+        }
+        nodes.push(obj);
+      }
+    }
+    return nodes;
+  }
+
+  function stripAnchorSuffix(id) {
+    if (id && id.endsWith("_anchor")) {
+      id = id.substring(0, id.length - "_anchor".length);
+    }
+    return id;
+  }
+
+  $scope.onSelectSchemaTreeNode = function (id) {
+    id = stripAnchorSuffix(id);
+    $scope.showFieldDetails = false;
+    $scope.isSchemaRoot = false;
+    $scope.isLeafNode = false;
+    delete $scope.containerNodeLabel;
+    delete $scope.containerNode;
+    delete $scope.containerNodes;
+    delete $scope.selectedFile;
+
+    if (id === "/") {
+      $scope.selectedType = "Schema";
+      $scope.selectedNode = null;
+      $scope.isSchemaRoot = true;
+      $scope.isLeafNode = false;
+      $scope.isContainerNode = false;
+      $scope.showFieldDetails = true;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+
+      if (!$scope.treeFilter) {
+        $scope.treeFilter = "type";
+        $scope.treeFilterOption = "*";
+        $scope.initTreeFilters();
+      }
+      return;
+    }
+
+    var jst = $('#schemaJsTree').jstree(true);
+    if (!jst) {
+      return;
+    }
+
+    var node = jst.get_node(id);
+    if (!node) {
+      return;
+    }
+
+    if (id === "files") {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      return;
+    }
+
+    if (id.indexOf("/") === -1) {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      $scope.containerNode = id;
+
+      if (id === "fields") {
+        $scope.containerNodes = fieldNodes($scope.fieldsNode ? $scope.fieldsNode.children : $scope.fieldsSrc, "field");
+      } else if (id === "dynamicFields") {
+        $scope.containerNodes = fieldNodes($scope.dynamicFieldsNode ? $scope.dynamicFieldsNode.children : $scope.dynamicFieldsSrc, "dynamicField");
+      } else if (id === "fieldTypes") {
+        $scope.containerNodes = fieldNodes($scope.fieldTypes, "type");
+      }
+
+      $scope.containerNodeLabel = node.text;
+      $scope.showFieldDetails = true;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+      return;
+    }
+
+    if (id.startsWith("files/")) {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+      if (node.children.length === 0) {
+        // file
+        $scope.showFieldDetails = true;
+        $scope.onSelectFileNode(id, false);
+      } else {
+        // folder
+        $scope.showFieldDetails = false;
+        delete $scope.selectedFile;
+      }
+      return;
+    }
+
+    delete $scope.selectedFile;
+    $scope.selectedNode = node["a_attr"]; // all the info we need is in the a_attr object
+    if (!$scope.selectedNode) {
+      // a node in the tree that isn't a field was selected, just ignore
+      return;
+    }
+
+    $scope.selectedNode.fieldType = getType($scope.selectedNode.type);
+    $scope.isLeafNode = true;
+
+    var nodeType = id.substring(0, id.indexOf("/"));
+    var name = null;
+    if (nodeType === "field") {
+      $scope.selectedType = "Field";
+      name = $scope.selectedNode.type;
+    } else if (nodeType === "dynamic") {
+      $scope.selectedType = "Dynamic Field";
+    } else if (nodeType === "type") {
+      $scope.selectedType = "Type";
+      name = $scope.selectedNode.name;
+    }
+
+    if (name) {
+      $scope.initTypeAnalysisInfo(name, "type");
+    }
+
+    // apply some sanity to the checkboxes
+    $scope.selectedNode = applyConstraintsOnField($scope.selectedNode);
+    $scope.showFieldDetails = true;
+
+    if (nodeType === "field" && $scope.selectedNode.tokenized && $scope.selectedNode.stored) {
+      $scope.showAnalysis = true;
+      $scope.updateSampleDocId();
+    } else {
+      $scope.showAnalysis = false;
+      $scope.indexText = "";
+      $scope.result = {};
+    }
+  };
+
+  function addFileNode(dirs, parent, f) {
+    var path = f.split("/");
+    if (path.length === 1) {
+      if (!parent.children) {
+        parent.children = [];
+        dirs.push(parent); // now parent has children, so track in dirs ...
+      }
+      var nodeId = parent.id + "/" + f;
+      parent.children.push({"text": f, "id": nodeId, "a_attr": {"href": nodeId}});
+    } else {
+      // get the parent for this path
+      var parentId = "files/" + path.slice(0, path.length - 1).join("/");
+      var dir = null;
+      for (var d in dirs) {
+        if (dirs[d].id === parentId) {
+          dir = dirs[d];
+          break;
+        }
+      }
+      if (!dir) {
+        dir = {"text": path[0], "id": parentId, "a_attr": {"href": parentId}, "children": []};
+        dirs.push(dir);
+        parent.children.push(dir);
+      }
+
+      // walk down the next level in this path
+      addFileNode(dirs, dir, path.slice(1).join("/"));
+    }
+  }
+
+  // transform a flat list structure into the nested tree structure
+  function filesToTree(files) {
+    var filesNode = {"text": "Files", "a_attr": {"href": "files"}, "id": "files", "children": []};
+    if (files) {
+      var dirs = []; // lookup for known dirs by path since nodes don't keep a ref to their parent node
+      for (var i in files) {
+        // hide the configoverlay.json from the UI
+        if (files[i] === "configoverlay.json") {
+          continue;
+        }
+
+        addFileNode(dirs, filesNode, files[i]);
+      }
+      delete dirs;
+    }
+    return filesNode;
+  }
+
+  function fieldsToTree(fields) {
+    var children = [];
+    if (fields) {
+      for (var i in fields) {
+        var id = "field/" + fields[i].name;
+        fields[i].href = id;
+        var text = fields[i].name;
+        if (fields[i].name === $scope.uniqueKeyField) {
+          text += "*"; // unique key field
+        }
+        children.push({"text": text, "a_attr": fields[i], "id": id});
+      }
+    }
+    return children;
+  }
+
+  function fieldTypesToTree(types) {
+    var children = [];
+    for (var i in types) {
+      var ft = types[i]
+      var id = "type/" + ft.name;
+      ft.href = id;
+      children.push({"text": ft.name, "a_attr": ft, "id": id});
+    }
+    return children;
+  }
+
+  $scope.onSampleDocumentsChanged = function () {
+    $scope.hasDocsOnServer = false; // so the updates get sent on next analyze action
+  };
+
+  $scope.initDesignerSettingsFromResponse = function (data) {
+    $scope.enableDynamicFields = data.enableDynamicFields !== null ? "" + data.enableDynamicFields : "true";
+    $scope.enableFieldGuessing = data.enableFieldGuessing !== null ? "" + data.enableFieldGuessing : "true";
+    $scope.enableNestedDocs = data.enableNestedDocs !== null ? "" + data.enableNestedDocs : "false";
+    $scope.languages = data.languages !== null && data.languages.length > 0 ? data.languages : ["*"];
+    $scope.copyFrom = data.copyFrom !== null ? data.copyFrom : "_default";
+  };
+
+  $scope.doAnalyze = function (nodeId) {
+    delete $scope.sampleMessage;
+
+    var schema = $scope.currentSchema;
+    if (schema) {
+      delete $scope.copyFrom;
+    } else {
+      schema = $scope.newSchema;
+      if (!$scope.copyFrom) {
+        $scope.copyFrom = "_default";
+      }
+    }
+
+    if (!schema) {
+      return;
+    }
+
+    var params = {path: "analyze", configSet: schema};
+    if ($scope.schemaVersion && $scope.schemaVersion !== -1) {
+      params.schemaVersion = $scope.schemaVersion;
+    }
+
+    if ($scope.enableDynamicFields) {
+      params.enableDynamicFields = $scope.enableDynamicFields;
+    }
+    if ($scope.enableFieldGuessing) {
+      params.enableFieldGuessing = $scope.enableFieldGuessing;
+    }
+    if ($scope.enableNestedDocs) {
+      params.enableNestedDocs = $scope.enableNestedDocs;
+    }
+
+    if ($scope.languages && $scope.languages.length > 0) {
+      params.languages = $scope.languages;
+    }
+
+    if ($scope.copyFrom) {
+      params.copyFrom = $scope.copyFrom;
+    }
+
+    $scope.updateWorking = true;
+    if ($scope.selectedUpdated) {
+      $scope.updateStatusMessage = "Applying " + $scope.selectedType + " updates ..."
+    } else {
+      $scope.updateStatusMessage = "Analyzing your sample data, schema will load momentarily ..."
+    }
+
+    if (!nodeId && $scope.selectedNode) {
+      nodeId = $scope.selectedNode.id;
+    }
+
+    // a bit tricky ...
+    // so users can upload a file or paste in docs
+    // if they upload a file containing a small (<15K) of text data, then we'll show it in the textarea
+    // they can change the text and re-analyze too
+    // if no changes or nothing uploaded, the server-side uses the latest sample data stored in the blob store
+    if ($scope.fileUpload) {
+      var file = $scope.fileUpload;
+      var fd = new FormData();
+      fd.append('file', file);
+      SchemaDesigner.upload(params, fd, function (data) {
+        $("#upload-file").val("");
+        delete $scope.fileUpload;
+        $scope.onSchemaUpdated(schema, data, nodeId);
+      }, $scope.errorHandler);
+    } else {
+      // don't need to keep re-posting the same sample if already stored in the blob store
+      var postData = null;
+      if (!$scope.hasDocsOnServer) {
+        postData = $scope.sampleDocuments;
+        if (!postData && !$scope.published) {
+          return;
+        }
+      }
+
+      var respHandler = function (data) {
+        $scope.onSchemaUpdated(schema, data, nodeId);
+      };
+
+      // TODO: need a better approach to detecting the content type from text content
+      var contentType = "text/plain";
+      if (postData != null) {
+        var txt = postData.trim();
+        if ((txt.startsWith("[") && txt.includes("]")) || (txt.startsWith("{") && txt.includes("}"))) {
+          contentType = "application/json"
+        } else if (txt.startsWith("<") || txt.includes("<add>") || txt.includes("<!--")) {
+          contentType = "text/xml";
+        } else {
+          contentType = "application/csv";
+        }
+      }
+
+      if (contentType === "text/xml") {
+        SchemaDesigner.postXml(params, postData, respHandler, $scope.errorHandler);
+      } else if (contentType === "application/csv") {
+        SchemaDesigner.postCsv(params, postData, respHandler, $scope.errorHandler);
+      } else {
+        SchemaDesigner.post(params, postData, respHandler, $scope.errorHandler);
+      }
+    }
+  };
+
+  $scope.onFieldTypeChanged = function () {
+
+    var copyFromType = ["stored", "indexed", "multiValued", "docValues", "useDocValuesAsStored", "tokenized", "uninvertible", "termVectors", "termPositions", "termOffsets",
+      "omitNorms", "omitTermFreqAndPositions", "omitPositions", "storeOffsetsWithPositions"];
+
+    var type = $scope.selectedNode.type
+
+    // when the user updates the selected field's type, we go apply the
+    // new field type's properties to the selected node
+    for (var i in $scope.fieldTypes) {
+      if ($scope.fieldTypes[i].text == type) {
+        var ft = $scope.fieldTypes[i];
+        $scope.selectedNode.fieldType = ft;
+        for (var i in copyFromType) {
+          var x = copyFromType[i];
+          if (ft.a_attr[x] !== null) {
+            $scope.selectedNode[x] = ft.a_attr[x];
+          } else {
+            delete $scope.selectedNode[x];
+          }
+        }
+        $scope.selectedUpdated = true;
+        break;
+      }
+    }
+
+    $scope.selectedNode = applyConstraintsOnField($scope.selectedNode);
+    if ($scope.selectedUpdated) {
+      // for luke analysis, we need the type info here, not the specific field into b/c it just changed.
+      $scope.initTypeAnalysisInfo(type, "type");
+    }
+  };
+
+  $scope.isDisabled = function (dep) {
+    if (!$scope.selectedNode) {
+      return false;
+    }
+
+    if (dep === "termVectors") {
+      // termVectors dependency depends on indexed
+      return !($scope.selectedNode.indexed && $scope.selectedNode.termVectors);
+    }
+
+    if (dep === "not-text" || dep === "docValues") {
+      if ($scope.selectedNode.fieldType && $scope.selectedNode.fieldType.a_attr.class === "solr.TextField") {
+        // no doc values for TextField
+        return true;
+      }
+    }
+
+    return $scope.selectedNode[dep] === false;
+  };
+
+  // this updates checkboxes based on the current settings
+  $scope.markSelectedUpdated = function (event) {
+    delete $scope.updateStatusMessage;
+    $scope.selectedUpdated = true; // enable the update button for this field
+  };
+
+  $scope.deleteSelected = function () {
+    // console.log(">> deleteSelected");
+  };
+
+  $scope.updateSelected = function () {
+    if (!$scope.selectedNode) {
+
+      if ($scope.selectedUpdated) {
+        // some root level property changed ... re-analyze
+        $scope.doAnalyze("/");
+      }
+
+      return;
+    }
+
+    delete $scope.updateSelectedError;
+
+    // make a copy for the PUT
+    var href = $scope.selectedNode.href;
+    var id = $scope.selectedNode.id;
+
+    var putData = JSON.parse(JSON.stringify($scope.selectedNode));
+    if ($scope.selectedType === "Field" && putData.copyDest) {
+      var fields = putData.copyDest.split(",");
+      for (var f in fields) {
+        var name = fields[f].trim();
+        if (!$scope.fieldNames.includes(name)) {
+          $scope.updateSelectedError = "Copy to field '" + name + "' doesn't exist!";
+          return;
+        }
+        if (name === $scope.selectedNode.name) {
+          $scope.updateSelectedError = "Cannot copy a field to itself!";
+          return;
+        }
+      }
+    } else {
+      delete putData.copyDest;
+    }
+
+    delete putData.fieldType;
+    delete putData.href;
+    delete putData.id;
+
+    putData = applyConstraintsOnField(putData);
+
+    if ($scope.analysisJsonText && !$scope.selectedNode.type) {
+      var text = $scope.analysisJsonText.trim();
+      if (text.length > 0) {
+        text = text.replace(/\s+/g, ' ');
+        if (!text.startsWith("{")) {
+          text = "{ " + text + " }";
+        }
+        try {
+          var textJson = JSON.parse(text);
+          if (textJson.analyzer) {
+            putData.analyzer = textJson.analyzer;
+          } else {
+            if (textJson.indexAnalyzer && textJson.queryAnalyzer) {
+              putData.indexAnalyzer = textJson.indexAnalyzer;
+              putData.queryAnalyzer = textJson.queryAnalyzer;
+            }
+          }
+        } catch (e) {
+          $scope.updateSelectedError = "Failed to parse analysis as JSON due to: " + e.message +
+              "; expected JSON object with either an 'analyzer' or 'indexAnalyzer' and 'queryAnalyzer'";
+          return;
+        }
+      }
+    }
+
+    $scope.updateWorking = true;
+    $scope.updateStatusMessage = "Updating " + $scope.selectedType + " ...";
+
+    SchemaDesigner.put({
+      path: "update",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, putData, function (data) {
+
+      var nodeType = data.updateType;
+      $scope.schemaVersion = data.schemaVersion;
+      $scope.currentSchema = data.configSet;
+      $scope.core = data.core;
+
+      $scope.selectedNode = data[nodeType];
+      $scope.selectedNode.href = href;
+      $scope.selectedNode.id = id;
+
+      var name = nodeType === "field" ? $scope.selectedNode.type : $scope.selectedNode.name;
+      $scope.initTypeAnalysisInfo(name, "type");
+      $scope.showFieldDetails = true;
+
+      if (nodeType === "field" && $scope.selectedNode.tokenized) {
+        $scope.showAnalysis = true;
+        $scope.updateSampleDocId();
+      }
+
+      $scope.onSchemaUpdated($scope.currentSchema, data, href);
+    }, $scope.errorHandler);
+  };
+
+  // TODO: These are copied from analysis.js, so move to a shared location for both vs. duplicating
+  var getShortComponentName = function (longname) {
+    var short = -1 !== longname.indexOf('$')
+        ? longname.split('$')[1]
+        : longname.split('.').pop();
+    return short.match(/[A-Z]/g).join('');
+  };
+
+  var getCaptionsForComponent = function (data) {
+    var captions = [];
+    for (var key in data[0]) {
+      key = key.replace(/.*#/, '');
+      if (key != "match" && key != "positionHistory") {
+        captions.push(key.replace(/.*#/, ''));
+      }
+    }
+    return captions;
+  };
+
+  var getTokensForComponent = function (data) {
+    var tokens = [];
+    var previousPosition = 0;
+    var index = 0;
+    for (var i in data) {
+      var tokenhash = data[i];
+      var positionDifference = tokenhash.position - previousPosition;
+      for (var j = positionDifference; j > 1; j--) {
+        tokens.push({position: tokenhash.position - j + 1, blank: true, index: index++});
+      }
+
+      var token = {position: tokenhash.position, keys: [], index: index++};
+
+      for (key in tokenhash) {
+        if (key == "match" || key == "positionHistory") {
+          //skip, to not display these keys in the UI
+        } else {
+          var tokenInfo = new Object();
+          tokenInfo.name = key;
+          tokenInfo.value = tokenhash[key];
+          if ('text' === key || 'raw_bytes' === key) {
+            if (tokenhash.match) {
+              tokenInfo.extraclass = 'match'; //to highlight matching text strings
+            }
+          }
+          token.keys.push(tokenInfo);
+        }
+      }
+      tokens.push(token);
+      previousPosition = tokenhash.position;
+    }
+    return tokens;
+  };
+
+  var extractComponents = function (data, result, name) {
+    if (data) {
+      result[name] = [];
+      for (var i = 0; i < data.length; i += 2) {
+        var component = {
+          name: data[i],
+          short: getShortComponentName(data[i]),
+          captions: getCaptionsForComponent(data[i + 1]),
+          tokens: getTokensForComponent(data[i + 1])
+        };
+        result[name].push(component);
+      }
+    }
+  };
+
+  var processFieldAnalysisData = function (analysis) {
+    var response = {};
+    extractComponents(analysis.index, response, "index");
+    return response;
+  };
+
+  $scope.doPublish = function () {
+    var params = {
+      path: "publish",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion,
+      reloadCollections: $scope.reloadOnPublish,
+      cleanupTemp: true,
+      disableDesigner: $scope.disableDesigner
+    };
+    if ($scope.newCollection && $scope.newCollection.name) {
+      params.newCollection = $scope.newCollection.name;
+      params.numShards = $scope.newCollection.numShards;
+      params.replicationFactor = $scope.newCollection.replicationFactor;
+      params.indexToCollection = $scope.newCollection.indexToCollection;
+    }
+    SchemaDesigner.put(params, null, function (data) {
+      $scope.schemaVersion = data.schemaVersion;
+      $scope.currentSchema = data.configSet;
+
+      delete $scope.selectedNode;
+      $scope.currentSchema = "";
+      delete $scope.newSchema;
+      $scope.showPublish = false;
+      $scope.refresh();
+
+      if (data.newCollection) {
+        $window.location.href = "#/" + data.newCollection + "/collection-overview";
+      }
+    }, $scope.errorHandler);
+  };
+
+  $scope.downloadConfig = function () {
+    // have to use an AJAX request so we can supply the Authorization header
+    if (sessionStorage.getItem("auth.header")) {
+      var fileName = $scope.currentSchema+"_configset.zip";
+      var xhr = new XMLHttpRequest();
+      xhr.open("GET", "/api/schema-designer/download/"+fileName+"?wt=raw&configSet="+$scope.currentSchema, true);
+      xhr.setRequestHeader('Authorization', sessionStorage.getItem("auth.header"));
+      xhr.responseType = 'blob';
+      xhr.addEventListener('load',function() {
+        if (xhr.status === 200) {
+          var url = window.URL.createObjectURL(xhr.response);
+          var a = document.createElement('a');
+          a.href = url;
+          a.download = fileName;
+          document.body.append(a);
+          a.click();
+          a.remove();
+          window.URL.revokeObjectURL(url);
+        }
+      })
+      xhr.send();
+    } else {
+      location.href = "/api/schema-designer/download/"+$scope.currentSchema+"_configset.zip?wt=raw&configSet=" + $scope.currentSchema;
+    }
+  };
+
+  function docsToTree(docs) {
+    var children = [];
+    for (var i in docs) {
+      var id = docs[i][$scope.uniqueKeyField];
+      if (!id) {
+        id = "" + i; // id not in results so use the position in results as the value
+      }
+      var nodeId = "doc/" + id;
+      docs[i].href = nodeId;
+      children.push({"text": id, "a_attr": docs[i], "id": nodeId});
+    }
+    return children;
+  }
+
+  function debugToTree(debugObj) {
+    var children = [];
+    for (var x in debugObj) {
+      if (typeof debugObj[x] === 'object') {
+        var obj = debugObj[x];
+        var nodeId = "debug/" + x;
+        var tdata = [];
+        for (var a in obj) {
+          if (typeof obj[a] !== 'object') {
+            tdata.push({name: a, value: obj[a]});
+          }
+        }
+        children.push({"text": x, "a_attr": {"href": nodeId}, "tdata": tdata, "id": nodeId, "children": []});
+      }
+    }
+    return children;
+  }
+
+  function facetsToTree(ff) {
+    var children = [];
+    for (var f in ff) {
+      var nodeId = "facet/" + f;
+      if (ff[f] && ff[f].length > 0) {
+        var facet = ff[f];
+        var tdata = [];
+        for (let i = 0; i < facet.length; i += 2) {
+          tdata.push({name: facet[i], value: facet[i + 1]});
+        }
+        children.push({"text": f, "a_attr": {"href": nodeId}, "tdata": tdata, "id": nodeId, "children": []});
+      }
+    }
+    return children;
+  }
+
+  function hlToTree(hl) {
+    var children = [];
+    for (var f in hl) {
+      var nodeId = "hl/" + f;
+      var tdata = [];
+      var obj = hl[f];
+      for (var a in obj) {
+        var v = obj[a];
+        if (v && v.length > 0) {
+          tdata.push({name: a, value: v[0]});
+        }
+      }
+      if (tdata.length > 0) {
+        children.push({"text": f, "a_attr": {"href": nodeId}, "tdata": tdata, "id": nodeId, "children": []});
+      }
+    }
+    return children;
+  }
+
+  $scope.selectField = function (event) {
+    var t = event.target || event.srcElement || event.currentTarget;
+    if (t && t.text) {
+      var nodeId = "field/" + t.text;
+      $scope.onSelectSchemaTreeNode(nodeId);
+      $scope.selectNodeInTree(nodeId);
+    }
+  };
+
+  $scope.selectFieldType = function (event) {
+    var t = event.target || event.srcElement || event.currentTarget;
+    if (t && t.text) {
+      var nodeId = "type/" + t.text;
+      $scope.onSelectSchemaTreeNode(nodeId);
+      $scope.selectNodeInTree(nodeId);
+    }
+  };
+
+  function omitVersionField(key,value) {
+    return (key === "_version_") ? undefined : value;
+  }
+
+  $scope.editDocuments = function() {
+    delete $scope.helpId;
+    if ($scope.queryDocs) {
+      $scope.hasDocsOnServer = false; // so the updated docs apply
+      $("#upload-file").val("");
+      delete $scope.fileUpload;
+      $scope.sampleDocuments = $scope.queryDocs;
+      $scope.onSampleDocumentsChanged();
+    }
+  };
+
+  $scope.renderResultsTree = function (data) {
+    var h = data.responseHeader;
+    var sort = h.params.sort;
+    if (!sort) {
+      sort = "score desc";
+    }
+
+    if (data.response && data.response.docs && data.response.docs.length > 0) {
+      $scope.queryDocs = JSON.stringify(data.response.docs, omitVersionField, 2);
+    } else {
+      delete $scope.queryDocs;
+    }
+
+    $scope.resultsMeta = [
+      {name: "Query", value: h.params.q},
+      {name: "QTime", value: h.QTime},
+      {name: "Hits", value: data.response.numFound},
+      {name: "sort", value: sort},
+    ];
+
+    var excParams = ["q", "handler", "debug", "configSet", "wt", "version", "_", "sort"];
+    for (var p in h.params) {
+      if (!excParams.includes(p)) {
+        $scope.resultsMeta.push({name: p, value: h.params[p]});
+      }
+    }
+
+    $scope.debugMeta = [];
+    for (var d in data.debug) {
+      if (typeof data.debug[d] !== 'object') {
+        var nvp = {name: d, value: data.debug[d]};
+        $scope.debugMeta.push(nvp);
+        $scope.resultsMeta.push(nvp);
+      }
+    }
+
+    var rootChildren = [{
+      "id":"docs",
+      "text": "Documents",
+      "state": {"opened": true},
+      "a_attr": {"href": "docs"},
+      "children": docsToTree(data.response.docs)
+    }];
+
+    if (data.facet_counts && data.facet_counts.facet_fields) {
+      rootChildren.push({
+        "id":"facets",
+        "text": "Facets",
+        "state": {"opened": true},
+        "a_attr": {"href": "facets"},
+        "children": facetsToTree(data.facet_counts.facet_fields)
+      });
+    }
+
+    if (data.highlighting) {
+      var hlNodes = hlToTree(data.highlighting);
+      if (hlNodes.length > 0) {
+        rootChildren.push({
+          "id":"hl",
+          "text": "Highlighting",
+          "state": {"opened": true},
+          "a_attr": {"href": "hl"},
+          "children": hlNodes
+        });
+      }
+    }
+
+    if (data.debug) {
+      rootChildren.push({"id":"debug", "text": "Debug", "a_attr": {"href": "debug"}, "children": debugToTree(data.debug)});
+    }
+
+    var tree = [{"id":"/","text": "Results", "a_attr": {"href": "/"}, "children": rootChildren}];
+    $scope.queryResultsTree = tree;
+  };
+
+  $scope.onSelectQueryResultsNode = function (id) {
+    $scope.selectedResultsNode = id;
+
+    if (id === "/" || id === "docs") {
+      $scope.resultsData = $scope.resultsMeta;
+      return;
+    }
+
+    if (id === "debug") {
+      $scope.resultsData = $scope.debugMeta;
+      return;
+    }
+
+    var jst = $('#queryResultsJsTree').jstree();
+    var node = jst.get_node(id);
+    if (!node || !node.a_attr) {
+      $scope.resultsData = $scope.resultsMeta;
+      return;
+    }
+
+    if (node.original && node.original.tdata) {
+      $scope.resultsData = node.original.tdata;
+    } else {
+      $scope.resultsData = [];
+      for (var a in node.a_attr) {
+        if (a === "href") continue;
+        var row = {name: a, value: node.a_attr[a]};
+        if (id.startsWith("doc/")) {
+          row.type = "f"; // so we can link to fields in the schema tree from results!
+        }
+
+        $scope.resultsData.push(row);
+      }
+    }
+
+    if (id.startsWith("doc/")) {
+      $scope.sampleDocId = id.substring(4);
+      $scope.updateSampleDocId();
+    }
+  };
+
+  $scope.doQuery = function () {
+
+    delete $scope.queryDocs;
+
+    var params = {path: "query", configSet: $scope.currentSchema, debug: "true", "wt": "json"};
+
+    if ($scope.selectedFacets && $scope.selectedFacets.length > 0) {
+      params["facet"] = true;
+      params["facet.field"] = $scope.selectedFacets;
+      params["facet.limit"] = 20;
+      params["facet.mincount"] = 1;
+    } else {
+      params["facet"] = false;

Review comment:
       *W069:*  ['facet'] is better written in dot notation.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/schema-designer.js
##########
@@ -0,0 +1,2014 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $cookies, $window, Constants, SchemaDesigner, Luke) {
+  $scope.resetMenu("schema-designer", Constants.IS_ROOT_PAGE);
+
+  $scope.onWarning = function (warnMsg, warnDetails) {
+    $scope.updateWorking = false;
+    delete $scope.updateStatusMessage;
+    $scope.apiWarning = warnMsg;
+    $scope.apiWarningDetails = warnDetails;
+  };
+  
+  $scope.onError = function (errorMsg, errorCode, errorDetails) {
+    $scope.updateWorking = false;
+    delete $scope.updateStatusMessage;
+    $scope.designerAPIError = errorMsg;
+    if (errorDetails) {
+      var errorDetailsStr = "";
+      if (errorDetails["error"]) {
+        errorDetailsStr = errorDetails["error"];
+      } else {
+        for (var id in errorDetails) {
+          var msg = errorDetails[id];
+          var at = msg.indexOf("ERROR: ");
+          if (at !== -1) {
+            msg = msg.substring(at+7);
+          }
+          if (!msg.includes(id)) {
+            msg = id+": "+msg;
+          }
+          errorDetailsStr += msg+"\n\n";
+        }
+      }
+      $scope.designerAPIErrorDetails = errorDetailsStr;
+    } else {
+      delete $scope.designerAPIErrorDetails;
+    }
+
+    if (errorCode === 409) {
+      $scope.schemaVersion = -1; // reset to get the latest
+      $scope.isVersionMismatch = true;
+      $scope.errorButton = "Reload Schema";
+    } else if (errorCode < 500) {
+      $scope.isVersionMismatch = false;
+      $scope.errorButton = "OK";
+    } // else 500 errors get the top-level error message
+  };
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error) {
+      $scope.onError(error.msg, error.code, e.data.errorDetails);
+    } else {
+      // when a timeout occurs, the error details are sparse so just give the user a hint that something was off
+      var path = e.config && e.config.url ? e.config.url : "/api/schema-designer";
+      var reloadMsg = "";
+      if (path.includes("/analyze")) {
+        reloadMsg = " Re-try analyzing your sample docs by clicking on 'Analyze Documents' again."
+      }
+      $scope.onError("Request to "+path+" failed!", 408,
+          {"error":"Most likely the request timed out; check server log for more details."+reloadMsg});
+    }
+  };
+
+  $scope.closeWarnDialog = function () {
+    delete $scope.apiWarning;
+    delete $scope.apiWarningDetails;
+  };
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.designerAPIError;
+    delete $scope.designerAPIErrorDetails;
+    if ($scope.isVersionMismatch) {
+      $scope.isVersionMismatch = false;
+      var nodeId = "/";
+      if ($scope.selectedNode) {
+        nodeId = $scope.selectedNode.href;
+      }
+      $scope.doAnalyze(nodeId);
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.isSchemaDesignerEnabled = true;
+
+    delete $scope.helpId;
+
+    $scope.updateStatusMessage = "";
+    $scope.analysisVerbose = false;
+    $scope.updateWorking = false;
+    $scope.currentSchema = "";
+
+    delete $scope.hasDocsOnServer;
+    delete $scope.queryResultsTree;
+
+    $scope.languages = ["*"];
+    $scope.copyFrom = "_default";
+    delete $scope.sampleMessage;
+    delete $scope.sampleDocuments;
+
+    $scope.schemaVersion = -1;
+    $scope.schemaTree = {};
+    $scope.showSchemaActions = false;
+    $scope.sampleDocIds = [];
+    $scope.isSchemaRoot = false;
+
+    delete $scope.enableNestedDocs;
+    delete $scope.enableDynamicFields;
+    delete $scope.enableFieldGuessing;
+
+    // schema editor
+    $scope.showFieldDetails = false;
+    $scope.selectedNode = null;
+    $scope.selectedUpdated = false;
+    delete $scope.updateStatusMessage;
+
+    // text field analysis
+    $scope.showAnalysis = false;
+    $scope.sampleDocId = null;
+    $scope.indexText = "";
+    $scope.result = {};
+
+    // publish vars
+    delete $scope.newCollection;
+    $scope.reloadOnPublish = "true";
+
+    // query form
+    $scope.query = {q: '*:*', sortBy: 'score', sortDir: 'desc'};
+
+    SchemaDesigner.get({path: "configs"}, function (data) {
+
+      $scope.schemas = [];
+      $scope.publishedSchemas = ["_default"];
+
+      for (var s in data.configSets) {
+        // 1 means published but not editable
+        if (data.configSets[s] !== 1) {
+          $scope.schemas.push(s);
+        }
+
+        // 0 means not published yet (so can't copy from it yet)
+        if (data.configSets[s] > 0) {
+          $scope.publishedSchemas.push(s);
+        }
+      }
+
+      $scope.schemas.sort();
+      $scope.publishedSchemas.sort();
+
+      // if no schemas available to select, open the pop-up immediately
+      if ($scope.schemas.length === 0) {
+        $scope.firstSchemaMessage = true;
+        $scope.showNewSchemaDialog();
+      }
+    }, function(e) {
+      if (e.status === 401 || e.status === 403) {
+        $scope.isSchemaDesignerEnabled = false;
+        $scope.hideAll();
+      }
+    });
+  };
+
+  $scope.selectNodeInTree = function(nodeId) {
+    nodeId = stripAnchorSuffix(nodeId);
+    if (!nodeId) return;
+
+    var jst = $('#schemaJsTree').jstree(true);
+    if (jst) {
+      var selectedId = null;
+      var selected_node = jst.get_selected();
+      if (selected_node && selected_node.length > 0) {
+        selectedId = selected_node[0];
+      }
+      if (selectedId) {
+        try {
+          jst.deselect_node(selectedId);
+        } catch (err) {
+          // just ignore
+          //console.log("error deselecting "+selectedId);
+        }
+      }
+
+      try {
+        jst.select_node(nodeId, true);
+      } catch (err) {
+        // just ignore, some low-level tree issue
+        //console.log("error selecting "+nodeId);
+      }
+    }
+  };
+
+  $scope.loadFile = function (event) {
+    var t = event.target || event.srcElement || event.currentTarget;
+    if (t && t.text) {
+      $scope.onSelectFileNode("files/" + t.text, true);
+    }
+  };
+
+  $scope.confirmEditSchema = function () {
+    $scope.showConfirmEditSchema = false;
+    if ($scope.hasDocsOnServer || $scope.published) {
+      $scope.doAnalyze();
+    } else {
+      $scope.sampleMessage = "Please upload or paste some sample documents to analyze for building the '" + $scope.currentSchema + "' schema.";
+    }
+  };
+
+  $scope.cancelEditSchema = function () {
+    $scope.currentSchema = "";
+    $scope.showConfirmEditSchema = false;
+  };
+
+  $scope.loadSchema = function () {
+
+    if (!$scope.currentSchema) {
+      return;
+    }
+
+    $scope.resetSchema();
+    var params = {path: "info", configSet: $scope.currentSchema};
+    SchemaDesigner.get(params, function (data) {
+      $scope.currentSchema = data.configSet;
+      $("#select-schema").trigger("chosen:updated");
+
+      $scope.confirmSchema = data.configSet;
+      $scope.collectionsForConfig = data.collections;
+      $scope.hasDocsOnServer = data.numDocs > 0;
+      $scope.published = data.published;
+      $scope.initDesignerSettingsFromResponse(data);
+      if ($scope.collectionsForConfig && $scope.collectionsForConfig.length > 0) {
+        $scope.showConfirmEditSchema = true;
+      } else {
+        if ($scope.hasDocsOnServer || $scope.published) {
+          $scope.doAnalyze();
+        } else {
+          $scope.sampleMessage = "Please upload or paste some sample documents to build the '" + $scope.currentSchema + "' schema.";
+        }
+      }
+    });
+  };
+
+  $scope.showNewSchemaDialog = function () {
+    $scope.hideAll();
+    $scope.showNewSchema = true;
+    $scope.newSchema = "";
+  };
+
+  $scope.addSchema = function () {
+    $scope.firstSchemaMessage = false;
+    delete $scope.addMessage;
+
+    if (!$scope.newSchema) {
+      $scope.addMessage = "Please provide a schema name!";
+      return;
+    }
+
+    $scope.newSchema = $scope.newSchema.trim();
+    if ($scope.newSchema.length > 50) {
+      $scope.addMessage = "Schema name be 50 characters or less";
+      return;
+    }
+
+    if ($scope.newSchema.indexOf(" ") !== -1 || $scope.newSchema.indexOf("/") !== -1) {
+      $scope.addMessage = "Schema name should not contain spaces or /";
+      return;
+    }
+
+    if ($scope.publishedSchemas.includes($scope.newSchema) || $scope.schemas.includes($scope.newSchema)) {
+      $scope.addMessage = "Schema '" + $scope.newSchema + "' already exists!";
+      return;
+    }
+
+    delete $scope.addMessage;
+    if (!$scope.copyFrom) {
+      $scope.copyFrom = "_default";
+    }
+
+    $scope.resetSchema();
+    $scope.schemas.push($scope.newSchema);
+    $scope.showNewSchema = false;
+    $scope.currentSchema = $scope.newSchema;
+    $scope.sampleMessage = "Please upload or paste some sample documents to analyze for building the '" + $scope.currentSchema + "' schema.";
+
+    SchemaDesigner.post({path: "prep", configSet: $scope.newSchema, copyFrom: $scope.copyFrom}, null, function (data) {
+      $scope.initDesignerSettingsFromResponse(data);
+    }, $scope.errorHandler);
+  };
+
+  $scope.cancelAddSchema = function () {
+    delete $scope.addMessage;
+    delete $scope.sampleMessage;
+
+    $scope.showNewSchema = false
+  };
+
+  $scope.hideAll = function () {
+    delete $scope.helpId;
+    $scope.showPublish = false;
+    $scope.showDiff = false;
+    $scope.showNewSchema = false;
+    $scope.showAddField = false;
+    $scope.showAddDynamicField = false;
+    $scope.showAddCopyField = false;
+    $scope.showAnalysis = false;
+    // add more dialogs here
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.hideData = function () {
+    $scope.showData = false;
+  };
+
+  $scope.rootChanged = function () {
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  $scope.updateUniqueKey = function () {
+    delete $scope.schemaRootMessage;
+    var jst = $('#schemaJsTree').jstree();
+    if (jst) {
+      var node = jst.get_node("field/" + $scope.updateUniqueKeyField);
+      if (node && node.a_attr) {
+        var attrs = node.a_attr;
+        if (attrs.multiValued || attrs.tokenized || !attrs.stored || !attrs.indexed) {
+          $scope.schemaRootMessage = "Field '" + $scope.updateUniqueKeyField +
+              "' cannot be used as the uniqueKey field! Must be single-valued, stored, indexed, and not tokenized.";
+          $scope.updateUniqueKeyField = $scope.uniqueKeyField;
+          return;
+        }
+      }
+    }
+    $scope.uniqueKeyField = $scope.updateUniqueKeyField;
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  $scope.resetSchema = function () {
+    $scope.hideAll();
+    $scope.analysisVerbose = false;
+    $scope.showSchemaActions = false;
+    $scope.showAnalysis = false;
+    $scope.showFieldDetails = false;
+    $scope.hasDocsOnServer = false;
+    $scope.query = {q: '*:*', sortBy: 'score', sortDir: 'desc'};
+    $scope.schemaVersion = -1;
+
+    $scope.updateWorking = false;
+    $scope.isVersionMismatch = false;
+    delete $scope.updateStatusMessage;
+    delete $scope.designerAPIError;
+    delete $scope.designerAPIErrorDetails;
+    delete $scope.selectedFacets;
+    delete $scope.sampleDocuments;
+    delete $scope.selectedNode;
+    delete $scope.queryResultsTree;
+  };
+
+  $scope.onSchemaUpdated = function (schema, data, nodeId) {
+    $scope.hasDocsOnServer = data.numDocs && data.numDocs > 0;
+    $scope.uniqueKeyField = data.uniqueKeyField;
+    $scope.updateUniqueKeyField = $scope.uniqueKeyField;
+    $scope.initDesignerSettingsFromResponse(data);
+
+    var fieldTypes = fieldTypesToTree(data.fieldTypes);
+    var files = filesToTree(data.files);
+
+    var rootChildren = [];
+    $scope.fieldsSrc = fieldsToTree(data.fields);
+    $scope.fieldsNode = {
+      "id": "fields",
+      "text": "Fields",
+      "state": {"opened": true},
+      "a_attr": {"href": "fields"},
+      "children": $scope.fieldsSrc
+    };
+    rootChildren.push($scope.fieldsNode);
+
+    if ($scope.enableDynamicFields === "true") {
+      $scope.dynamicFieldsSrc = fieldsToTree(data.dynamicFields);
+      $scope.dynamicFieldsNode = {
+        "id": "dynamicFields",
+        "text": "Dynamic Fields",
+        "a_attr": {"href": "dynamicFields"},
+        "children": $scope.dynamicFieldsSrc
+      };
+      rootChildren.push($scope.dynamicFieldsNode);
+    } else {
+      delete $scope.dynamicFieldsNode;
+      delete $scope.dynamicFieldsSrc;
+    }
+
+    rootChildren.push({"id":"fieldTypes", "text": "Field Types", "a_attr": {"href": "fieldTypes"}, "children": fieldTypes});
+    rootChildren.push(files);
+
+    var tree = [{"id":"/", "text": schema, "a_attr": {"href": "/"}, "state":{"opened": true}, "children": rootChildren}];
+
+    $scope.fields = data.fields;
+    $scope.fieldNames = data.fields.map(f => f.name).sort();
+    $scope.possibleIdFields = data.fields.filter(f => f.indexed && f.stored && !f.tokenized).map(f => f.name).sort();
+    $scope.sortableFields = data.fields.filter(f => (f.indexed || f.docValues) && !f.multiValued && !f.tokenized).map(f => f.name).sort();
+    $scope.sortableFields.push("score");
+
+    $scope.facetFields = data.fields.filter(f => (f.indexed || f.docValues) && !f.tokenized && f.name !== '_version_').map(f => f.name).sort();
+    $scope.hlFields = data.fields.filter(f => f.stored && f.tokenized).map(f => f.name).sort();
+
+    $scope.schemaVersion = data.schemaVersion;
+    $scope.currentSchema = data.configSet;
+    $scope.fieldTypes = fieldTypes;
+    $scope.core = data.core;
+    $scope.schemaTree = tree;
+    $scope.refreshTree();
+
+    $scope.collectionsForConfig = data.collectionsForConfig;
+
+    if (data.docIds) {
+      $scope.sampleDocIds = data.docIds;
+    }
+
+    // re-apply the filters on the updated schema
+    $scope.onTreeFilterOptionChanged();
+
+    // Load the Luke schema
+    Luke.schema({core: data.core}, function (schema) {
+      Luke.raw({core: data.core}, function (index) {
+        $scope.luke = mergeIndexAndSchemaData(index, schema.schema);
+        $scope.types = Object.keys(schema.schema.types);
+        $scope.showSchemaActions = true;
+        if (!nodeId) {
+          nodeId = "/";
+        }
+        $scope.onSelectSchemaTreeNode(nodeId);
+
+        $scope.updateWorking = false;
+
+        if (data.updateError != null) {
+          $scope.onError(data.updateError, data.updateErrorCode, data.errorDetails);
+        } else {
+          if ($scope.selectedUpdated) {
+            $scope.selectedUpdated = false;
+            $scope.updateStatusMessage = "Changes applied successfully.";
+            var waitMs = 3000;
+            if (data.rebuild) {
+              $scope.updateStatusMessage += " Did full re-index of sample docs due to incompatible update.";
+              waitMs = 5000; // longer message, more time to read
+            }
+
+            if (data.analysisError) {
+              var updateType = data["updateType"];
+              var updatedObject = data[updateType];
+              var updatedName = updatedObject && updatedObject.name ? updatedObject.name : "";
+              var warnMsg = "Changes to "+updateType+" "+updatedName+" applied, but required the temp collection to be deleted " +
+                  "and re-created due to an incompatible Lucene change, see details below.";
+              $scope.onWarning(warnMsg, data.analysisError);
+            } else {
+              $timeout(function () { delete $scope.updateStatusMessage; }, waitMs);
+            }
+          } else {
+            var source = data.sampleSource;
+            if (source) {
+              if (source === "paste") {
+                source = "pasted sample"
+              } else if (source === "blob") {
+                source = "previous upload stored on the server"
+              }
+              if (data.numDocs > 0) {
+                $scope.updateStatusMessage = "Analyzed "+data.numDocs+" docs from "+source;
+              } else {
+                $scope.updateStatusMessage = "Schema '"+$scope.currentSchema+"' loaded.";
+              }
+            }
+            $timeout(function () {
+              delete $scope.updateStatusMessage;
+            }, 5000);
+          }
+        }
+
+        // re-fire the current query to reflect the updated schema
+        $scope.doQuery();
+        $scope.selectNodeInTree(nodeId);
+      });
+    });
+  };
+
+  $scope.toggleAddField = function (type) {
+    if ($scope.showAddField) {
+      $scope.hideAll();
+    } else {
+      $scope.hideAll();
+      $scope.showAddField = true;
+      $scope.adding = type;
+
+      $scope.newField = {
+        stored: "true",
+        indexed: "true",
+        uninvertible: "true",
+        docValues: "true"
+      }
+
+      if (type === "field") {
+        $scope.newField.type = "string";
+      }
+
+      delete $scope.addErrors;
+    }
+  };
+
+  function applyConstraintsOnField(f) {
+
+    if (!f.docValues) {
+      delete f.useDocValuesAsStored;
+    }
+
+    if (!f.docValues && !f.uninvertible) {
+      delete f.sortMissingLast; // remove this setting if no docValues / uninvertible
+    }
+
+    if (f.indexed) {
+      if (f.omitTermFreqAndPositions && !f.omitPositions) {
+        delete f.omitPositions; // :shrug ~ see SchemaField ln 295
+      }
+      if (!f.termVectors) {
+        delete f.termPositions;
+        delete f.termOffsets;
+        delete f.termPayloads;
+      }
+    } else {
+      // if not indexed, a bunch of fields are false
+      f.tokenized = false;
+      f.uninvertible = false;
+
+      // drop these from the request
+      delete f.termVectors;
+      delete f.termPositions;
+      delete f.termOffsets;
+      delete f.termPayloads;
+      delete f.omitNorms;
+      delete f.omitPositions;
+      delete f.omitTermFreqAndPositions;
+      delete f.storeOffsetsWithPositions;
+    }
+
+    return f;
+  }
+
+  $scope.addField = function () {
+
+    // validate the form input
+    $scope.addErrors = [];
+    if (!$scope.newField.name) {
+      $scope.addErrors.push($scope.adding + " name is required!");
+    }
+
+    if ($scope.newField.name.indexOf(" ") != -1) {
+      $scope.addErrors.push($scope.adding + " name should not have whitespace");
+    }
+
+    var command = "add-field-type";
+    if ("field" === $scope.adding) {
+
+      if ($scope.fieldNames.includes($scope.newField.name)) {
+        $scope.addErrors.push("Field '" + $scope.newField.name + "' already exists!");
+        return;
+      }
+
+      // TODO: is this the correct logic for detecting dynamic? Probably good enough for the designer
+      var isDynamic = $scope.newField.name.startsWith("*") || $scope.newField.name.endsWith("*");
+      if (isDynamic) {
+        if ($scope.luke && $scope.luke.dynamic_fields[$scope.newField.name]) {
+          $scope.addErrors.push("dynamic field '" + $scope.newField.name + "' already exists!");
+        }
+      } else {
+        if ($scope.luke && $scope.luke.fields[$scope.newField.name]) {
+          $scope.addErrors.push("field '" + $scope.newField.name + "' already exists!");
+        }
+      }
+
+      if (!$scope.newField.type) {
+        $scope.addErrors.push("field type is required!");
+      }
+
+      command = isDynamic ? "add-dynamic-field" : "add-field";
+    } else if ("type" === $scope.adding) {
+      if ($scope.types.includes($scope.newField.name)) {
+        $scope.addErrors.push("Type '" + $scope.newField.name + "' already exists!");
+      }
+
+      if (!$scope.newField.class) {
+        $scope.addErrors.push("class is required when creating a new field type!");
+      }
+    }
+
+    var addData = {};
+    addData[command] = applyConstraintsOnField($scope.newField);
+    if ($scope.textAnalysisJson) {
+      var text = $scope.textAnalysisJson.trim();
+      if (text.length > 0) {
+        text = text.replace(/\s+/g, ' ');
+        if (!text.startsWith("{")) {
+          text = "{ " + text + " }";
+        }
+        try {
+          var textJson = JSON.parse(text);
+          if (textJson.analyzer) {
+            addData[command].analyzer = textJson.analyzer;
+          } else {
+            if (!textJson.indexAnalyzer || !textJson.queryAnalyzer) {
+              $scope.addErrors.push("Text analysis JSON should define either an 'analyzer' or an 'indexAnalyzer' and 'queryAnalyzer'");
+              return;
+            }
+            addData[command].indexAnalyzer = textJson.indexAnalyzer;
+            addData[command].queryAnalyzer = textJson.queryAnalyzer;
+          }
+        } catch (e) {
+          $scope.addErrors.push("Failed to parse analysis as JSON due to: " + e.message +
+              "; expected JSON object with either an 'analyzer' or 'indexAnalyzer' and 'queryAnalyzer'");
+          return;
+        }
+      }
+    }
+
+    if ($scope.addErrors.length > 0) {
+      return;
+    }
+    delete $scope.addErrors; // no errors!
+
+    SchemaDesigner.post({
+      path: "add",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, addData, function (data) {
+      if (data.errors) {
+        $scope.addErrors = data.errors[0].errorMessages;
+        if (typeof $scope.addErrors === "string") {
+          $scope.addErrors = [$scope.addErrors];
+        }
+      } else {
+        delete $scope.textAnalysisJson;
+        $scope.added = true;
+        $timeout(function () {
+          $scope.showAddField = false;
+          $scope.added = false;
+          var nodeId = "/";
+          if ("field" === $scope.adding) {
+            nodeId = "field/" + data[command];
+          } else if ("type" === $scope.adding) {
+            nodeId = "type/" + data[command];
+          }
+          $scope.onSchemaUpdated(data.configSet, data, nodeId);
+        }, 500);
+      }
+    }, $scope.errorHandler);
+  }
+
+  function toSortedNameAndTypeList(fields, typeAttr) {
+    var list = [];
+    var keys = Object.keys(fields);
+    for (var f in keys) {
+      var field = fields[keys[f]];
+      var type = field[typeAttr];
+      if (type) {
+        list.push(field.name + ": "+type);
+      } else {
+        list.push(field.name);
+      }
+    }
+    return list.sort();
+  }
+
+  function toSortedFieldList(fields) {
+    var list = [];
+    var keys = Object.keys(fields);
+    for (var f in keys) {
+      list.push(fields[keys[f]]);
+    }
+    return list.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  $scope.toggleDiff = function (event) {
+    if ($scope.showDiff) {
+      // toggle, close dialog
+      $scope.showDiff = false;
+      return;
+    }
+
+    if (event) {
+      var t = event.target || event.currentTarget;
+      var leftPos = t.getBoundingClientRect().left - 600;
+      if (leftPos < 0) leftPos = 0;
+      $('#show-diff-dialog').css({left: leftPos});
+    }
+
+    SchemaDesigner.get({ path: "diff", configSet: $scope.currentSchema }, function (data) {
+      var diff = data.diff;
+
+      var dynamicFields = diff.dynamicFields;
+      var enableDynamicFields = data.enableDynamicFields !== null ? data.enableDynamicFields : true;
+      if (!enableDynamicFields) {
+        dynamicFields = null;
+      }
+
+      $scope.diffSource = data["diff-source"];
+      $scope.schemaDiff = {
+        "fieldsDiff": diff.fields,
+        "addedFields": [],
+        "removedFields": [],
+        "fieldTypesDiff": diff.fieldTypes,
+        "removedTypes": [],
+        "dynamicFieldsDiff": dynamicFields,
+        "copyFieldsDiff": diff.copyFields
+      }
+      if (diff.fields && diff.fields.added) {
+        $scope.schemaDiff.addedFields = toSortedFieldList(diff.fields.added);
+      }
+      if (diff.fields && diff.fields.removed) {
+        $scope.schemaDiff.removedFields = toSortedNameAndTypeList(diff.fields.removed, "type");
+      }
+      if (diff.fieldTypes && diff.fieldTypes.removed) {
+        $scope.schemaDiff.removedTypes = toSortedNameAndTypeList(diff.fieldTypes.removed, "class");
+      }
+
+      $scope.schemaDiffExists = !(diff.fields == null && diff.fieldTypes == null && dynamicFields == null && diff.copyFields == null);
+      $scope.showDiff = true;
+    });
+  }
+
+  $scope.togglePublish = function (event) {
+    if (event) {
+      var t = event.target || event.currentTarget;
+      var leftPos = t.getBoundingClientRect().left - 515;
+      if (leftPos < 0) leftPos = 0;
+      $('#publish-dialog').css({left: leftPos});
+    }
+
+    $scope.showDiff = false;
+    $scope.showPublish = !$scope.showPublish;
+    delete $scope.publishErrors;
+
+    $scope.disableDesigner = "false";
+
+    if ($scope.showPublish && !$scope.newCollection) {
+      $scope.newCollection = {numShards: 1, replicationFactor: 1, indexToCollection: "true"};
+    }
+  };
+
+  $scope.toggleAddCopyField = function () {
+    if ($scope.showAddCopyField) {
+      $scope.hideAll();
+      $scope.showFieldDetails = true;
+    } else {
+      $scope.hideAll();
+      $scope.showAddCopyField = true;
+      $scope.showFieldDetails = false;
+
+      $scope.copyField = {};
+      delete $scope.addCopyFieldErrors;
+    }
+  }
+  $scope.addCopyField = function () {
+    delete $scope.addCopyFieldErrors;
+    var data = {"add-copy-field": $scope.copyField};
+    SchemaDesigner.post({
+      path: "add",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, data, function (data) {
+      if (data.errors) {
+        $scope.addCopyFieldErrors = data.errors[0].errorMessages;
+        if (typeof $scope.addCopyFieldErrors === "string") {
+          $scope.addCopyFieldErrors = [$scope.addCopyFieldErrors];
+        }
+      } else {
+        $scope.showAddCopyField = false;
+        // TODO:
+        //$timeout($scope.refresh, 1500);
+      }
+    }, $scope.errorHandler);
+  }
+
+  $scope.toggleAnalyzer = function (analyzer) {
+    analyzer.show = !analyzer.show;
+  }
+
+  $scope.initTypeAnalysisInfo = function (typeName) {
+    $scope.analysis = getAnalysisInfo($scope.luke, {type: true}, typeName);
+    if ($scope.analysis && $scope.analysis.data) {
+      $scope.className = $scope.analysis.data.className
+    }
+    $scope.editAnalysis = "Edit JSON";
+    $scope.showAnalysisJson = false;
+    delete $scope.analysisJsonText;
+  };
+
+  $scope.toggleVerbose = function () {
+    $scope.analysisVerbose = !$scope.analysisVerbose;
+  };
+
+  $scope.updateSampleDocId = function () {
+    $scope.indexText = "";
+    $scope.result = {};
+
+    if (!$scope.selectedNode) {
+      return;
+    }
+
+    var field = $scope.selectedNode.name;
+    var params = {path: "sample"};
+    params.configSet = $scope.currentSchema;
+    params.uniqueKeyField = $scope.uniqueKeyField;
+    params.field = field;
+
+    if ($scope.sampleDocId) {
+      params.docId = $scope.sampleDocId;
+    } // else the server will pick the first doc with a non-empty text value for the desired field
+
+    SchemaDesigner.get(params, function (data) {
+      $scope.sampleDocId = data[$scope.uniqueKeyField];
+      $scope.indexText = data[field];
+      if (data.analysis && data.analysis["field_names"]) {
+        $scope.result = processFieldAnalysisData(data.analysis["field_names"][field]);

Review comment:
       *W069:*  ['field_names'] is better written in dot notation.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/schema-designer.js
##########
@@ -0,0 +1,2014 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $cookies, $window, Constants, SchemaDesigner, Luke) {
+  $scope.resetMenu("schema-designer", Constants.IS_ROOT_PAGE);
+
+  $scope.onWarning = function (warnMsg, warnDetails) {
+    $scope.updateWorking = false;
+    delete $scope.updateStatusMessage;
+    $scope.apiWarning = warnMsg;
+    $scope.apiWarningDetails = warnDetails;
+  };
+  
+  $scope.onError = function (errorMsg, errorCode, errorDetails) {
+    $scope.updateWorking = false;
+    delete $scope.updateStatusMessage;
+    $scope.designerAPIError = errorMsg;
+    if (errorDetails) {
+      var errorDetailsStr = "";
+      if (errorDetails["error"]) {
+        errorDetailsStr = errorDetails["error"];
+      } else {
+        for (var id in errorDetails) {
+          var msg = errorDetails[id];
+          var at = msg.indexOf("ERROR: ");
+          if (at !== -1) {
+            msg = msg.substring(at+7);
+          }
+          if (!msg.includes(id)) {
+            msg = id+": "+msg;
+          }
+          errorDetailsStr += msg+"\n\n";
+        }
+      }
+      $scope.designerAPIErrorDetails = errorDetailsStr;
+    } else {
+      delete $scope.designerAPIErrorDetails;
+    }
+
+    if (errorCode === 409) {
+      $scope.schemaVersion = -1; // reset to get the latest
+      $scope.isVersionMismatch = true;
+      $scope.errorButton = "Reload Schema";
+    } else if (errorCode < 500) {
+      $scope.isVersionMismatch = false;
+      $scope.errorButton = "OK";
+    } // else 500 errors get the top-level error message
+  };
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error) {
+      $scope.onError(error.msg, error.code, e.data.errorDetails);
+    } else {
+      // when a timeout occurs, the error details are sparse so just give the user a hint that something was off
+      var path = e.config && e.config.url ? e.config.url : "/api/schema-designer";
+      var reloadMsg = "";
+      if (path.includes("/analyze")) {
+        reloadMsg = " Re-try analyzing your sample docs by clicking on 'Analyze Documents' again."
+      }
+      $scope.onError("Request to "+path+" failed!", 408,
+          {"error":"Most likely the request timed out; check server log for more details."+reloadMsg});
+    }
+  };
+
+  $scope.closeWarnDialog = function () {
+    delete $scope.apiWarning;
+    delete $scope.apiWarningDetails;
+  };
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.designerAPIError;
+    delete $scope.designerAPIErrorDetails;
+    if ($scope.isVersionMismatch) {
+      $scope.isVersionMismatch = false;
+      var nodeId = "/";
+      if ($scope.selectedNode) {
+        nodeId = $scope.selectedNode.href;
+      }
+      $scope.doAnalyze(nodeId);
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.isSchemaDesignerEnabled = true;
+
+    delete $scope.helpId;
+
+    $scope.updateStatusMessage = "";
+    $scope.analysisVerbose = false;
+    $scope.updateWorking = false;
+    $scope.currentSchema = "";
+
+    delete $scope.hasDocsOnServer;
+    delete $scope.queryResultsTree;
+
+    $scope.languages = ["*"];
+    $scope.copyFrom = "_default";
+    delete $scope.sampleMessage;
+    delete $scope.sampleDocuments;
+
+    $scope.schemaVersion = -1;
+    $scope.schemaTree = {};
+    $scope.showSchemaActions = false;
+    $scope.sampleDocIds = [];
+    $scope.isSchemaRoot = false;
+
+    delete $scope.enableNestedDocs;
+    delete $scope.enableDynamicFields;
+    delete $scope.enableFieldGuessing;
+
+    // schema editor
+    $scope.showFieldDetails = false;
+    $scope.selectedNode = null;
+    $scope.selectedUpdated = false;
+    delete $scope.updateStatusMessage;
+
+    // text field analysis
+    $scope.showAnalysis = false;
+    $scope.sampleDocId = null;
+    $scope.indexText = "";
+    $scope.result = {};
+
+    // publish vars
+    delete $scope.newCollection;
+    $scope.reloadOnPublish = "true";
+
+    // query form
+    $scope.query = {q: '*:*', sortBy: 'score', sortDir: 'desc'};
+
+    SchemaDesigner.get({path: "configs"}, function (data) {
+
+      $scope.schemas = [];
+      $scope.publishedSchemas = ["_default"];
+
+      for (var s in data.configSets) {
+        // 1 means published but not editable
+        if (data.configSets[s] !== 1) {
+          $scope.schemas.push(s);
+        }
+
+        // 0 means not published yet (so can't copy from it yet)
+        if (data.configSets[s] > 0) {
+          $scope.publishedSchemas.push(s);
+        }
+      }
+
+      $scope.schemas.sort();
+      $scope.publishedSchemas.sort();
+
+      // if no schemas available to select, open the pop-up immediately
+      if ($scope.schemas.length === 0) {
+        $scope.firstSchemaMessage = true;
+        $scope.showNewSchemaDialog();
+      }
+    }, function(e) {
+      if (e.status === 401 || e.status === 403) {
+        $scope.isSchemaDesignerEnabled = false;
+        $scope.hideAll();
+      }
+    });
+  };
+
+  $scope.selectNodeInTree = function(nodeId) {
+    nodeId = stripAnchorSuffix(nodeId);
+    if (!nodeId) return;
+
+    var jst = $('#schemaJsTree').jstree(true);
+    if (jst) {
+      var selectedId = null;
+      var selected_node = jst.get_selected();
+      if (selected_node && selected_node.length > 0) {
+        selectedId = selected_node[0];
+      }
+      if (selectedId) {
+        try {
+          jst.deselect_node(selectedId);
+        } catch (err) {
+          // just ignore
+          //console.log("error deselecting "+selectedId);
+        }
+      }
+
+      try {
+        jst.select_node(nodeId, true);
+      } catch (err) {
+        // just ignore, some low-level tree issue
+        //console.log("error selecting "+nodeId);
+      }
+    }
+  };
+
+  $scope.loadFile = function (event) {
+    var t = event.target || event.srcElement || event.currentTarget;
+    if (t && t.text) {
+      $scope.onSelectFileNode("files/" + t.text, true);
+    }
+  };
+
+  $scope.confirmEditSchema = function () {
+    $scope.showConfirmEditSchema = false;
+    if ($scope.hasDocsOnServer || $scope.published) {
+      $scope.doAnalyze();
+    } else {
+      $scope.sampleMessage = "Please upload or paste some sample documents to analyze for building the '" + $scope.currentSchema + "' schema.";
+    }
+  };
+
+  $scope.cancelEditSchema = function () {
+    $scope.currentSchema = "";
+    $scope.showConfirmEditSchema = false;
+  };
+
+  $scope.loadSchema = function () {
+
+    if (!$scope.currentSchema) {
+      return;
+    }
+
+    $scope.resetSchema();
+    var params = {path: "info", configSet: $scope.currentSchema};
+    SchemaDesigner.get(params, function (data) {
+      $scope.currentSchema = data.configSet;
+      $("#select-schema").trigger("chosen:updated");
+
+      $scope.confirmSchema = data.configSet;
+      $scope.collectionsForConfig = data.collections;
+      $scope.hasDocsOnServer = data.numDocs > 0;
+      $scope.published = data.published;
+      $scope.initDesignerSettingsFromResponse(data);
+      if ($scope.collectionsForConfig && $scope.collectionsForConfig.length > 0) {
+        $scope.showConfirmEditSchema = true;
+      } else {
+        if ($scope.hasDocsOnServer || $scope.published) {
+          $scope.doAnalyze();
+        } else {
+          $scope.sampleMessage = "Please upload or paste some sample documents to build the '" + $scope.currentSchema + "' schema.";
+        }
+      }
+    });
+  };
+
+  $scope.showNewSchemaDialog = function () {
+    $scope.hideAll();
+    $scope.showNewSchema = true;
+    $scope.newSchema = "";
+  };
+
+  $scope.addSchema = function () {
+    $scope.firstSchemaMessage = false;
+    delete $scope.addMessage;
+
+    if (!$scope.newSchema) {
+      $scope.addMessage = "Please provide a schema name!";
+      return;
+    }
+
+    $scope.newSchema = $scope.newSchema.trim();
+    if ($scope.newSchema.length > 50) {
+      $scope.addMessage = "Schema name be 50 characters or less";
+      return;
+    }
+
+    if ($scope.newSchema.indexOf(" ") !== -1 || $scope.newSchema.indexOf("/") !== -1) {
+      $scope.addMessage = "Schema name should not contain spaces or /";
+      return;
+    }
+
+    if ($scope.publishedSchemas.includes($scope.newSchema) || $scope.schemas.includes($scope.newSchema)) {
+      $scope.addMessage = "Schema '" + $scope.newSchema + "' already exists!";
+      return;
+    }
+
+    delete $scope.addMessage;
+    if (!$scope.copyFrom) {
+      $scope.copyFrom = "_default";
+    }
+
+    $scope.resetSchema();
+    $scope.schemas.push($scope.newSchema);
+    $scope.showNewSchema = false;
+    $scope.currentSchema = $scope.newSchema;
+    $scope.sampleMessage = "Please upload or paste some sample documents to analyze for building the '" + $scope.currentSchema + "' schema.";
+
+    SchemaDesigner.post({path: "prep", configSet: $scope.newSchema, copyFrom: $scope.copyFrom}, null, function (data) {
+      $scope.initDesignerSettingsFromResponse(data);
+    }, $scope.errorHandler);
+  };
+
+  $scope.cancelAddSchema = function () {
+    delete $scope.addMessage;
+    delete $scope.sampleMessage;
+
+    $scope.showNewSchema = false
+  };
+
+  $scope.hideAll = function () {
+    delete $scope.helpId;
+    $scope.showPublish = false;
+    $scope.showDiff = false;
+    $scope.showNewSchema = false;
+    $scope.showAddField = false;
+    $scope.showAddDynamicField = false;
+    $scope.showAddCopyField = false;
+    $scope.showAnalysis = false;
+    // add more dialogs here
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.hideData = function () {
+    $scope.showData = false;
+  };
+
+  $scope.rootChanged = function () {
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  $scope.updateUniqueKey = function () {
+    delete $scope.schemaRootMessage;
+    var jst = $('#schemaJsTree').jstree();
+    if (jst) {
+      var node = jst.get_node("field/" + $scope.updateUniqueKeyField);
+      if (node && node.a_attr) {
+        var attrs = node.a_attr;
+        if (attrs.multiValued || attrs.tokenized || !attrs.stored || !attrs.indexed) {
+          $scope.schemaRootMessage = "Field '" + $scope.updateUniqueKeyField +
+              "' cannot be used as the uniqueKey field! Must be single-valued, stored, indexed, and not tokenized.";
+          $scope.updateUniqueKeyField = $scope.uniqueKeyField;
+          return;
+        }
+      }
+    }
+    $scope.uniqueKeyField = $scope.updateUniqueKeyField;
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  $scope.resetSchema = function () {
+    $scope.hideAll();
+    $scope.analysisVerbose = false;
+    $scope.showSchemaActions = false;
+    $scope.showAnalysis = false;
+    $scope.showFieldDetails = false;
+    $scope.hasDocsOnServer = false;
+    $scope.query = {q: '*:*', sortBy: 'score', sortDir: 'desc'};
+    $scope.schemaVersion = -1;
+
+    $scope.updateWorking = false;
+    $scope.isVersionMismatch = false;
+    delete $scope.updateStatusMessage;
+    delete $scope.designerAPIError;
+    delete $scope.designerAPIErrorDetails;
+    delete $scope.selectedFacets;
+    delete $scope.sampleDocuments;
+    delete $scope.selectedNode;
+    delete $scope.queryResultsTree;
+  };
+
+  $scope.onSchemaUpdated = function (schema, data, nodeId) {
+    $scope.hasDocsOnServer = data.numDocs && data.numDocs > 0;
+    $scope.uniqueKeyField = data.uniqueKeyField;
+    $scope.updateUniqueKeyField = $scope.uniqueKeyField;
+    $scope.initDesignerSettingsFromResponse(data);
+
+    var fieldTypes = fieldTypesToTree(data.fieldTypes);
+    var files = filesToTree(data.files);
+
+    var rootChildren = [];
+    $scope.fieldsSrc = fieldsToTree(data.fields);
+    $scope.fieldsNode = {
+      "id": "fields",
+      "text": "Fields",
+      "state": {"opened": true},
+      "a_attr": {"href": "fields"},
+      "children": $scope.fieldsSrc
+    };
+    rootChildren.push($scope.fieldsNode);
+
+    if ($scope.enableDynamicFields === "true") {
+      $scope.dynamicFieldsSrc = fieldsToTree(data.dynamicFields);
+      $scope.dynamicFieldsNode = {
+        "id": "dynamicFields",
+        "text": "Dynamic Fields",
+        "a_attr": {"href": "dynamicFields"},
+        "children": $scope.dynamicFieldsSrc
+      };
+      rootChildren.push($scope.dynamicFieldsNode);
+    } else {
+      delete $scope.dynamicFieldsNode;
+      delete $scope.dynamicFieldsSrc;
+    }
+
+    rootChildren.push({"id":"fieldTypes", "text": "Field Types", "a_attr": {"href": "fieldTypes"}, "children": fieldTypes});
+    rootChildren.push(files);
+
+    var tree = [{"id":"/", "text": schema, "a_attr": {"href": "/"}, "state":{"opened": true}, "children": rootChildren}];
+
+    $scope.fields = data.fields;
+    $scope.fieldNames = data.fields.map(f => f.name).sort();
+    $scope.possibleIdFields = data.fields.filter(f => f.indexed && f.stored && !f.tokenized).map(f => f.name).sort();
+    $scope.sortableFields = data.fields.filter(f => (f.indexed || f.docValues) && !f.multiValued && !f.tokenized).map(f => f.name).sort();
+    $scope.sortableFields.push("score");
+
+    $scope.facetFields = data.fields.filter(f => (f.indexed || f.docValues) && !f.tokenized && f.name !== '_version_').map(f => f.name).sort();
+    $scope.hlFields = data.fields.filter(f => f.stored && f.tokenized).map(f => f.name).sort();
+
+    $scope.schemaVersion = data.schemaVersion;
+    $scope.currentSchema = data.configSet;
+    $scope.fieldTypes = fieldTypes;
+    $scope.core = data.core;
+    $scope.schemaTree = tree;
+    $scope.refreshTree();
+
+    $scope.collectionsForConfig = data.collectionsForConfig;
+
+    if (data.docIds) {
+      $scope.sampleDocIds = data.docIds;
+    }
+
+    // re-apply the filters on the updated schema
+    $scope.onTreeFilterOptionChanged();
+
+    // Load the Luke schema
+    Luke.schema({core: data.core}, function (schema) {
+      Luke.raw({core: data.core}, function (index) {
+        $scope.luke = mergeIndexAndSchemaData(index, schema.schema);
+        $scope.types = Object.keys(schema.schema.types);
+        $scope.showSchemaActions = true;
+        if (!nodeId) {
+          nodeId = "/";
+        }
+        $scope.onSelectSchemaTreeNode(nodeId);
+
+        $scope.updateWorking = false;
+
+        if (data.updateError != null) {
+          $scope.onError(data.updateError, data.updateErrorCode, data.errorDetails);
+        } else {
+          if ($scope.selectedUpdated) {
+            $scope.selectedUpdated = false;
+            $scope.updateStatusMessage = "Changes applied successfully.";
+            var waitMs = 3000;
+            if (data.rebuild) {
+              $scope.updateStatusMessage += " Did full re-index of sample docs due to incompatible update.";
+              waitMs = 5000; // longer message, more time to read
+            }
+
+            if (data.analysisError) {
+              var updateType = data["updateType"];
+              var updatedObject = data[updateType];
+              var updatedName = updatedObject && updatedObject.name ? updatedObject.name : "";
+              var warnMsg = "Changes to "+updateType+" "+updatedName+" applied, but required the temp collection to be deleted " +
+                  "and re-created due to an incompatible Lucene change, see details below.";
+              $scope.onWarning(warnMsg, data.analysisError);
+            } else {
+              $timeout(function () { delete $scope.updateStatusMessage; }, waitMs);
+            }
+          } else {
+            var source = data.sampleSource;
+            if (source) {
+              if (source === "paste") {
+                source = "pasted sample"
+              } else if (source === "blob") {
+                source = "previous upload stored on the server"
+              }
+              if (data.numDocs > 0) {
+                $scope.updateStatusMessage = "Analyzed "+data.numDocs+" docs from "+source;
+              } else {
+                $scope.updateStatusMessage = "Schema '"+$scope.currentSchema+"' loaded.";
+              }
+            }
+            $timeout(function () {
+              delete $scope.updateStatusMessage;
+            }, 5000);
+          }
+        }
+
+        // re-fire the current query to reflect the updated schema
+        $scope.doQuery();
+        $scope.selectNodeInTree(nodeId);
+      });
+    });
+  };
+
+  $scope.toggleAddField = function (type) {
+    if ($scope.showAddField) {
+      $scope.hideAll();
+    } else {
+      $scope.hideAll();
+      $scope.showAddField = true;
+      $scope.adding = type;
+
+      $scope.newField = {
+        stored: "true",
+        indexed: "true",
+        uninvertible: "true",
+        docValues: "true"
+      }
+
+      if (type === "field") {
+        $scope.newField.type = "string";
+      }
+
+      delete $scope.addErrors;
+    }
+  };
+
+  function applyConstraintsOnField(f) {
+
+    if (!f.docValues) {
+      delete f.useDocValuesAsStored;
+    }
+
+    if (!f.docValues && !f.uninvertible) {
+      delete f.sortMissingLast; // remove this setting if no docValues / uninvertible
+    }
+
+    if (f.indexed) {
+      if (f.omitTermFreqAndPositions && !f.omitPositions) {
+        delete f.omitPositions; // :shrug ~ see SchemaField ln 295
+      }
+      if (!f.termVectors) {
+        delete f.termPositions;
+        delete f.termOffsets;
+        delete f.termPayloads;
+      }
+    } else {
+      // if not indexed, a bunch of fields are false
+      f.tokenized = false;
+      f.uninvertible = false;
+
+      // drop these from the request
+      delete f.termVectors;
+      delete f.termPositions;
+      delete f.termOffsets;
+      delete f.termPayloads;
+      delete f.omitNorms;
+      delete f.omitPositions;
+      delete f.omitTermFreqAndPositions;
+      delete f.storeOffsetsWithPositions;
+    }
+
+    return f;
+  }
+
+  $scope.addField = function () {
+
+    // validate the form input
+    $scope.addErrors = [];
+    if (!$scope.newField.name) {
+      $scope.addErrors.push($scope.adding + " name is required!");
+    }
+
+    if ($scope.newField.name.indexOf(" ") != -1) {
+      $scope.addErrors.push($scope.adding + " name should not have whitespace");
+    }
+
+    var command = "add-field-type";
+    if ("field" === $scope.adding) {
+
+      if ($scope.fieldNames.includes($scope.newField.name)) {
+        $scope.addErrors.push("Field '" + $scope.newField.name + "' already exists!");
+        return;
+      }
+
+      // TODO: is this the correct logic for detecting dynamic? Probably good enough for the designer
+      var isDynamic = $scope.newField.name.startsWith("*") || $scope.newField.name.endsWith("*");
+      if (isDynamic) {
+        if ($scope.luke && $scope.luke.dynamic_fields[$scope.newField.name]) {
+          $scope.addErrors.push("dynamic field '" + $scope.newField.name + "' already exists!");
+        }
+      } else {
+        if ($scope.luke && $scope.luke.fields[$scope.newField.name]) {
+          $scope.addErrors.push("field '" + $scope.newField.name + "' already exists!");
+        }
+      }
+
+      if (!$scope.newField.type) {
+        $scope.addErrors.push("field type is required!");
+      }
+
+      command = isDynamic ? "add-dynamic-field" : "add-field";
+    } else if ("type" === $scope.adding) {
+      if ($scope.types.includes($scope.newField.name)) {
+        $scope.addErrors.push("Type '" + $scope.newField.name + "' already exists!");
+      }
+
+      if (!$scope.newField.class) {
+        $scope.addErrors.push("class is required when creating a new field type!");
+      }
+    }
+
+    var addData = {};
+    addData[command] = applyConstraintsOnField($scope.newField);
+    if ($scope.textAnalysisJson) {
+      var text = $scope.textAnalysisJson.trim();
+      if (text.length > 0) {
+        text = text.replace(/\s+/g, ' ');
+        if (!text.startsWith("{")) {
+          text = "{ " + text + " }";
+        }
+        try {
+          var textJson = JSON.parse(text);
+          if (textJson.analyzer) {
+            addData[command].analyzer = textJson.analyzer;
+          } else {
+            if (!textJson.indexAnalyzer || !textJson.queryAnalyzer) {
+              $scope.addErrors.push("Text analysis JSON should define either an 'analyzer' or an 'indexAnalyzer' and 'queryAnalyzer'");
+              return;
+            }
+            addData[command].indexAnalyzer = textJson.indexAnalyzer;
+            addData[command].queryAnalyzer = textJson.queryAnalyzer;
+          }
+        } catch (e) {
+          $scope.addErrors.push("Failed to parse analysis as JSON due to: " + e.message +
+              "; expected JSON object with either an 'analyzer' or 'indexAnalyzer' and 'queryAnalyzer'");
+          return;
+        }
+      }
+    }
+
+    if ($scope.addErrors.length > 0) {
+      return;
+    }
+    delete $scope.addErrors; // no errors!
+
+    SchemaDesigner.post({
+      path: "add",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, addData, function (data) {
+      if (data.errors) {
+        $scope.addErrors = data.errors[0].errorMessages;
+        if (typeof $scope.addErrors === "string") {
+          $scope.addErrors = [$scope.addErrors];
+        }
+      } else {
+        delete $scope.textAnalysisJson;
+        $scope.added = true;
+        $timeout(function () {
+          $scope.showAddField = false;
+          $scope.added = false;
+          var nodeId = "/";
+          if ("field" === $scope.adding) {
+            nodeId = "field/" + data[command];
+          } else if ("type" === $scope.adding) {
+            nodeId = "type/" + data[command];
+          }
+          $scope.onSchemaUpdated(data.configSet, data, nodeId);
+        }, 500);
+      }
+    }, $scope.errorHandler);
+  }
+
+  function toSortedNameAndTypeList(fields, typeAttr) {
+    var list = [];
+    var keys = Object.keys(fields);
+    for (var f in keys) {
+      var field = fields[keys[f]];
+      var type = field[typeAttr];
+      if (type) {
+        list.push(field.name + ": "+type);
+      } else {
+        list.push(field.name);
+      }
+    }
+    return list.sort();
+  }
+
+  function toSortedFieldList(fields) {
+    var list = [];
+    var keys = Object.keys(fields);
+    for (var f in keys) {
+      list.push(fields[keys[f]]);
+    }
+    return list.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  $scope.toggleDiff = function (event) {
+    if ($scope.showDiff) {
+      // toggle, close dialog
+      $scope.showDiff = false;
+      return;
+    }
+
+    if (event) {
+      var t = event.target || event.currentTarget;
+      var leftPos = t.getBoundingClientRect().left - 600;
+      if (leftPos < 0) leftPos = 0;
+      $('#show-diff-dialog').css({left: leftPos});
+    }
+
+    SchemaDesigner.get({ path: "diff", configSet: $scope.currentSchema }, function (data) {
+      var diff = data.diff;
+
+      var dynamicFields = diff.dynamicFields;
+      var enableDynamicFields = data.enableDynamicFields !== null ? data.enableDynamicFields : true;
+      if (!enableDynamicFields) {
+        dynamicFields = null;
+      }
+
+      $scope.diffSource = data["diff-source"];
+      $scope.schemaDiff = {
+        "fieldsDiff": diff.fields,
+        "addedFields": [],
+        "removedFields": [],
+        "fieldTypesDiff": diff.fieldTypes,
+        "removedTypes": [],
+        "dynamicFieldsDiff": dynamicFields,
+        "copyFieldsDiff": diff.copyFields
+      }
+      if (diff.fields && diff.fields.added) {
+        $scope.schemaDiff.addedFields = toSortedFieldList(diff.fields.added);
+      }
+      if (diff.fields && diff.fields.removed) {
+        $scope.schemaDiff.removedFields = toSortedNameAndTypeList(diff.fields.removed, "type");
+      }
+      if (diff.fieldTypes && diff.fieldTypes.removed) {
+        $scope.schemaDiff.removedTypes = toSortedNameAndTypeList(diff.fieldTypes.removed, "class");
+      }
+
+      $scope.schemaDiffExists = !(diff.fields == null && diff.fieldTypes == null && dynamicFields == null && diff.copyFields == null);
+      $scope.showDiff = true;
+    });
+  }
+
+  $scope.togglePublish = function (event) {
+    if (event) {
+      var t = event.target || event.currentTarget;
+      var leftPos = t.getBoundingClientRect().left - 515;
+      if (leftPos < 0) leftPos = 0;
+      $('#publish-dialog').css({left: leftPos});
+    }
+
+    $scope.showDiff = false;
+    $scope.showPublish = !$scope.showPublish;
+    delete $scope.publishErrors;
+
+    $scope.disableDesigner = "false";
+
+    if ($scope.showPublish && !$scope.newCollection) {
+      $scope.newCollection = {numShards: 1, replicationFactor: 1, indexToCollection: "true"};
+    }
+  };
+
+  $scope.toggleAddCopyField = function () {
+    if ($scope.showAddCopyField) {
+      $scope.hideAll();
+      $scope.showFieldDetails = true;
+    } else {
+      $scope.hideAll();
+      $scope.showAddCopyField = true;
+      $scope.showFieldDetails = false;
+
+      $scope.copyField = {};
+      delete $scope.addCopyFieldErrors;
+    }
+  }
+  $scope.addCopyField = function () {
+    delete $scope.addCopyFieldErrors;
+    var data = {"add-copy-field": $scope.copyField};
+    SchemaDesigner.post({
+      path: "add",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, data, function (data) {
+      if (data.errors) {
+        $scope.addCopyFieldErrors = data.errors[0].errorMessages;
+        if (typeof $scope.addCopyFieldErrors === "string") {
+          $scope.addCopyFieldErrors = [$scope.addCopyFieldErrors];
+        }
+      } else {
+        $scope.showAddCopyField = false;
+        // TODO:
+        //$timeout($scope.refresh, 1500);
+      }
+    }, $scope.errorHandler);
+  }
+
+  $scope.toggleAnalyzer = function (analyzer) {
+    analyzer.show = !analyzer.show;
+  }
+
+  $scope.initTypeAnalysisInfo = function (typeName) {
+    $scope.analysis = getAnalysisInfo($scope.luke, {type: true}, typeName);
+    if ($scope.analysis && $scope.analysis.data) {
+      $scope.className = $scope.analysis.data.className
+    }
+    $scope.editAnalysis = "Edit JSON";
+    $scope.showAnalysisJson = false;
+    delete $scope.analysisJsonText;
+  };
+
+  $scope.toggleVerbose = function () {
+    $scope.analysisVerbose = !$scope.analysisVerbose;
+  };
+
+  $scope.updateSampleDocId = function () {
+    $scope.indexText = "";
+    $scope.result = {};
+
+    if (!$scope.selectedNode) {
+      return;
+    }
+
+    var field = $scope.selectedNode.name;
+    var params = {path: "sample"};
+    params.configSet = $scope.currentSchema;
+    params.uniqueKeyField = $scope.uniqueKeyField;
+    params.field = field;
+
+    if ($scope.sampleDocId) {
+      params.docId = $scope.sampleDocId;
+    } // else the server will pick the first doc with a non-empty text value for the desired field
+
+    SchemaDesigner.get(params, function (data) {
+      $scope.sampleDocId = data[$scope.uniqueKeyField];
+      $scope.indexText = data[field];
+      if (data.analysis && data.analysis["field_names"]) {
+        $scope.result = processFieldAnalysisData(data.analysis["field_names"][field]);
+      }
+    });
+  };
+
+  $scope.changeLanguages = function () {
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  function getType(typeName) {
+    if ($scope.fieldTypes) {
+      for (i in $scope.fieldTypes) {
+        if ($scope.fieldTypes[i].text === typeName) {
+          return $scope.fieldTypes[i];
+        }
+      }
+    }
+    return null;
+  }
+
+  $scope.refreshTree = function() {
+    var jst = $('#schemaJsTree').jstree(true);
+    if (jst) {
+      jst.refresh();
+    }
+  };
+
+  $scope.onSchemaTreeLoaded = function (id) {
+    //console.log(">> on tree loaded");
+  };
+
+  $scope.updateFile = function () {
+    var nodeId = "files/" + $scope.selectedFile;
+    var params = {path: "file", file: $scope.selectedFile, configSet: $scope.currentSchema};
+
+    $scope.updateWorking = true;
+    $scope.updateStatusMessage = "Updating file ...";
+
+    SchemaDesigner.post(params, $scope.fileNodeText, function (data) {
+      if (data.updateFileError) {
+        if (data[$scope.selectedFile]) {
+          $scope.fileNodeText = data[$scope.selectedFile];
+        }
+        $scope.updateFileError = data.updateFileError;
+      } else {
+        delete $scope.updateFileError;
+        $scope.updateStatusMessage = "File '"+$scope.selectedFile+"' updated.";
+        $scope.onSchemaUpdated(data.configSet, data, nodeId);
+      }
+    }, $scope.errorHandler);
+  };
+
+  $scope.onSelectFileNode = function (id, doSelectOnTree) {
+    $scope.selectedFile = id.startsWith("files/") ? id.substring("files/".length) : id;
+
+    var params = {path: "file", file: $scope.selectedFile, configSet: $scope.currentSchema};
+    SchemaDesigner.get(params, function (data) {
+      $scope.fileNodeText = data[$scope.selectedFile];
+      $scope.isLeafNode = false;
+      if (doSelectOnTree) {
+        delete $scope.selectedNode;
+        $scope.isLeafNode = false;
+        $scope.showFieldDetails = true;
+        delete $scope.sampleDocId;
+        $scope.showAnalysis = false;
+        $scope.selectNodeInTree(id);
+      }
+    });
+  };
+  
+  function fieldNodes(src, type) {
+    var nodes = [];
+    for (var c in src) {
+      var childNode = src[c];
+      if (childNode && childNode.a_attr) {
+        var a = childNode.a_attr;
+        var stored = a.stored || (a.docValues && a.useDocValuesAsStored);
+        var obj = {"name":a.name, "indexed":a.indexed, "docValues": a.docValues,
+          "multiValued":a.multiValued, "stored":stored, "tokenized": a.tokenized};
+        if (type === "field" || type === "dynamicField") {
+          obj.type = a.type;
+        } else if (type === "type") {
+          obj.class = a.class;
+        }
+        nodes.push(obj);
+      }
+    }
+    return nodes;
+  }
+
+  function stripAnchorSuffix(id) {
+    if (id && id.endsWith("_anchor")) {
+      id = id.substring(0, id.length - "_anchor".length);
+    }
+    return id;
+  }
+
+  $scope.onSelectSchemaTreeNode = function (id) {
+    id = stripAnchorSuffix(id);
+    $scope.showFieldDetails = false;
+    $scope.isSchemaRoot = false;
+    $scope.isLeafNode = false;
+    delete $scope.containerNodeLabel;
+    delete $scope.containerNode;
+    delete $scope.containerNodes;
+    delete $scope.selectedFile;
+
+    if (id === "/") {
+      $scope.selectedType = "Schema";
+      $scope.selectedNode = null;
+      $scope.isSchemaRoot = true;
+      $scope.isLeafNode = false;
+      $scope.isContainerNode = false;
+      $scope.showFieldDetails = true;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+
+      if (!$scope.treeFilter) {
+        $scope.treeFilter = "type";
+        $scope.treeFilterOption = "*";
+        $scope.initTreeFilters();
+      }
+      return;
+    }
+
+    var jst = $('#schemaJsTree').jstree(true);
+    if (!jst) {
+      return;
+    }
+
+    var node = jst.get_node(id);
+    if (!node) {
+      return;
+    }
+
+    if (id === "files") {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      return;
+    }
+
+    if (id.indexOf("/") === -1) {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      $scope.containerNode = id;
+
+      if (id === "fields") {
+        $scope.containerNodes = fieldNodes($scope.fieldsNode ? $scope.fieldsNode.children : $scope.fieldsSrc, "field");
+      } else if (id === "dynamicFields") {
+        $scope.containerNodes = fieldNodes($scope.dynamicFieldsNode ? $scope.dynamicFieldsNode.children : $scope.dynamicFieldsSrc, "dynamicField");
+      } else if (id === "fieldTypes") {
+        $scope.containerNodes = fieldNodes($scope.fieldTypes, "type");
+      }
+
+      $scope.containerNodeLabel = node.text;
+      $scope.showFieldDetails = true;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+      return;
+    }
+
+    if (id.startsWith("files/")) {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+      if (node.children.length === 0) {
+        // file
+        $scope.showFieldDetails = true;
+        $scope.onSelectFileNode(id, false);
+      } else {
+        // folder
+        $scope.showFieldDetails = false;
+        delete $scope.selectedFile;
+      }
+      return;
+    }
+
+    delete $scope.selectedFile;
+    $scope.selectedNode = node["a_attr"]; // all the info we need is in the a_attr object
+    if (!$scope.selectedNode) {
+      // a node in the tree that isn't a field was selected, just ignore
+      return;
+    }
+
+    $scope.selectedNode.fieldType = getType($scope.selectedNode.type);
+    $scope.isLeafNode = true;
+
+    var nodeType = id.substring(0, id.indexOf("/"));
+    var name = null;
+    if (nodeType === "field") {
+      $scope.selectedType = "Field";
+      name = $scope.selectedNode.type;
+    } else if (nodeType === "dynamic") {
+      $scope.selectedType = "Dynamic Field";
+    } else if (nodeType === "type") {
+      $scope.selectedType = "Type";
+      name = $scope.selectedNode.name;
+    }
+
+    if (name) {
+      $scope.initTypeAnalysisInfo(name, "type");
+    }
+
+    // apply some sanity to the checkboxes
+    $scope.selectedNode = applyConstraintsOnField($scope.selectedNode);
+    $scope.showFieldDetails = true;
+
+    if (nodeType === "field" && $scope.selectedNode.tokenized && $scope.selectedNode.stored) {
+      $scope.showAnalysis = true;
+      $scope.updateSampleDocId();
+    } else {
+      $scope.showAnalysis = false;
+      $scope.indexText = "";
+      $scope.result = {};
+    }
+  };
+
+  function addFileNode(dirs, parent, f) {
+    var path = f.split("/");
+    if (path.length === 1) {
+      if (!parent.children) {
+        parent.children = [];
+        dirs.push(parent); // now parent has children, so track in dirs ...
+      }
+      var nodeId = parent.id + "/" + f;
+      parent.children.push({"text": f, "id": nodeId, "a_attr": {"href": nodeId}});
+    } else {
+      // get the parent for this path
+      var parentId = "files/" + path.slice(0, path.length - 1).join("/");
+      var dir = null;
+      for (var d in dirs) {
+        if (dirs[d].id === parentId) {
+          dir = dirs[d];
+          break;
+        }
+      }
+      if (!dir) {
+        dir = {"text": path[0], "id": parentId, "a_attr": {"href": parentId}, "children": []};
+        dirs.push(dir);
+        parent.children.push(dir);
+      }
+
+      // walk down the next level in this path
+      addFileNode(dirs, dir, path.slice(1).join("/"));
+    }
+  }
+
+  // transform a flat list structure into the nested tree structure
+  function filesToTree(files) {
+    var filesNode = {"text": "Files", "a_attr": {"href": "files"}, "id": "files", "children": []};
+    if (files) {
+      var dirs = []; // lookup for known dirs by path since nodes don't keep a ref to their parent node
+      for (var i in files) {
+        // hide the configoverlay.json from the UI
+        if (files[i] === "configoverlay.json") {
+          continue;
+        }
+
+        addFileNode(dirs, filesNode, files[i]);
+      }
+      delete dirs;
+    }
+    return filesNode;
+  }
+
+  function fieldsToTree(fields) {
+    var children = [];
+    if (fields) {
+      for (var i in fields) {
+        var id = "field/" + fields[i].name;
+        fields[i].href = id;
+        var text = fields[i].name;
+        if (fields[i].name === $scope.uniqueKeyField) {
+          text += "*"; // unique key field
+        }
+        children.push({"text": text, "a_attr": fields[i], "id": id});
+      }
+    }
+    return children;
+  }
+
+  function fieldTypesToTree(types) {
+    var children = [];
+    for (var i in types) {
+      var ft = types[i]
+      var id = "type/" + ft.name;
+      ft.href = id;
+      children.push({"text": ft.name, "a_attr": ft, "id": id});
+    }
+    return children;
+  }
+
+  $scope.onSampleDocumentsChanged = function () {
+    $scope.hasDocsOnServer = false; // so the updates get sent on next analyze action
+  };
+
+  $scope.initDesignerSettingsFromResponse = function (data) {
+    $scope.enableDynamicFields = data.enableDynamicFields !== null ? "" + data.enableDynamicFields : "true";
+    $scope.enableFieldGuessing = data.enableFieldGuessing !== null ? "" + data.enableFieldGuessing : "true";
+    $scope.enableNestedDocs = data.enableNestedDocs !== null ? "" + data.enableNestedDocs : "false";
+    $scope.languages = data.languages !== null && data.languages.length > 0 ? data.languages : ["*"];
+    $scope.copyFrom = data.copyFrom !== null ? data.copyFrom : "_default";
+  };
+
+  $scope.doAnalyze = function (nodeId) {
+    delete $scope.sampleMessage;
+
+    var schema = $scope.currentSchema;
+    if (schema) {
+      delete $scope.copyFrom;
+    } else {
+      schema = $scope.newSchema;
+      if (!$scope.copyFrom) {
+        $scope.copyFrom = "_default";
+      }
+    }
+
+    if (!schema) {
+      return;
+    }
+
+    var params = {path: "analyze", configSet: schema};
+    if ($scope.schemaVersion && $scope.schemaVersion !== -1) {
+      params.schemaVersion = $scope.schemaVersion;
+    }
+
+    if ($scope.enableDynamicFields) {
+      params.enableDynamicFields = $scope.enableDynamicFields;
+    }
+    if ($scope.enableFieldGuessing) {
+      params.enableFieldGuessing = $scope.enableFieldGuessing;
+    }
+    if ($scope.enableNestedDocs) {
+      params.enableNestedDocs = $scope.enableNestedDocs;
+    }
+
+    if ($scope.languages && $scope.languages.length > 0) {
+      params.languages = $scope.languages;
+    }
+
+    if ($scope.copyFrom) {
+      params.copyFrom = $scope.copyFrom;
+    }
+
+    $scope.updateWorking = true;
+    if ($scope.selectedUpdated) {
+      $scope.updateStatusMessage = "Applying " + $scope.selectedType + " updates ..."
+    } else {
+      $scope.updateStatusMessage = "Analyzing your sample data, schema will load momentarily ..."
+    }
+
+    if (!nodeId && $scope.selectedNode) {
+      nodeId = $scope.selectedNode.id;
+    }
+
+    // a bit tricky ...
+    // so users can upload a file or paste in docs
+    // if they upload a file containing a small (<15K) of text data, then we'll show it in the textarea
+    // they can change the text and re-analyze too
+    // if no changes or nothing uploaded, the server-side uses the latest sample data stored in the blob store
+    if ($scope.fileUpload) {
+      var file = $scope.fileUpload;
+      var fd = new FormData();
+      fd.append('file', file);
+      SchemaDesigner.upload(params, fd, function (data) {
+        $("#upload-file").val("");
+        delete $scope.fileUpload;
+        $scope.onSchemaUpdated(schema, data, nodeId);
+      }, $scope.errorHandler);
+    } else {
+      // don't need to keep re-posting the same sample if already stored in the blob store
+      var postData = null;
+      if (!$scope.hasDocsOnServer) {
+        postData = $scope.sampleDocuments;
+        if (!postData && !$scope.published) {
+          return;
+        }
+      }
+
+      var respHandler = function (data) {
+        $scope.onSchemaUpdated(schema, data, nodeId);
+      };
+
+      // TODO: need a better approach to detecting the content type from text content
+      var contentType = "text/plain";
+      if (postData != null) {
+        var txt = postData.trim();
+        if ((txt.startsWith("[") && txt.includes("]")) || (txt.startsWith("{") && txt.includes("}"))) {
+          contentType = "application/json"
+        } else if (txt.startsWith("<") || txt.includes("<add>") || txt.includes("<!--")) {
+          contentType = "text/xml";
+        } else {
+          contentType = "application/csv";
+        }
+      }
+
+      if (contentType === "text/xml") {
+        SchemaDesigner.postXml(params, postData, respHandler, $scope.errorHandler);
+      } else if (contentType === "application/csv") {
+        SchemaDesigner.postCsv(params, postData, respHandler, $scope.errorHandler);
+      } else {
+        SchemaDesigner.post(params, postData, respHandler, $scope.errorHandler);
+      }
+    }
+  };
+
+  $scope.onFieldTypeChanged = function () {
+
+    var copyFromType = ["stored", "indexed", "multiValued", "docValues", "useDocValuesAsStored", "tokenized", "uninvertible", "termVectors", "termPositions", "termOffsets",
+      "omitNorms", "omitTermFreqAndPositions", "omitPositions", "storeOffsetsWithPositions"];
+
+    var type = $scope.selectedNode.type
+
+    // when the user updates the selected field's type, we go apply the
+    // new field type's properties to the selected node
+    for (var i in $scope.fieldTypes) {
+      if ($scope.fieldTypes[i].text == type) {
+        var ft = $scope.fieldTypes[i];
+        $scope.selectedNode.fieldType = ft;
+        for (var i in copyFromType) {
+          var x = copyFromType[i];
+          if (ft.a_attr[x] !== null) {
+            $scope.selectedNode[x] = ft.a_attr[x];
+          } else {
+            delete $scope.selectedNode[x];
+          }
+        }
+        $scope.selectedUpdated = true;
+        break;
+      }
+    }
+
+    $scope.selectedNode = applyConstraintsOnField($scope.selectedNode);
+    if ($scope.selectedUpdated) {
+      // for luke analysis, we need the type info here, not the specific field into b/c it just changed.
+      $scope.initTypeAnalysisInfo(type, "type");
+    }
+  };
+
+  $scope.isDisabled = function (dep) {
+    if (!$scope.selectedNode) {
+      return false;
+    }
+
+    if (dep === "termVectors") {
+      // termVectors dependency depends on indexed
+      return !($scope.selectedNode.indexed && $scope.selectedNode.termVectors);
+    }
+
+    if (dep === "not-text" || dep === "docValues") {
+      if ($scope.selectedNode.fieldType && $scope.selectedNode.fieldType.a_attr.class === "solr.TextField") {
+        // no doc values for TextField
+        return true;
+      }
+    }
+
+    return $scope.selectedNode[dep] === false;
+  };
+
+  // this updates checkboxes based on the current settings
+  $scope.markSelectedUpdated = function (event) {
+    delete $scope.updateStatusMessage;
+    $scope.selectedUpdated = true; // enable the update button for this field
+  };
+
+  $scope.deleteSelected = function () {
+    // console.log(">> deleteSelected");
+  };
+
+  $scope.updateSelected = function () {
+    if (!$scope.selectedNode) {
+
+      if ($scope.selectedUpdated) {
+        // some root level property changed ... re-analyze
+        $scope.doAnalyze("/");
+      }
+
+      return;
+    }
+
+    delete $scope.updateSelectedError;
+
+    // make a copy for the PUT
+    var href = $scope.selectedNode.href;
+    var id = $scope.selectedNode.id;
+
+    var putData = JSON.parse(JSON.stringify($scope.selectedNode));
+    if ($scope.selectedType === "Field" && putData.copyDest) {
+      var fields = putData.copyDest.split(",");
+      for (var f in fields) {
+        var name = fields[f].trim();
+        if (!$scope.fieldNames.includes(name)) {
+          $scope.updateSelectedError = "Copy to field '" + name + "' doesn't exist!";
+          return;
+        }
+        if (name === $scope.selectedNode.name) {
+          $scope.updateSelectedError = "Cannot copy a field to itself!";
+          return;
+        }
+      }
+    } else {
+      delete putData.copyDest;
+    }
+
+    delete putData.fieldType;
+    delete putData.href;
+    delete putData.id;
+
+    putData = applyConstraintsOnField(putData);
+
+    if ($scope.analysisJsonText && !$scope.selectedNode.type) {
+      var text = $scope.analysisJsonText.trim();
+      if (text.length > 0) {
+        text = text.replace(/\s+/g, ' ');
+        if (!text.startsWith("{")) {
+          text = "{ " + text + " }";
+        }
+        try {
+          var textJson = JSON.parse(text);
+          if (textJson.analyzer) {
+            putData.analyzer = textJson.analyzer;
+          } else {
+            if (textJson.indexAnalyzer && textJson.queryAnalyzer) {
+              putData.indexAnalyzer = textJson.indexAnalyzer;
+              putData.queryAnalyzer = textJson.queryAnalyzer;
+            }
+          }
+        } catch (e) {
+          $scope.updateSelectedError = "Failed to parse analysis as JSON due to: " + e.message +
+              "; expected JSON object with either an 'analyzer' or 'indexAnalyzer' and 'queryAnalyzer'";
+          return;
+        }
+      }
+    }
+
+    $scope.updateWorking = true;
+    $scope.updateStatusMessage = "Updating " + $scope.selectedType + " ...";
+
+    SchemaDesigner.put({
+      path: "update",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, putData, function (data) {
+
+      var nodeType = data.updateType;
+      $scope.schemaVersion = data.schemaVersion;
+      $scope.currentSchema = data.configSet;
+      $scope.core = data.core;
+
+      $scope.selectedNode = data[nodeType];
+      $scope.selectedNode.href = href;
+      $scope.selectedNode.id = id;
+
+      var name = nodeType === "field" ? $scope.selectedNode.type : $scope.selectedNode.name;
+      $scope.initTypeAnalysisInfo(name, "type");
+      $scope.showFieldDetails = true;
+
+      if (nodeType === "field" && $scope.selectedNode.tokenized) {
+        $scope.showAnalysis = true;
+        $scope.updateSampleDocId();
+      }
+
+      $scope.onSchemaUpdated($scope.currentSchema, data, href);
+    }, $scope.errorHandler);
+  };
+
+  // TODO: These are copied from analysis.js, so move to a shared location for both vs. duplicating
+  var getShortComponentName = function (longname) {
+    var short = -1 !== longname.indexOf('$')
+        ? longname.split('$')[1]
+        : longname.split('.').pop();
+    return short.match(/[A-Z]/g).join('');
+  };
+
+  var getCaptionsForComponent = function (data) {
+    var captions = [];
+    for (var key in data[0]) {
+      key = key.replace(/.*#/, '');
+      if (key != "match" && key != "positionHistory") {
+        captions.push(key.replace(/.*#/, ''));
+      }
+    }
+    return captions;
+  };
+
+  var getTokensForComponent = function (data) {
+    var tokens = [];
+    var previousPosition = 0;
+    var index = 0;
+    for (var i in data) {
+      var tokenhash = data[i];
+      var positionDifference = tokenhash.position - previousPosition;
+      for (var j = positionDifference; j > 1; j--) {
+        tokens.push({position: tokenhash.position - j + 1, blank: true, index: index++});
+      }
+
+      var token = {position: tokenhash.position, keys: [], index: index++};
+
+      for (key in tokenhash) {
+        if (key == "match" || key == "positionHistory") {
+          //skip, to not display these keys in the UI
+        } else {
+          var tokenInfo = new Object();
+          tokenInfo.name = key;
+          tokenInfo.value = tokenhash[key];
+          if ('text' === key || 'raw_bytes' === key) {
+            if (tokenhash.match) {
+              tokenInfo.extraclass = 'match'; //to highlight matching text strings
+            }
+          }
+          token.keys.push(tokenInfo);
+        }
+      }
+      tokens.push(token);
+      previousPosition = tokenhash.position;
+    }
+    return tokens;
+  };
+
+  var extractComponents = function (data, result, name) {
+    if (data) {
+      result[name] = [];
+      for (var i = 0; i < data.length; i += 2) {
+        var component = {
+          name: data[i],
+          short: getShortComponentName(data[i]),
+          captions: getCaptionsForComponent(data[i + 1]),
+          tokens: getTokensForComponent(data[i + 1])
+        };
+        result[name].push(component);
+      }
+    }
+  };
+
+  var processFieldAnalysisData = function (analysis) {
+    var response = {};
+    extractComponents(analysis.index, response, "index");
+    return response;
+  };
+
+  $scope.doPublish = function () {
+    var params = {
+      path: "publish",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion,
+      reloadCollections: $scope.reloadOnPublish,
+      cleanupTemp: true,
+      disableDesigner: $scope.disableDesigner
+    };
+    if ($scope.newCollection && $scope.newCollection.name) {
+      params.newCollection = $scope.newCollection.name;
+      params.numShards = $scope.newCollection.numShards;
+      params.replicationFactor = $scope.newCollection.replicationFactor;
+      params.indexToCollection = $scope.newCollection.indexToCollection;
+    }
+    SchemaDesigner.put(params, null, function (data) {
+      $scope.schemaVersion = data.schemaVersion;
+      $scope.currentSchema = data.configSet;
+
+      delete $scope.selectedNode;
+      $scope.currentSchema = "";
+      delete $scope.newSchema;
+      $scope.showPublish = false;
+      $scope.refresh();
+
+      if (data.newCollection) {
+        $window.location.href = "#/" + data.newCollection + "/collection-overview";
+      }
+    }, $scope.errorHandler);
+  };
+
+  $scope.downloadConfig = function () {
+    // have to use an AJAX request so we can supply the Authorization header
+    if (sessionStorage.getItem("auth.header")) {
+      var fileName = $scope.currentSchema+"_configset.zip";
+      var xhr = new XMLHttpRequest();
+      xhr.open("GET", "/api/schema-designer/download/"+fileName+"?wt=raw&configSet="+$scope.currentSchema, true);
+      xhr.setRequestHeader('Authorization', sessionStorage.getItem("auth.header"));
+      xhr.responseType = 'blob';
+      xhr.addEventListener('load',function() {
+        if (xhr.status === 200) {
+          var url = window.URL.createObjectURL(xhr.response);
+          var a = document.createElement('a');
+          a.href = url;
+          a.download = fileName;
+          document.body.append(a);
+          a.click();
+          a.remove();
+          window.URL.revokeObjectURL(url);
+        }
+      })
+      xhr.send();
+    } else {
+      location.href = "/api/schema-designer/download/"+$scope.currentSchema+"_configset.zip?wt=raw&configSet=" + $scope.currentSchema;
+    }
+  };
+
+  function docsToTree(docs) {
+    var children = [];
+    for (var i in docs) {
+      var id = docs[i][$scope.uniqueKeyField];
+      if (!id) {
+        id = "" + i; // id not in results so use the position in results as the value
+      }
+      var nodeId = "doc/" + id;
+      docs[i].href = nodeId;
+      children.push({"text": id, "a_attr": docs[i], "id": nodeId});
+    }
+    return children;
+  }
+
+  function debugToTree(debugObj) {
+    var children = [];
+    for (var x in debugObj) {
+      if (typeof debugObj[x] === 'object') {
+        var obj = debugObj[x];
+        var nodeId = "debug/" + x;
+        var tdata = [];
+        for (var a in obj) {
+          if (typeof obj[a] !== 'object') {
+            tdata.push({name: a, value: obj[a]});
+          }
+        }
+        children.push({"text": x, "a_attr": {"href": nodeId}, "tdata": tdata, "id": nodeId, "children": []});
+      }
+    }
+    return children;
+  }
+
+  function facetsToTree(ff) {
+    var children = [];
+    for (var f in ff) {
+      var nodeId = "facet/" + f;
+      if (ff[f] && ff[f].length > 0) {
+        var facet = ff[f];
+        var tdata = [];
+        for (let i = 0; i < facet.length; i += 2) {
+          tdata.push({name: facet[i], value: facet[i + 1]});
+        }
+        children.push({"text": f, "a_attr": {"href": nodeId}, "tdata": tdata, "id": nodeId, "children": []});
+      }
+    }
+    return children;
+  }
+
+  function hlToTree(hl) {
+    var children = [];
+    for (var f in hl) {
+      var nodeId = "hl/" + f;
+      var tdata = [];
+      var obj = hl[f];
+      for (var a in obj) {
+        var v = obj[a];
+        if (v && v.length > 0) {
+          tdata.push({name: a, value: v[0]});
+        }
+      }
+      if (tdata.length > 0) {
+        children.push({"text": f, "a_attr": {"href": nodeId}, "tdata": tdata, "id": nodeId, "children": []});
+      }
+    }
+    return children;
+  }
+
+  $scope.selectField = function (event) {
+    var t = event.target || event.srcElement || event.currentTarget;
+    if (t && t.text) {
+      var nodeId = "field/" + t.text;
+      $scope.onSelectSchemaTreeNode(nodeId);
+      $scope.selectNodeInTree(nodeId);
+    }
+  };
+
+  $scope.selectFieldType = function (event) {
+    var t = event.target || event.srcElement || event.currentTarget;
+    if (t && t.text) {
+      var nodeId = "type/" + t.text;
+      $scope.onSelectSchemaTreeNode(nodeId);
+      $scope.selectNodeInTree(nodeId);
+    }
+  };
+
+  function omitVersionField(key,value) {
+    return (key === "_version_") ? undefined : value;
+  }
+
+  $scope.editDocuments = function() {
+    delete $scope.helpId;
+    if ($scope.queryDocs) {
+      $scope.hasDocsOnServer = false; // so the updated docs apply
+      $("#upload-file").val("");
+      delete $scope.fileUpload;
+      $scope.sampleDocuments = $scope.queryDocs;
+      $scope.onSampleDocumentsChanged();
+    }
+  };
+
+  $scope.renderResultsTree = function (data) {
+    var h = data.responseHeader;
+    var sort = h.params.sort;
+    if (!sort) {
+      sort = "score desc";
+    }
+
+    if (data.response && data.response.docs && data.response.docs.length > 0) {
+      $scope.queryDocs = JSON.stringify(data.response.docs, omitVersionField, 2);
+    } else {
+      delete $scope.queryDocs;
+    }
+
+    $scope.resultsMeta = [
+      {name: "Query", value: h.params.q},
+      {name: "QTime", value: h.QTime},
+      {name: "Hits", value: data.response.numFound},
+      {name: "sort", value: sort},
+    ];
+
+    var excParams = ["q", "handler", "debug", "configSet", "wt", "version", "_", "sort"];
+    for (var p in h.params) {
+      if (!excParams.includes(p)) {
+        $scope.resultsMeta.push({name: p, value: h.params[p]});
+      }
+    }
+
+    $scope.debugMeta = [];
+    for (var d in data.debug) {
+      if (typeof data.debug[d] !== 'object') {
+        var nvp = {name: d, value: data.debug[d]};
+        $scope.debugMeta.push(nvp);
+        $scope.resultsMeta.push(nvp);
+      }
+    }
+
+    var rootChildren = [{
+      "id":"docs",
+      "text": "Documents",
+      "state": {"opened": true},
+      "a_attr": {"href": "docs"},
+      "children": docsToTree(data.response.docs)
+    }];
+
+    if (data.facet_counts && data.facet_counts.facet_fields) {
+      rootChildren.push({
+        "id":"facets",
+        "text": "Facets",
+        "state": {"opened": true},
+        "a_attr": {"href": "facets"},
+        "children": facetsToTree(data.facet_counts.facet_fields)
+      });
+    }
+
+    if (data.highlighting) {
+      var hlNodes = hlToTree(data.highlighting);
+      if (hlNodes.length > 0) {
+        rootChildren.push({
+          "id":"hl",
+          "text": "Highlighting",
+          "state": {"opened": true},
+          "a_attr": {"href": "hl"},
+          "children": hlNodes
+        });
+      }
+    }
+
+    if (data.debug) {
+      rootChildren.push({"id":"debug", "text": "Debug", "a_attr": {"href": "debug"}, "children": debugToTree(data.debug)});
+    }
+
+    var tree = [{"id":"/","text": "Results", "a_attr": {"href": "/"}, "children": rootChildren}];
+    $scope.queryResultsTree = tree;
+  };
+
+  $scope.onSelectQueryResultsNode = function (id) {
+    $scope.selectedResultsNode = id;
+
+    if (id === "/" || id === "docs") {
+      $scope.resultsData = $scope.resultsMeta;
+      return;
+    }
+
+    if (id === "debug") {
+      $scope.resultsData = $scope.debugMeta;
+      return;
+    }
+
+    var jst = $('#queryResultsJsTree').jstree();
+    var node = jst.get_node(id);
+    if (!node || !node.a_attr) {
+      $scope.resultsData = $scope.resultsMeta;
+      return;
+    }
+
+    if (node.original && node.original.tdata) {
+      $scope.resultsData = node.original.tdata;
+    } else {
+      $scope.resultsData = [];
+      for (var a in node.a_attr) {
+        if (a === "href") continue;
+        var row = {name: a, value: node.a_attr[a]};
+        if (id.startsWith("doc/")) {
+          row.type = "f"; // so we can link to fields in the schema tree from results!
+        }
+
+        $scope.resultsData.push(row);
+      }
+    }
+
+    if (id.startsWith("doc/")) {
+      $scope.sampleDocId = id.substring(4);
+      $scope.updateSampleDocId();
+    }
+  };
+
+  $scope.doQuery = function () {
+
+    delete $scope.queryDocs;
+
+    var params = {path: "query", configSet: $scope.currentSchema, debug: "true", "wt": "json"};
+
+    if ($scope.selectedFacets && $scope.selectedFacets.length > 0) {
+      params["facet"] = true;
+      params["facet.field"] = $scope.selectedFacets;
+      params["facet.limit"] = 20;
+      params["facet.mincount"] = 1;
+    } else {
+      params["facet"] = false;
+      delete params["facet.field"];
+    }
+
+    var set = function (key, value) {
+      if (params[key]) {
+        params[key].push(value);
+      } else {
+        params[key] = [value];
+      }
+    }
+
+    params["sort"] = $scope.query.sortBy + " " + $scope.query.sortDir;
+    params["q"] = $scope.query.q.trim();
+    if (!params["q"]) {
+      params["q"] = "*:*";
+    }
+
+    if ($scope.rawParams) {
+      var rawParams = $scope.rawParams.split(/[&\n]/);
+      for (var i in rawParams) {
+        var param = rawParams[i];
+        var equalPos = param.indexOf("=");
+        if (equalPos > -1) {
+          set(param.substring(0, equalPos), param.substring(equalPos + 1));
+        } else {
+          set(param, ""); // Use empty value for params without "="
+        }
+      }
+    }
+
+    if (params["q"] !== '*:*' && $scope.query.highlight) {
+      params["hl"] = true;
+      params["hl.fl"] = $scope.query.highlight;
+      if (!params["hl.method"]) {
+        // lookup the field props
+        var method = "unified";
+        var field = $scope.fields.find(f => f.name === $scope.query.highlight);
+        if (field) {
+          if (field.termVectors && field.termOffsets && field.termPositions) {
+            method = "fastVector";
+          }
+        }
+        params["hl.method"] = method;
+      }
+    } else {
+      delete params["hl"];

Review comment:
       *W069:*  ['hl'] is better written in dot notation.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/schema-designer.js
##########
@@ -0,0 +1,2014 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $cookies, $window, Constants, SchemaDesigner, Luke) {
+  $scope.resetMenu("schema-designer", Constants.IS_ROOT_PAGE);
+
+  $scope.onWarning = function (warnMsg, warnDetails) {
+    $scope.updateWorking = false;
+    delete $scope.updateStatusMessage;
+    $scope.apiWarning = warnMsg;
+    $scope.apiWarningDetails = warnDetails;
+  };
+  
+  $scope.onError = function (errorMsg, errorCode, errorDetails) {
+    $scope.updateWorking = false;
+    delete $scope.updateStatusMessage;
+    $scope.designerAPIError = errorMsg;
+    if (errorDetails) {
+      var errorDetailsStr = "";
+      if (errorDetails["error"]) {
+        errorDetailsStr = errorDetails["error"];
+      } else {
+        for (var id in errorDetails) {
+          var msg = errorDetails[id];
+          var at = msg.indexOf("ERROR: ");
+          if (at !== -1) {
+            msg = msg.substring(at+7);
+          }
+          if (!msg.includes(id)) {
+            msg = id+": "+msg;
+          }
+          errorDetailsStr += msg+"\n\n";
+        }
+      }
+      $scope.designerAPIErrorDetails = errorDetailsStr;
+    } else {
+      delete $scope.designerAPIErrorDetails;
+    }
+
+    if (errorCode === 409) {
+      $scope.schemaVersion = -1; // reset to get the latest
+      $scope.isVersionMismatch = true;
+      $scope.errorButton = "Reload Schema";
+    } else if (errorCode < 500) {
+      $scope.isVersionMismatch = false;
+      $scope.errorButton = "OK";
+    } // else 500 errors get the top-level error message
+  };
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error) {
+      $scope.onError(error.msg, error.code, e.data.errorDetails);
+    } else {
+      // when a timeout occurs, the error details are sparse so just give the user a hint that something was off
+      var path = e.config && e.config.url ? e.config.url : "/api/schema-designer";
+      var reloadMsg = "";
+      if (path.includes("/analyze")) {
+        reloadMsg = " Re-try analyzing your sample docs by clicking on 'Analyze Documents' again."
+      }
+      $scope.onError("Request to "+path+" failed!", 408,
+          {"error":"Most likely the request timed out; check server log for more details."+reloadMsg});
+    }
+  };
+
+  $scope.closeWarnDialog = function () {
+    delete $scope.apiWarning;
+    delete $scope.apiWarningDetails;
+  };
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.designerAPIError;
+    delete $scope.designerAPIErrorDetails;
+    if ($scope.isVersionMismatch) {
+      $scope.isVersionMismatch = false;
+      var nodeId = "/";
+      if ($scope.selectedNode) {
+        nodeId = $scope.selectedNode.href;
+      }
+      $scope.doAnalyze(nodeId);
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.isSchemaDesignerEnabled = true;
+
+    delete $scope.helpId;
+
+    $scope.updateStatusMessage = "";
+    $scope.analysisVerbose = false;
+    $scope.updateWorking = false;
+    $scope.currentSchema = "";
+
+    delete $scope.hasDocsOnServer;
+    delete $scope.queryResultsTree;
+
+    $scope.languages = ["*"];
+    $scope.copyFrom = "_default";
+    delete $scope.sampleMessage;
+    delete $scope.sampleDocuments;
+
+    $scope.schemaVersion = -1;
+    $scope.schemaTree = {};
+    $scope.showSchemaActions = false;
+    $scope.sampleDocIds = [];
+    $scope.isSchemaRoot = false;
+
+    delete $scope.enableNestedDocs;
+    delete $scope.enableDynamicFields;
+    delete $scope.enableFieldGuessing;
+
+    // schema editor
+    $scope.showFieldDetails = false;
+    $scope.selectedNode = null;
+    $scope.selectedUpdated = false;
+    delete $scope.updateStatusMessage;
+
+    // text field analysis
+    $scope.showAnalysis = false;
+    $scope.sampleDocId = null;
+    $scope.indexText = "";
+    $scope.result = {};
+
+    // publish vars
+    delete $scope.newCollection;
+    $scope.reloadOnPublish = "true";
+
+    // query form
+    $scope.query = {q: '*:*', sortBy: 'score', sortDir: 'desc'};
+
+    SchemaDesigner.get({path: "configs"}, function (data) {
+
+      $scope.schemas = [];
+      $scope.publishedSchemas = ["_default"];
+
+      for (var s in data.configSets) {
+        // 1 means published but not editable
+        if (data.configSets[s] !== 1) {
+          $scope.schemas.push(s);
+        }
+
+        // 0 means not published yet (so can't copy from it yet)
+        if (data.configSets[s] > 0) {
+          $scope.publishedSchemas.push(s);
+        }
+      }
+
+      $scope.schemas.sort();
+      $scope.publishedSchemas.sort();
+
+      // if no schemas available to select, open the pop-up immediately
+      if ($scope.schemas.length === 0) {
+        $scope.firstSchemaMessage = true;
+        $scope.showNewSchemaDialog();
+      }
+    }, function(e) {
+      if (e.status === 401 || e.status === 403) {
+        $scope.isSchemaDesignerEnabled = false;
+        $scope.hideAll();
+      }
+    });
+  };
+
+  $scope.selectNodeInTree = function(nodeId) {
+    nodeId = stripAnchorSuffix(nodeId);
+    if (!nodeId) return;
+
+    var jst = $('#schemaJsTree').jstree(true);
+    if (jst) {
+      var selectedId = null;
+      var selected_node = jst.get_selected();
+      if (selected_node && selected_node.length > 0) {
+        selectedId = selected_node[0];
+      }
+      if (selectedId) {
+        try {
+          jst.deselect_node(selectedId);
+        } catch (err) {
+          // just ignore
+          //console.log("error deselecting "+selectedId);
+        }
+      }
+
+      try {
+        jst.select_node(nodeId, true);
+      } catch (err) {
+        // just ignore, some low-level tree issue
+        //console.log("error selecting "+nodeId);
+      }
+    }
+  };
+
+  $scope.loadFile = function (event) {
+    var t = event.target || event.srcElement || event.currentTarget;
+    if (t && t.text) {
+      $scope.onSelectFileNode("files/" + t.text, true);
+    }
+  };
+
+  $scope.confirmEditSchema = function () {
+    $scope.showConfirmEditSchema = false;
+    if ($scope.hasDocsOnServer || $scope.published) {
+      $scope.doAnalyze();
+    } else {
+      $scope.sampleMessage = "Please upload or paste some sample documents to analyze for building the '" + $scope.currentSchema + "' schema.";
+    }
+  };
+
+  $scope.cancelEditSchema = function () {
+    $scope.currentSchema = "";
+    $scope.showConfirmEditSchema = false;
+  };
+
+  $scope.loadSchema = function () {
+
+    if (!$scope.currentSchema) {
+      return;
+    }
+
+    $scope.resetSchema();
+    var params = {path: "info", configSet: $scope.currentSchema};
+    SchemaDesigner.get(params, function (data) {
+      $scope.currentSchema = data.configSet;
+      $("#select-schema").trigger("chosen:updated");
+
+      $scope.confirmSchema = data.configSet;
+      $scope.collectionsForConfig = data.collections;
+      $scope.hasDocsOnServer = data.numDocs > 0;
+      $scope.published = data.published;
+      $scope.initDesignerSettingsFromResponse(data);
+      if ($scope.collectionsForConfig && $scope.collectionsForConfig.length > 0) {
+        $scope.showConfirmEditSchema = true;
+      } else {
+        if ($scope.hasDocsOnServer || $scope.published) {
+          $scope.doAnalyze();
+        } else {
+          $scope.sampleMessage = "Please upload or paste some sample documents to build the '" + $scope.currentSchema + "' schema.";
+        }
+      }
+    });
+  };
+
+  $scope.showNewSchemaDialog = function () {
+    $scope.hideAll();
+    $scope.showNewSchema = true;
+    $scope.newSchema = "";
+  };
+
+  $scope.addSchema = function () {
+    $scope.firstSchemaMessage = false;
+    delete $scope.addMessage;
+
+    if (!$scope.newSchema) {
+      $scope.addMessage = "Please provide a schema name!";
+      return;
+    }
+
+    $scope.newSchema = $scope.newSchema.trim();
+    if ($scope.newSchema.length > 50) {
+      $scope.addMessage = "Schema name be 50 characters or less";
+      return;
+    }
+
+    if ($scope.newSchema.indexOf(" ") !== -1 || $scope.newSchema.indexOf("/") !== -1) {
+      $scope.addMessage = "Schema name should not contain spaces or /";
+      return;
+    }
+
+    if ($scope.publishedSchemas.includes($scope.newSchema) || $scope.schemas.includes($scope.newSchema)) {
+      $scope.addMessage = "Schema '" + $scope.newSchema + "' already exists!";
+      return;
+    }
+
+    delete $scope.addMessage;
+    if (!$scope.copyFrom) {
+      $scope.copyFrom = "_default";
+    }
+
+    $scope.resetSchema();
+    $scope.schemas.push($scope.newSchema);
+    $scope.showNewSchema = false;
+    $scope.currentSchema = $scope.newSchema;
+    $scope.sampleMessage = "Please upload or paste some sample documents to analyze for building the '" + $scope.currentSchema + "' schema.";
+
+    SchemaDesigner.post({path: "prep", configSet: $scope.newSchema, copyFrom: $scope.copyFrom}, null, function (data) {
+      $scope.initDesignerSettingsFromResponse(data);
+    }, $scope.errorHandler);
+  };
+
+  $scope.cancelAddSchema = function () {
+    delete $scope.addMessage;
+    delete $scope.sampleMessage;
+
+    $scope.showNewSchema = false
+  };
+
+  $scope.hideAll = function () {
+    delete $scope.helpId;
+    $scope.showPublish = false;
+    $scope.showDiff = false;
+    $scope.showNewSchema = false;
+    $scope.showAddField = false;
+    $scope.showAddDynamicField = false;
+    $scope.showAddCopyField = false;
+    $scope.showAnalysis = false;
+    // add more dialogs here
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.hideData = function () {
+    $scope.showData = false;
+  };
+
+  $scope.rootChanged = function () {
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  $scope.updateUniqueKey = function () {
+    delete $scope.schemaRootMessage;
+    var jst = $('#schemaJsTree').jstree();
+    if (jst) {
+      var node = jst.get_node("field/" + $scope.updateUniqueKeyField);
+      if (node && node.a_attr) {
+        var attrs = node.a_attr;
+        if (attrs.multiValued || attrs.tokenized || !attrs.stored || !attrs.indexed) {
+          $scope.schemaRootMessage = "Field '" + $scope.updateUniqueKeyField +
+              "' cannot be used as the uniqueKey field! Must be single-valued, stored, indexed, and not tokenized.";
+          $scope.updateUniqueKeyField = $scope.uniqueKeyField;
+          return;
+        }
+      }
+    }
+    $scope.uniqueKeyField = $scope.updateUniqueKeyField;
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  $scope.resetSchema = function () {
+    $scope.hideAll();
+    $scope.analysisVerbose = false;
+    $scope.showSchemaActions = false;
+    $scope.showAnalysis = false;
+    $scope.showFieldDetails = false;
+    $scope.hasDocsOnServer = false;
+    $scope.query = {q: '*:*', sortBy: 'score', sortDir: 'desc'};
+    $scope.schemaVersion = -1;
+
+    $scope.updateWorking = false;
+    $scope.isVersionMismatch = false;
+    delete $scope.updateStatusMessage;
+    delete $scope.designerAPIError;
+    delete $scope.designerAPIErrorDetails;
+    delete $scope.selectedFacets;
+    delete $scope.sampleDocuments;
+    delete $scope.selectedNode;
+    delete $scope.queryResultsTree;
+  };
+
+  $scope.onSchemaUpdated = function (schema, data, nodeId) {
+    $scope.hasDocsOnServer = data.numDocs && data.numDocs > 0;
+    $scope.uniqueKeyField = data.uniqueKeyField;
+    $scope.updateUniqueKeyField = $scope.uniqueKeyField;
+    $scope.initDesignerSettingsFromResponse(data);
+
+    var fieldTypes = fieldTypesToTree(data.fieldTypes);
+    var files = filesToTree(data.files);
+
+    var rootChildren = [];
+    $scope.fieldsSrc = fieldsToTree(data.fields);
+    $scope.fieldsNode = {
+      "id": "fields",
+      "text": "Fields",
+      "state": {"opened": true},
+      "a_attr": {"href": "fields"},
+      "children": $scope.fieldsSrc
+    };
+    rootChildren.push($scope.fieldsNode);
+
+    if ($scope.enableDynamicFields === "true") {
+      $scope.dynamicFieldsSrc = fieldsToTree(data.dynamicFields);
+      $scope.dynamicFieldsNode = {
+        "id": "dynamicFields",
+        "text": "Dynamic Fields",
+        "a_attr": {"href": "dynamicFields"},
+        "children": $scope.dynamicFieldsSrc
+      };
+      rootChildren.push($scope.dynamicFieldsNode);
+    } else {
+      delete $scope.dynamicFieldsNode;
+      delete $scope.dynamicFieldsSrc;
+    }
+
+    rootChildren.push({"id":"fieldTypes", "text": "Field Types", "a_attr": {"href": "fieldTypes"}, "children": fieldTypes});
+    rootChildren.push(files);
+
+    var tree = [{"id":"/", "text": schema, "a_attr": {"href": "/"}, "state":{"opened": true}, "children": rootChildren}];
+
+    $scope.fields = data.fields;
+    $scope.fieldNames = data.fields.map(f => f.name).sort();
+    $scope.possibleIdFields = data.fields.filter(f => f.indexed && f.stored && !f.tokenized).map(f => f.name).sort();
+    $scope.sortableFields = data.fields.filter(f => (f.indexed || f.docValues) && !f.multiValued && !f.tokenized).map(f => f.name).sort();
+    $scope.sortableFields.push("score");
+
+    $scope.facetFields = data.fields.filter(f => (f.indexed || f.docValues) && !f.tokenized && f.name !== '_version_').map(f => f.name).sort();
+    $scope.hlFields = data.fields.filter(f => f.stored && f.tokenized).map(f => f.name).sort();
+
+    $scope.schemaVersion = data.schemaVersion;
+    $scope.currentSchema = data.configSet;
+    $scope.fieldTypes = fieldTypes;
+    $scope.core = data.core;
+    $scope.schemaTree = tree;
+    $scope.refreshTree();
+
+    $scope.collectionsForConfig = data.collectionsForConfig;
+
+    if (data.docIds) {
+      $scope.sampleDocIds = data.docIds;
+    }
+
+    // re-apply the filters on the updated schema
+    $scope.onTreeFilterOptionChanged();
+
+    // Load the Luke schema
+    Luke.schema({core: data.core}, function (schema) {
+      Luke.raw({core: data.core}, function (index) {
+        $scope.luke = mergeIndexAndSchemaData(index, schema.schema);
+        $scope.types = Object.keys(schema.schema.types);
+        $scope.showSchemaActions = true;
+        if (!nodeId) {
+          nodeId = "/";
+        }
+        $scope.onSelectSchemaTreeNode(nodeId);
+
+        $scope.updateWorking = false;
+
+        if (data.updateError != null) {
+          $scope.onError(data.updateError, data.updateErrorCode, data.errorDetails);
+        } else {
+          if ($scope.selectedUpdated) {
+            $scope.selectedUpdated = false;
+            $scope.updateStatusMessage = "Changes applied successfully.";
+            var waitMs = 3000;
+            if (data.rebuild) {
+              $scope.updateStatusMessage += " Did full re-index of sample docs due to incompatible update.";
+              waitMs = 5000; // longer message, more time to read
+            }
+
+            if (data.analysisError) {
+              var updateType = data["updateType"];
+              var updatedObject = data[updateType];
+              var updatedName = updatedObject && updatedObject.name ? updatedObject.name : "";
+              var warnMsg = "Changes to "+updateType+" "+updatedName+" applied, but required the temp collection to be deleted " +
+                  "and re-created due to an incompatible Lucene change, see details below.";
+              $scope.onWarning(warnMsg, data.analysisError);
+            } else {
+              $timeout(function () { delete $scope.updateStatusMessage; }, waitMs);
+            }
+          } else {
+            var source = data.sampleSource;
+            if (source) {
+              if (source === "paste") {
+                source = "pasted sample"
+              } else if (source === "blob") {
+                source = "previous upload stored on the server"
+              }
+              if (data.numDocs > 0) {
+                $scope.updateStatusMessage = "Analyzed "+data.numDocs+" docs from "+source;
+              } else {
+                $scope.updateStatusMessage = "Schema '"+$scope.currentSchema+"' loaded.";
+              }
+            }
+            $timeout(function () {
+              delete $scope.updateStatusMessage;
+            }, 5000);
+          }
+        }
+
+        // re-fire the current query to reflect the updated schema
+        $scope.doQuery();
+        $scope.selectNodeInTree(nodeId);
+      });
+    });
+  };
+
+  $scope.toggleAddField = function (type) {
+    if ($scope.showAddField) {
+      $scope.hideAll();
+    } else {
+      $scope.hideAll();
+      $scope.showAddField = true;
+      $scope.adding = type;
+
+      $scope.newField = {
+        stored: "true",
+        indexed: "true",
+        uninvertible: "true",
+        docValues: "true"
+      }
+
+      if (type === "field") {
+        $scope.newField.type = "string";
+      }
+
+      delete $scope.addErrors;
+    }
+  };
+
+  function applyConstraintsOnField(f) {
+
+    if (!f.docValues) {
+      delete f.useDocValuesAsStored;
+    }
+
+    if (!f.docValues && !f.uninvertible) {
+      delete f.sortMissingLast; // remove this setting if no docValues / uninvertible
+    }
+
+    if (f.indexed) {
+      if (f.omitTermFreqAndPositions && !f.omitPositions) {
+        delete f.omitPositions; // :shrug ~ see SchemaField ln 295
+      }
+      if (!f.termVectors) {
+        delete f.termPositions;
+        delete f.termOffsets;
+        delete f.termPayloads;
+      }
+    } else {
+      // if not indexed, a bunch of fields are false
+      f.tokenized = false;
+      f.uninvertible = false;
+
+      // drop these from the request
+      delete f.termVectors;
+      delete f.termPositions;
+      delete f.termOffsets;
+      delete f.termPayloads;
+      delete f.omitNorms;
+      delete f.omitPositions;
+      delete f.omitTermFreqAndPositions;
+      delete f.storeOffsetsWithPositions;
+    }
+
+    return f;
+  }
+
+  $scope.addField = function () {
+
+    // validate the form input
+    $scope.addErrors = [];
+    if (!$scope.newField.name) {
+      $scope.addErrors.push($scope.adding + " name is required!");
+    }
+
+    if ($scope.newField.name.indexOf(" ") != -1) {
+      $scope.addErrors.push($scope.adding + " name should not have whitespace");
+    }
+
+    var command = "add-field-type";
+    if ("field" === $scope.adding) {
+
+      if ($scope.fieldNames.includes($scope.newField.name)) {
+        $scope.addErrors.push("Field '" + $scope.newField.name + "' already exists!");
+        return;
+      }
+
+      // TODO: is this the correct logic for detecting dynamic? Probably good enough for the designer
+      var isDynamic = $scope.newField.name.startsWith("*") || $scope.newField.name.endsWith("*");
+      if (isDynamic) {
+        if ($scope.luke && $scope.luke.dynamic_fields[$scope.newField.name]) {
+          $scope.addErrors.push("dynamic field '" + $scope.newField.name + "' already exists!");
+        }
+      } else {
+        if ($scope.luke && $scope.luke.fields[$scope.newField.name]) {
+          $scope.addErrors.push("field '" + $scope.newField.name + "' already exists!");
+        }
+      }
+
+      if (!$scope.newField.type) {
+        $scope.addErrors.push("field type is required!");
+      }
+
+      command = isDynamic ? "add-dynamic-field" : "add-field";
+    } else if ("type" === $scope.adding) {
+      if ($scope.types.includes($scope.newField.name)) {
+        $scope.addErrors.push("Type '" + $scope.newField.name + "' already exists!");
+      }
+
+      if (!$scope.newField.class) {
+        $scope.addErrors.push("class is required when creating a new field type!");
+      }
+    }
+
+    var addData = {};
+    addData[command] = applyConstraintsOnField($scope.newField);
+    if ($scope.textAnalysisJson) {
+      var text = $scope.textAnalysisJson.trim();
+      if (text.length > 0) {
+        text = text.replace(/\s+/g, ' ');
+        if (!text.startsWith("{")) {
+          text = "{ " + text + " }";
+        }
+        try {
+          var textJson = JSON.parse(text);
+          if (textJson.analyzer) {
+            addData[command].analyzer = textJson.analyzer;
+          } else {
+            if (!textJson.indexAnalyzer || !textJson.queryAnalyzer) {
+              $scope.addErrors.push("Text analysis JSON should define either an 'analyzer' or an 'indexAnalyzer' and 'queryAnalyzer'");
+              return;
+            }
+            addData[command].indexAnalyzer = textJson.indexAnalyzer;
+            addData[command].queryAnalyzer = textJson.queryAnalyzer;
+          }
+        } catch (e) {
+          $scope.addErrors.push("Failed to parse analysis as JSON due to: " + e.message +
+              "; expected JSON object with either an 'analyzer' or 'indexAnalyzer' and 'queryAnalyzer'");
+          return;
+        }
+      }
+    }
+
+    if ($scope.addErrors.length > 0) {
+      return;
+    }
+    delete $scope.addErrors; // no errors!
+
+    SchemaDesigner.post({
+      path: "add",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, addData, function (data) {
+      if (data.errors) {
+        $scope.addErrors = data.errors[0].errorMessages;
+        if (typeof $scope.addErrors === "string") {
+          $scope.addErrors = [$scope.addErrors];
+        }
+      } else {
+        delete $scope.textAnalysisJson;
+        $scope.added = true;
+        $timeout(function () {
+          $scope.showAddField = false;
+          $scope.added = false;
+          var nodeId = "/";
+          if ("field" === $scope.adding) {
+            nodeId = "field/" + data[command];
+          } else if ("type" === $scope.adding) {
+            nodeId = "type/" + data[command];
+          }
+          $scope.onSchemaUpdated(data.configSet, data, nodeId);
+        }, 500);
+      }
+    }, $scope.errorHandler);
+  }
+
+  function toSortedNameAndTypeList(fields, typeAttr) {
+    var list = [];
+    var keys = Object.keys(fields);
+    for (var f in keys) {
+      var field = fields[keys[f]];
+      var type = field[typeAttr];
+      if (type) {
+        list.push(field.name + ": "+type);
+      } else {
+        list.push(field.name);
+      }
+    }
+    return list.sort();
+  }
+
+  function toSortedFieldList(fields) {
+    var list = [];
+    var keys = Object.keys(fields);
+    for (var f in keys) {
+      list.push(fields[keys[f]]);
+    }
+    return list.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  $scope.toggleDiff = function (event) {
+    if ($scope.showDiff) {
+      // toggle, close dialog
+      $scope.showDiff = false;
+      return;
+    }
+
+    if (event) {
+      var t = event.target || event.currentTarget;
+      var leftPos = t.getBoundingClientRect().left - 600;
+      if (leftPos < 0) leftPos = 0;
+      $('#show-diff-dialog').css({left: leftPos});
+    }
+
+    SchemaDesigner.get({ path: "diff", configSet: $scope.currentSchema }, function (data) {
+      var diff = data.diff;
+
+      var dynamicFields = diff.dynamicFields;
+      var enableDynamicFields = data.enableDynamicFields !== null ? data.enableDynamicFields : true;
+      if (!enableDynamicFields) {
+        dynamicFields = null;
+      }
+
+      $scope.diffSource = data["diff-source"];
+      $scope.schemaDiff = {
+        "fieldsDiff": diff.fields,
+        "addedFields": [],
+        "removedFields": [],
+        "fieldTypesDiff": diff.fieldTypes,
+        "removedTypes": [],
+        "dynamicFieldsDiff": dynamicFields,
+        "copyFieldsDiff": diff.copyFields
+      }
+      if (diff.fields && diff.fields.added) {
+        $scope.schemaDiff.addedFields = toSortedFieldList(diff.fields.added);
+      }
+      if (diff.fields && diff.fields.removed) {
+        $scope.schemaDiff.removedFields = toSortedNameAndTypeList(diff.fields.removed, "type");
+      }
+      if (diff.fieldTypes && diff.fieldTypes.removed) {
+        $scope.schemaDiff.removedTypes = toSortedNameAndTypeList(diff.fieldTypes.removed, "class");
+      }
+
+      $scope.schemaDiffExists = !(diff.fields == null && diff.fieldTypes == null && dynamicFields == null && diff.copyFields == null);
+      $scope.showDiff = true;
+    });
+  }
+
+  $scope.togglePublish = function (event) {
+    if (event) {
+      var t = event.target || event.currentTarget;
+      var leftPos = t.getBoundingClientRect().left - 515;
+      if (leftPos < 0) leftPos = 0;
+      $('#publish-dialog').css({left: leftPos});
+    }
+
+    $scope.showDiff = false;
+    $scope.showPublish = !$scope.showPublish;
+    delete $scope.publishErrors;
+
+    $scope.disableDesigner = "false";
+
+    if ($scope.showPublish && !$scope.newCollection) {
+      $scope.newCollection = {numShards: 1, replicationFactor: 1, indexToCollection: "true"};
+    }
+  };
+
+  $scope.toggleAddCopyField = function () {
+    if ($scope.showAddCopyField) {
+      $scope.hideAll();
+      $scope.showFieldDetails = true;
+    } else {
+      $scope.hideAll();
+      $scope.showAddCopyField = true;
+      $scope.showFieldDetails = false;
+
+      $scope.copyField = {};
+      delete $scope.addCopyFieldErrors;
+    }
+  }
+  $scope.addCopyField = function () {
+    delete $scope.addCopyFieldErrors;
+    var data = {"add-copy-field": $scope.copyField};
+    SchemaDesigner.post({
+      path: "add",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, data, function (data) {
+      if (data.errors) {
+        $scope.addCopyFieldErrors = data.errors[0].errorMessages;
+        if (typeof $scope.addCopyFieldErrors === "string") {
+          $scope.addCopyFieldErrors = [$scope.addCopyFieldErrors];
+        }
+      } else {
+        $scope.showAddCopyField = false;
+        // TODO:
+        //$timeout($scope.refresh, 1500);
+      }
+    }, $scope.errorHandler);
+  }
+
+  $scope.toggleAnalyzer = function (analyzer) {
+    analyzer.show = !analyzer.show;
+  }
+
+  $scope.initTypeAnalysisInfo = function (typeName) {
+    $scope.analysis = getAnalysisInfo($scope.luke, {type: true}, typeName);
+    if ($scope.analysis && $scope.analysis.data) {
+      $scope.className = $scope.analysis.data.className
+    }
+    $scope.editAnalysis = "Edit JSON";
+    $scope.showAnalysisJson = false;
+    delete $scope.analysisJsonText;
+  };
+
+  $scope.toggleVerbose = function () {
+    $scope.analysisVerbose = !$scope.analysisVerbose;
+  };
+
+  $scope.updateSampleDocId = function () {
+    $scope.indexText = "";
+    $scope.result = {};
+
+    if (!$scope.selectedNode) {
+      return;
+    }
+
+    var field = $scope.selectedNode.name;
+    var params = {path: "sample"};
+    params.configSet = $scope.currentSchema;
+    params.uniqueKeyField = $scope.uniqueKeyField;
+    params.field = field;
+
+    if ($scope.sampleDocId) {
+      params.docId = $scope.sampleDocId;
+    } // else the server will pick the first doc with a non-empty text value for the desired field
+
+    SchemaDesigner.get(params, function (data) {
+      $scope.sampleDocId = data[$scope.uniqueKeyField];
+      $scope.indexText = data[field];
+      if (data.analysis && data.analysis["field_names"]) {
+        $scope.result = processFieldAnalysisData(data.analysis["field_names"][field]);
+      }
+    });
+  };
+
+  $scope.changeLanguages = function () {
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  function getType(typeName) {
+    if ($scope.fieldTypes) {
+      for (i in $scope.fieldTypes) {
+        if ($scope.fieldTypes[i].text === typeName) {
+          return $scope.fieldTypes[i];
+        }
+      }
+    }
+    return null;
+  }
+
+  $scope.refreshTree = function() {
+    var jst = $('#schemaJsTree').jstree(true);
+    if (jst) {
+      jst.refresh();
+    }
+  };
+
+  $scope.onSchemaTreeLoaded = function (id) {
+    //console.log(">> on tree loaded");
+  };
+
+  $scope.updateFile = function () {
+    var nodeId = "files/" + $scope.selectedFile;
+    var params = {path: "file", file: $scope.selectedFile, configSet: $scope.currentSchema};
+
+    $scope.updateWorking = true;
+    $scope.updateStatusMessage = "Updating file ...";
+
+    SchemaDesigner.post(params, $scope.fileNodeText, function (data) {
+      if (data.updateFileError) {
+        if (data[$scope.selectedFile]) {
+          $scope.fileNodeText = data[$scope.selectedFile];
+        }
+        $scope.updateFileError = data.updateFileError;
+      } else {
+        delete $scope.updateFileError;
+        $scope.updateStatusMessage = "File '"+$scope.selectedFile+"' updated.";
+        $scope.onSchemaUpdated(data.configSet, data, nodeId);
+      }
+    }, $scope.errorHandler);
+  };
+
+  $scope.onSelectFileNode = function (id, doSelectOnTree) {
+    $scope.selectedFile = id.startsWith("files/") ? id.substring("files/".length) : id;
+
+    var params = {path: "file", file: $scope.selectedFile, configSet: $scope.currentSchema};
+    SchemaDesigner.get(params, function (data) {
+      $scope.fileNodeText = data[$scope.selectedFile];
+      $scope.isLeafNode = false;
+      if (doSelectOnTree) {
+        delete $scope.selectedNode;
+        $scope.isLeafNode = false;
+        $scope.showFieldDetails = true;
+        delete $scope.sampleDocId;
+        $scope.showAnalysis = false;
+        $scope.selectNodeInTree(id);
+      }
+    });
+  };
+  
+  function fieldNodes(src, type) {
+    var nodes = [];
+    for (var c in src) {
+      var childNode = src[c];
+      if (childNode && childNode.a_attr) {
+        var a = childNode.a_attr;
+        var stored = a.stored || (a.docValues && a.useDocValuesAsStored);
+        var obj = {"name":a.name, "indexed":a.indexed, "docValues": a.docValues,
+          "multiValued":a.multiValued, "stored":stored, "tokenized": a.tokenized};
+        if (type === "field" || type === "dynamicField") {
+          obj.type = a.type;
+        } else if (type === "type") {
+          obj.class = a.class;
+        }
+        nodes.push(obj);
+      }
+    }
+    return nodes;
+  }
+
+  function stripAnchorSuffix(id) {
+    if (id && id.endsWith("_anchor")) {
+      id = id.substring(0, id.length - "_anchor".length);
+    }
+    return id;
+  }
+
+  $scope.onSelectSchemaTreeNode = function (id) {
+    id = stripAnchorSuffix(id);
+    $scope.showFieldDetails = false;
+    $scope.isSchemaRoot = false;
+    $scope.isLeafNode = false;
+    delete $scope.containerNodeLabel;
+    delete $scope.containerNode;
+    delete $scope.containerNodes;
+    delete $scope.selectedFile;
+
+    if (id === "/") {
+      $scope.selectedType = "Schema";
+      $scope.selectedNode = null;
+      $scope.isSchemaRoot = true;
+      $scope.isLeafNode = false;
+      $scope.isContainerNode = false;
+      $scope.showFieldDetails = true;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+
+      if (!$scope.treeFilter) {
+        $scope.treeFilter = "type";
+        $scope.treeFilterOption = "*";
+        $scope.initTreeFilters();
+      }
+      return;
+    }
+
+    var jst = $('#schemaJsTree').jstree(true);
+    if (!jst) {
+      return;
+    }
+
+    var node = jst.get_node(id);
+    if (!node) {
+      return;
+    }
+
+    if (id === "files") {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      return;
+    }
+
+    if (id.indexOf("/") === -1) {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      $scope.containerNode = id;
+
+      if (id === "fields") {
+        $scope.containerNodes = fieldNodes($scope.fieldsNode ? $scope.fieldsNode.children : $scope.fieldsSrc, "field");
+      } else if (id === "dynamicFields") {
+        $scope.containerNodes = fieldNodes($scope.dynamicFieldsNode ? $scope.dynamicFieldsNode.children : $scope.dynamicFieldsSrc, "dynamicField");
+      } else if (id === "fieldTypes") {
+        $scope.containerNodes = fieldNodes($scope.fieldTypes, "type");
+      }
+
+      $scope.containerNodeLabel = node.text;
+      $scope.showFieldDetails = true;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+      return;
+    }
+
+    if (id.startsWith("files/")) {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+      if (node.children.length === 0) {
+        // file
+        $scope.showFieldDetails = true;
+        $scope.onSelectFileNode(id, false);
+      } else {
+        // folder
+        $scope.showFieldDetails = false;
+        delete $scope.selectedFile;
+      }
+      return;
+    }
+
+    delete $scope.selectedFile;
+    $scope.selectedNode = node["a_attr"]; // all the info we need is in the a_attr object
+    if (!$scope.selectedNode) {
+      // a node in the tree that isn't a field was selected, just ignore
+      return;
+    }
+
+    $scope.selectedNode.fieldType = getType($scope.selectedNode.type);
+    $scope.isLeafNode = true;
+
+    var nodeType = id.substring(0, id.indexOf("/"));
+    var name = null;
+    if (nodeType === "field") {
+      $scope.selectedType = "Field";
+      name = $scope.selectedNode.type;
+    } else if (nodeType === "dynamic") {
+      $scope.selectedType = "Dynamic Field";
+    } else if (nodeType === "type") {
+      $scope.selectedType = "Type";
+      name = $scope.selectedNode.name;
+    }
+
+    if (name) {
+      $scope.initTypeAnalysisInfo(name, "type");
+    }
+
+    // apply some sanity to the checkboxes
+    $scope.selectedNode = applyConstraintsOnField($scope.selectedNode);
+    $scope.showFieldDetails = true;
+
+    if (nodeType === "field" && $scope.selectedNode.tokenized && $scope.selectedNode.stored) {
+      $scope.showAnalysis = true;
+      $scope.updateSampleDocId();
+    } else {
+      $scope.showAnalysis = false;
+      $scope.indexText = "";
+      $scope.result = {};
+    }
+  };
+
+  function addFileNode(dirs, parent, f) {
+    var path = f.split("/");
+    if (path.length === 1) {
+      if (!parent.children) {
+        parent.children = [];
+        dirs.push(parent); // now parent has children, so track in dirs ...
+      }
+      var nodeId = parent.id + "/" + f;
+      parent.children.push({"text": f, "id": nodeId, "a_attr": {"href": nodeId}});
+    } else {
+      // get the parent for this path
+      var parentId = "files/" + path.slice(0, path.length - 1).join("/");
+      var dir = null;
+      for (var d in dirs) {
+        if (dirs[d].id === parentId) {
+          dir = dirs[d];
+          break;
+        }
+      }
+      if (!dir) {
+        dir = {"text": path[0], "id": parentId, "a_attr": {"href": parentId}, "children": []};
+        dirs.push(dir);
+        parent.children.push(dir);
+      }
+
+      // walk down the next level in this path
+      addFileNode(dirs, dir, path.slice(1).join("/"));
+    }
+  }
+
+  // transform a flat list structure into the nested tree structure
+  function filesToTree(files) {
+    var filesNode = {"text": "Files", "a_attr": {"href": "files"}, "id": "files", "children": []};
+    if (files) {
+      var dirs = []; // lookup for known dirs by path since nodes don't keep a ref to their parent node
+      for (var i in files) {
+        // hide the configoverlay.json from the UI
+        if (files[i] === "configoverlay.json") {
+          continue;
+        }
+
+        addFileNode(dirs, filesNode, files[i]);
+      }
+      delete dirs;
+    }
+    return filesNode;
+  }
+
+  function fieldsToTree(fields) {
+    var children = [];
+    if (fields) {
+      for (var i in fields) {
+        var id = "field/" + fields[i].name;
+        fields[i].href = id;
+        var text = fields[i].name;
+        if (fields[i].name === $scope.uniqueKeyField) {
+          text += "*"; // unique key field
+        }
+        children.push({"text": text, "a_attr": fields[i], "id": id});
+      }
+    }
+    return children;
+  }
+
+  function fieldTypesToTree(types) {
+    var children = [];
+    for (var i in types) {
+      var ft = types[i]
+      var id = "type/" + ft.name;
+      ft.href = id;
+      children.push({"text": ft.name, "a_attr": ft, "id": id});
+    }
+    return children;
+  }
+
+  $scope.onSampleDocumentsChanged = function () {
+    $scope.hasDocsOnServer = false; // so the updates get sent on next analyze action
+  };
+
+  $scope.initDesignerSettingsFromResponse = function (data) {
+    $scope.enableDynamicFields = data.enableDynamicFields !== null ? "" + data.enableDynamicFields : "true";
+    $scope.enableFieldGuessing = data.enableFieldGuessing !== null ? "" + data.enableFieldGuessing : "true";
+    $scope.enableNestedDocs = data.enableNestedDocs !== null ? "" + data.enableNestedDocs : "false";
+    $scope.languages = data.languages !== null && data.languages.length > 0 ? data.languages : ["*"];
+    $scope.copyFrom = data.copyFrom !== null ? data.copyFrom : "_default";
+  };
+
+  $scope.doAnalyze = function (nodeId) {
+    delete $scope.sampleMessage;
+
+    var schema = $scope.currentSchema;
+    if (schema) {
+      delete $scope.copyFrom;
+    } else {
+      schema = $scope.newSchema;
+      if (!$scope.copyFrom) {
+        $scope.copyFrom = "_default";
+      }
+    }
+
+    if (!schema) {
+      return;
+    }
+
+    var params = {path: "analyze", configSet: schema};
+    if ($scope.schemaVersion && $scope.schemaVersion !== -1) {
+      params.schemaVersion = $scope.schemaVersion;
+    }
+
+    if ($scope.enableDynamicFields) {
+      params.enableDynamicFields = $scope.enableDynamicFields;
+    }
+    if ($scope.enableFieldGuessing) {
+      params.enableFieldGuessing = $scope.enableFieldGuessing;
+    }
+    if ($scope.enableNestedDocs) {
+      params.enableNestedDocs = $scope.enableNestedDocs;
+    }
+
+    if ($scope.languages && $scope.languages.length > 0) {
+      params.languages = $scope.languages;
+    }
+
+    if ($scope.copyFrom) {
+      params.copyFrom = $scope.copyFrom;
+    }
+
+    $scope.updateWorking = true;
+    if ($scope.selectedUpdated) {
+      $scope.updateStatusMessage = "Applying " + $scope.selectedType + " updates ..."
+    } else {
+      $scope.updateStatusMessage = "Analyzing your sample data, schema will load momentarily ..."
+    }
+
+    if (!nodeId && $scope.selectedNode) {
+      nodeId = $scope.selectedNode.id;
+    }
+
+    // a bit tricky ...
+    // so users can upload a file or paste in docs
+    // if they upload a file containing a small (<15K) of text data, then we'll show it in the textarea
+    // they can change the text and re-analyze too
+    // if no changes or nothing uploaded, the server-side uses the latest sample data stored in the blob store
+    if ($scope.fileUpload) {
+      var file = $scope.fileUpload;
+      var fd = new FormData();
+      fd.append('file', file);
+      SchemaDesigner.upload(params, fd, function (data) {
+        $("#upload-file").val("");
+        delete $scope.fileUpload;
+        $scope.onSchemaUpdated(schema, data, nodeId);
+      }, $scope.errorHandler);
+    } else {
+      // don't need to keep re-posting the same sample if already stored in the blob store
+      var postData = null;
+      if (!$scope.hasDocsOnServer) {
+        postData = $scope.sampleDocuments;
+        if (!postData && !$scope.published) {
+          return;
+        }
+      }
+
+      var respHandler = function (data) {
+        $scope.onSchemaUpdated(schema, data, nodeId);
+      };
+
+      // TODO: need a better approach to detecting the content type from text content
+      var contentType = "text/plain";
+      if (postData != null) {
+        var txt = postData.trim();
+        if ((txt.startsWith("[") && txt.includes("]")) || (txt.startsWith("{") && txt.includes("}"))) {
+          contentType = "application/json"
+        } else if (txt.startsWith("<") || txt.includes("<add>") || txt.includes("<!--")) {
+          contentType = "text/xml";
+        } else {
+          contentType = "application/csv";
+        }
+      }
+
+      if (contentType === "text/xml") {
+        SchemaDesigner.postXml(params, postData, respHandler, $scope.errorHandler);
+      } else if (contentType === "application/csv") {
+        SchemaDesigner.postCsv(params, postData, respHandler, $scope.errorHandler);
+      } else {
+        SchemaDesigner.post(params, postData, respHandler, $scope.errorHandler);
+      }
+    }
+  };
+
+  $scope.onFieldTypeChanged = function () {
+
+    var copyFromType = ["stored", "indexed", "multiValued", "docValues", "useDocValuesAsStored", "tokenized", "uninvertible", "termVectors", "termPositions", "termOffsets",
+      "omitNorms", "omitTermFreqAndPositions", "omitPositions", "storeOffsetsWithPositions"];
+
+    var type = $scope.selectedNode.type
+
+    // when the user updates the selected field's type, we go apply the
+    // new field type's properties to the selected node
+    for (var i in $scope.fieldTypes) {
+      if ($scope.fieldTypes[i].text == type) {
+        var ft = $scope.fieldTypes[i];
+        $scope.selectedNode.fieldType = ft;
+        for (var i in copyFromType) {
+          var x = copyFromType[i];
+          if (ft.a_attr[x] !== null) {
+            $scope.selectedNode[x] = ft.a_attr[x];
+          } else {
+            delete $scope.selectedNode[x];
+          }
+        }
+        $scope.selectedUpdated = true;
+        break;
+      }
+    }
+
+    $scope.selectedNode = applyConstraintsOnField($scope.selectedNode);
+    if ($scope.selectedUpdated) {
+      // for luke analysis, we need the type info here, not the specific field into b/c it just changed.
+      $scope.initTypeAnalysisInfo(type, "type");
+    }
+  };
+
+  $scope.isDisabled = function (dep) {
+    if (!$scope.selectedNode) {
+      return false;
+    }
+
+    if (dep === "termVectors") {
+      // termVectors dependency depends on indexed
+      return !($scope.selectedNode.indexed && $scope.selectedNode.termVectors);
+    }
+
+    if (dep === "not-text" || dep === "docValues") {
+      if ($scope.selectedNode.fieldType && $scope.selectedNode.fieldType.a_attr.class === "solr.TextField") {
+        // no doc values for TextField
+        return true;
+      }
+    }
+
+    return $scope.selectedNode[dep] === false;
+  };
+
+  // this updates checkboxes based on the current settings
+  $scope.markSelectedUpdated = function (event) {
+    delete $scope.updateStatusMessage;
+    $scope.selectedUpdated = true; // enable the update button for this field
+  };
+
+  $scope.deleteSelected = function () {
+    // console.log(">> deleteSelected");
+  };
+
+  $scope.updateSelected = function () {
+    if (!$scope.selectedNode) {
+
+      if ($scope.selectedUpdated) {
+        // some root level property changed ... re-analyze
+        $scope.doAnalyze("/");
+      }
+
+      return;
+    }
+
+    delete $scope.updateSelectedError;
+
+    // make a copy for the PUT
+    var href = $scope.selectedNode.href;
+    var id = $scope.selectedNode.id;
+
+    var putData = JSON.parse(JSON.stringify($scope.selectedNode));
+    if ($scope.selectedType === "Field" && putData.copyDest) {
+      var fields = putData.copyDest.split(",");
+      for (var f in fields) {
+        var name = fields[f].trim();
+        if (!$scope.fieldNames.includes(name)) {
+          $scope.updateSelectedError = "Copy to field '" + name + "' doesn't exist!";
+          return;
+        }
+        if (name === $scope.selectedNode.name) {
+          $scope.updateSelectedError = "Cannot copy a field to itself!";
+          return;
+        }
+      }
+    } else {
+      delete putData.copyDest;
+    }
+
+    delete putData.fieldType;
+    delete putData.href;
+    delete putData.id;
+
+    putData = applyConstraintsOnField(putData);
+
+    if ($scope.analysisJsonText && !$scope.selectedNode.type) {
+      var text = $scope.analysisJsonText.trim();
+      if (text.length > 0) {
+        text = text.replace(/\s+/g, ' ');
+        if (!text.startsWith("{")) {
+          text = "{ " + text + " }";
+        }
+        try {
+          var textJson = JSON.parse(text);
+          if (textJson.analyzer) {
+            putData.analyzer = textJson.analyzer;
+          } else {
+            if (textJson.indexAnalyzer && textJson.queryAnalyzer) {
+              putData.indexAnalyzer = textJson.indexAnalyzer;
+              putData.queryAnalyzer = textJson.queryAnalyzer;
+            }
+          }
+        } catch (e) {
+          $scope.updateSelectedError = "Failed to parse analysis as JSON due to: " + e.message +
+              "; expected JSON object with either an 'analyzer' or 'indexAnalyzer' and 'queryAnalyzer'";
+          return;
+        }
+      }
+    }
+
+    $scope.updateWorking = true;
+    $scope.updateStatusMessage = "Updating " + $scope.selectedType + " ...";
+
+    SchemaDesigner.put({
+      path: "update",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, putData, function (data) {
+
+      var nodeType = data.updateType;
+      $scope.schemaVersion = data.schemaVersion;
+      $scope.currentSchema = data.configSet;
+      $scope.core = data.core;
+
+      $scope.selectedNode = data[nodeType];
+      $scope.selectedNode.href = href;
+      $scope.selectedNode.id = id;
+
+      var name = nodeType === "field" ? $scope.selectedNode.type : $scope.selectedNode.name;
+      $scope.initTypeAnalysisInfo(name, "type");
+      $scope.showFieldDetails = true;
+
+      if (nodeType === "field" && $scope.selectedNode.tokenized) {
+        $scope.showAnalysis = true;
+        $scope.updateSampleDocId();
+      }
+
+      $scope.onSchemaUpdated($scope.currentSchema, data, href);
+    }, $scope.errorHandler);
+  };
+
+  // TODO: These are copied from analysis.js, so move to a shared location for both vs. duplicating
+  var getShortComponentName = function (longname) {
+    var short = -1 !== longname.indexOf('$')
+        ? longname.split('$')[1]
+        : longname.split('.').pop();
+    return short.match(/[A-Z]/g).join('');
+  };
+
+  var getCaptionsForComponent = function (data) {
+    var captions = [];
+    for (var key in data[0]) {
+      key = key.replace(/.*#/, '');
+      if (key != "match" && key != "positionHistory") {
+        captions.push(key.replace(/.*#/, ''));
+      }
+    }
+    return captions;
+  };
+
+  var getTokensForComponent = function (data) {
+    var tokens = [];
+    var previousPosition = 0;
+    var index = 0;
+    for (var i in data) {
+      var tokenhash = data[i];
+      var positionDifference = tokenhash.position - previousPosition;
+      for (var j = positionDifference; j > 1; j--) {
+        tokens.push({position: tokenhash.position - j + 1, blank: true, index: index++});
+      }
+
+      var token = {position: tokenhash.position, keys: [], index: index++};
+
+      for (key in tokenhash) {
+        if (key == "match" || key == "positionHistory") {
+          //skip, to not display these keys in the UI
+        } else {
+          var tokenInfo = new Object();
+          tokenInfo.name = key;
+          tokenInfo.value = tokenhash[key];
+          if ('text' === key || 'raw_bytes' === key) {
+            if (tokenhash.match) {
+              tokenInfo.extraclass = 'match'; //to highlight matching text strings
+            }
+          }
+          token.keys.push(tokenInfo);
+        }
+      }
+      tokens.push(token);
+      previousPosition = tokenhash.position;
+    }
+    return tokens;
+  };
+
+  var extractComponents = function (data, result, name) {
+    if (data) {
+      result[name] = [];
+      for (var i = 0; i < data.length; i += 2) {
+        var component = {
+          name: data[i],
+          short: getShortComponentName(data[i]),
+          captions: getCaptionsForComponent(data[i + 1]),
+          tokens: getTokensForComponent(data[i + 1])
+        };
+        result[name].push(component);
+      }
+    }
+  };
+
+  var processFieldAnalysisData = function (analysis) {
+    var response = {};
+    extractComponents(analysis.index, response, "index");
+    return response;
+  };
+
+  $scope.doPublish = function () {
+    var params = {
+      path: "publish",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion,
+      reloadCollections: $scope.reloadOnPublish,
+      cleanupTemp: true,
+      disableDesigner: $scope.disableDesigner
+    };
+    if ($scope.newCollection && $scope.newCollection.name) {
+      params.newCollection = $scope.newCollection.name;
+      params.numShards = $scope.newCollection.numShards;
+      params.replicationFactor = $scope.newCollection.replicationFactor;
+      params.indexToCollection = $scope.newCollection.indexToCollection;
+    }
+    SchemaDesigner.put(params, null, function (data) {
+      $scope.schemaVersion = data.schemaVersion;
+      $scope.currentSchema = data.configSet;
+
+      delete $scope.selectedNode;
+      $scope.currentSchema = "";
+      delete $scope.newSchema;
+      $scope.showPublish = false;
+      $scope.refresh();
+
+      if (data.newCollection) {
+        $window.location.href = "#/" + data.newCollection + "/collection-overview";
+      }
+    }, $scope.errorHandler);
+  };
+
+  $scope.downloadConfig = function () {
+    // have to use an AJAX request so we can supply the Authorization header
+    if (sessionStorage.getItem("auth.header")) {
+      var fileName = $scope.currentSchema+"_configset.zip";
+      var xhr = new XMLHttpRequest();
+      xhr.open("GET", "/api/schema-designer/download/"+fileName+"?wt=raw&configSet="+$scope.currentSchema, true);
+      xhr.setRequestHeader('Authorization', sessionStorage.getItem("auth.header"));
+      xhr.responseType = 'blob';
+      xhr.addEventListener('load',function() {
+        if (xhr.status === 200) {
+          var url = window.URL.createObjectURL(xhr.response);
+          var a = document.createElement('a');
+          a.href = url;
+          a.download = fileName;
+          document.body.append(a);
+          a.click();
+          a.remove();
+          window.URL.revokeObjectURL(url);
+        }
+      })
+      xhr.send();
+    } else {
+      location.href = "/api/schema-designer/download/"+$scope.currentSchema+"_configset.zip?wt=raw&configSet=" + $scope.currentSchema;
+    }
+  };
+
+  function docsToTree(docs) {
+    var children = [];
+    for (var i in docs) {
+      var id = docs[i][$scope.uniqueKeyField];
+      if (!id) {
+        id = "" + i; // id not in results so use the position in results as the value
+      }
+      var nodeId = "doc/" + id;
+      docs[i].href = nodeId;
+      children.push({"text": id, "a_attr": docs[i], "id": nodeId});
+    }
+    return children;
+  }
+
+  function debugToTree(debugObj) {
+    var children = [];
+    for (var x in debugObj) {
+      if (typeof debugObj[x] === 'object') {
+        var obj = debugObj[x];
+        var nodeId = "debug/" + x;
+        var tdata = [];
+        for (var a in obj) {
+          if (typeof obj[a] !== 'object') {
+            tdata.push({name: a, value: obj[a]});
+          }
+        }
+        children.push({"text": x, "a_attr": {"href": nodeId}, "tdata": tdata, "id": nodeId, "children": []});
+      }
+    }
+    return children;
+  }
+
+  function facetsToTree(ff) {
+    var children = [];
+    for (var f in ff) {
+      var nodeId = "facet/" + f;
+      if (ff[f] && ff[f].length > 0) {
+        var facet = ff[f];
+        var tdata = [];
+        for (let i = 0; i < facet.length; i += 2) {
+          tdata.push({name: facet[i], value: facet[i + 1]});
+        }
+        children.push({"text": f, "a_attr": {"href": nodeId}, "tdata": tdata, "id": nodeId, "children": []});
+      }
+    }
+    return children;
+  }
+
+  function hlToTree(hl) {
+    var children = [];
+    for (var f in hl) {
+      var nodeId = "hl/" + f;
+      var tdata = [];
+      var obj = hl[f];
+      for (var a in obj) {
+        var v = obj[a];
+        if (v && v.length > 0) {
+          tdata.push({name: a, value: v[0]});
+        }
+      }
+      if (tdata.length > 0) {
+        children.push({"text": f, "a_attr": {"href": nodeId}, "tdata": tdata, "id": nodeId, "children": []});
+      }
+    }
+    return children;
+  }
+
+  $scope.selectField = function (event) {
+    var t = event.target || event.srcElement || event.currentTarget;
+    if (t && t.text) {
+      var nodeId = "field/" + t.text;
+      $scope.onSelectSchemaTreeNode(nodeId);
+      $scope.selectNodeInTree(nodeId);
+    }
+  };
+
+  $scope.selectFieldType = function (event) {
+    var t = event.target || event.srcElement || event.currentTarget;
+    if (t && t.text) {
+      var nodeId = "type/" + t.text;
+      $scope.onSelectSchemaTreeNode(nodeId);
+      $scope.selectNodeInTree(nodeId);
+    }
+  };
+
+  function omitVersionField(key,value) {
+    return (key === "_version_") ? undefined : value;
+  }
+
+  $scope.editDocuments = function() {
+    delete $scope.helpId;
+    if ($scope.queryDocs) {
+      $scope.hasDocsOnServer = false; // so the updated docs apply
+      $("#upload-file").val("");
+      delete $scope.fileUpload;
+      $scope.sampleDocuments = $scope.queryDocs;
+      $scope.onSampleDocumentsChanged();
+    }
+  };
+
+  $scope.renderResultsTree = function (data) {
+    var h = data.responseHeader;
+    var sort = h.params.sort;
+    if (!sort) {
+      sort = "score desc";
+    }
+
+    if (data.response && data.response.docs && data.response.docs.length > 0) {
+      $scope.queryDocs = JSON.stringify(data.response.docs, omitVersionField, 2);
+    } else {
+      delete $scope.queryDocs;
+    }
+
+    $scope.resultsMeta = [
+      {name: "Query", value: h.params.q},
+      {name: "QTime", value: h.QTime},
+      {name: "Hits", value: data.response.numFound},
+      {name: "sort", value: sort},
+    ];
+
+    var excParams = ["q", "handler", "debug", "configSet", "wt", "version", "_", "sort"];
+    for (var p in h.params) {
+      if (!excParams.includes(p)) {
+        $scope.resultsMeta.push({name: p, value: h.params[p]});
+      }
+    }
+
+    $scope.debugMeta = [];
+    for (var d in data.debug) {
+      if (typeof data.debug[d] !== 'object') {
+        var nvp = {name: d, value: data.debug[d]};
+        $scope.debugMeta.push(nvp);
+        $scope.resultsMeta.push(nvp);
+      }
+    }
+
+    var rootChildren = [{
+      "id":"docs",
+      "text": "Documents",
+      "state": {"opened": true},
+      "a_attr": {"href": "docs"},
+      "children": docsToTree(data.response.docs)
+    }];
+
+    if (data.facet_counts && data.facet_counts.facet_fields) {
+      rootChildren.push({
+        "id":"facets",
+        "text": "Facets",
+        "state": {"opened": true},
+        "a_attr": {"href": "facets"},
+        "children": facetsToTree(data.facet_counts.facet_fields)
+      });
+    }
+
+    if (data.highlighting) {
+      var hlNodes = hlToTree(data.highlighting);
+      if (hlNodes.length > 0) {
+        rootChildren.push({
+          "id":"hl",
+          "text": "Highlighting",
+          "state": {"opened": true},
+          "a_attr": {"href": "hl"},
+          "children": hlNodes
+        });
+      }
+    }
+
+    if (data.debug) {
+      rootChildren.push({"id":"debug", "text": "Debug", "a_attr": {"href": "debug"}, "children": debugToTree(data.debug)});
+    }
+
+    var tree = [{"id":"/","text": "Results", "a_attr": {"href": "/"}, "children": rootChildren}];
+    $scope.queryResultsTree = tree;
+  };
+
+  $scope.onSelectQueryResultsNode = function (id) {
+    $scope.selectedResultsNode = id;
+
+    if (id === "/" || id === "docs") {
+      $scope.resultsData = $scope.resultsMeta;
+      return;
+    }
+
+    if (id === "debug") {
+      $scope.resultsData = $scope.debugMeta;
+      return;
+    }
+
+    var jst = $('#queryResultsJsTree').jstree();
+    var node = jst.get_node(id);
+    if (!node || !node.a_attr) {
+      $scope.resultsData = $scope.resultsMeta;
+      return;
+    }
+
+    if (node.original && node.original.tdata) {
+      $scope.resultsData = node.original.tdata;
+    } else {
+      $scope.resultsData = [];
+      for (var a in node.a_attr) {
+        if (a === "href") continue;
+        var row = {name: a, value: node.a_attr[a]};
+        if (id.startsWith("doc/")) {
+          row.type = "f"; // so we can link to fields in the schema tree from results!
+        }
+
+        $scope.resultsData.push(row);
+      }
+    }
+
+    if (id.startsWith("doc/")) {
+      $scope.sampleDocId = id.substring(4);
+      $scope.updateSampleDocId();
+    }
+  };
+
+  $scope.doQuery = function () {
+
+    delete $scope.queryDocs;
+
+    var params = {path: "query", configSet: $scope.currentSchema, debug: "true", "wt": "json"};
+
+    if ($scope.selectedFacets && $scope.selectedFacets.length > 0) {
+      params["facet"] = true;
+      params["facet.field"] = $scope.selectedFacets;
+      params["facet.limit"] = 20;
+      params["facet.mincount"] = 1;
+    } else {
+      params["facet"] = false;
+      delete params["facet.field"];
+    }
+
+    var set = function (key, value) {
+      if (params[key]) {
+        params[key].push(value);
+      } else {
+        params[key] = [value];
+      }
+    }
+
+    params["sort"] = $scope.query.sortBy + " " + $scope.query.sortDir;
+    params["q"] = $scope.query.q.trim();
+    if (!params["q"]) {
+      params["q"] = "*:*";
+    }
+
+    if ($scope.rawParams) {
+      var rawParams = $scope.rawParams.split(/[&\n]/);
+      for (var i in rawParams) {
+        var param = rawParams[i];
+        var equalPos = param.indexOf("=");
+        if (equalPos > -1) {
+          set(param.substring(0, equalPos), param.substring(equalPos + 1));
+        } else {
+          set(param, ""); // Use empty value for params without "="
+        }
+      }
+    }
+
+    if (params["q"] !== '*:*' && $scope.query.highlight) {

Review comment:
       *W069:*  ['q'] is better written in dot notation.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/schema-designer.js
##########
@@ -0,0 +1,2014 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $cookies, $window, Constants, SchemaDesigner, Luke) {
+  $scope.resetMenu("schema-designer", Constants.IS_ROOT_PAGE);
+
+  $scope.onWarning = function (warnMsg, warnDetails) {
+    $scope.updateWorking = false;
+    delete $scope.updateStatusMessage;
+    $scope.apiWarning = warnMsg;
+    $scope.apiWarningDetails = warnDetails;
+  };
+  
+  $scope.onError = function (errorMsg, errorCode, errorDetails) {
+    $scope.updateWorking = false;
+    delete $scope.updateStatusMessage;
+    $scope.designerAPIError = errorMsg;
+    if (errorDetails) {
+      var errorDetailsStr = "";
+      if (errorDetails["error"]) {
+        errorDetailsStr = errorDetails["error"];
+      } else {
+        for (var id in errorDetails) {
+          var msg = errorDetails[id];
+          var at = msg.indexOf("ERROR: ");
+          if (at !== -1) {
+            msg = msg.substring(at+7);
+          }
+          if (!msg.includes(id)) {
+            msg = id+": "+msg;
+          }
+          errorDetailsStr += msg+"\n\n";
+        }
+      }
+      $scope.designerAPIErrorDetails = errorDetailsStr;
+    } else {
+      delete $scope.designerAPIErrorDetails;
+    }
+
+    if (errorCode === 409) {
+      $scope.schemaVersion = -1; // reset to get the latest
+      $scope.isVersionMismatch = true;
+      $scope.errorButton = "Reload Schema";
+    } else if (errorCode < 500) {
+      $scope.isVersionMismatch = false;
+      $scope.errorButton = "OK";
+    } // else 500 errors get the top-level error message
+  };
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error) {
+      $scope.onError(error.msg, error.code, e.data.errorDetails);
+    } else {
+      // when a timeout occurs, the error details are sparse so just give the user a hint that something was off
+      var path = e.config && e.config.url ? e.config.url : "/api/schema-designer";
+      var reloadMsg = "";
+      if (path.includes("/analyze")) {
+        reloadMsg = " Re-try analyzing your sample docs by clicking on 'Analyze Documents' again."
+      }
+      $scope.onError("Request to "+path+" failed!", 408,
+          {"error":"Most likely the request timed out; check server log for more details."+reloadMsg});
+    }
+  };
+
+  $scope.closeWarnDialog = function () {
+    delete $scope.apiWarning;
+    delete $scope.apiWarningDetails;
+  };
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.designerAPIError;
+    delete $scope.designerAPIErrorDetails;
+    if ($scope.isVersionMismatch) {
+      $scope.isVersionMismatch = false;
+      var nodeId = "/";
+      if ($scope.selectedNode) {
+        nodeId = $scope.selectedNode.href;
+      }
+      $scope.doAnalyze(nodeId);
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.isSchemaDesignerEnabled = true;
+
+    delete $scope.helpId;
+
+    $scope.updateStatusMessage = "";
+    $scope.analysisVerbose = false;
+    $scope.updateWorking = false;
+    $scope.currentSchema = "";
+
+    delete $scope.hasDocsOnServer;
+    delete $scope.queryResultsTree;
+
+    $scope.languages = ["*"];
+    $scope.copyFrom = "_default";
+    delete $scope.sampleMessage;
+    delete $scope.sampleDocuments;
+
+    $scope.schemaVersion = -1;
+    $scope.schemaTree = {};
+    $scope.showSchemaActions = false;
+    $scope.sampleDocIds = [];
+    $scope.isSchemaRoot = false;
+
+    delete $scope.enableNestedDocs;
+    delete $scope.enableDynamicFields;
+    delete $scope.enableFieldGuessing;
+
+    // schema editor
+    $scope.showFieldDetails = false;
+    $scope.selectedNode = null;
+    $scope.selectedUpdated = false;
+    delete $scope.updateStatusMessage;
+
+    // text field analysis
+    $scope.showAnalysis = false;
+    $scope.sampleDocId = null;
+    $scope.indexText = "";
+    $scope.result = {};
+
+    // publish vars
+    delete $scope.newCollection;
+    $scope.reloadOnPublish = "true";
+
+    // query form
+    $scope.query = {q: '*:*', sortBy: 'score', sortDir: 'desc'};
+
+    SchemaDesigner.get({path: "configs"}, function (data) {
+
+      $scope.schemas = [];
+      $scope.publishedSchemas = ["_default"];
+
+      for (var s in data.configSets) {
+        // 1 means published but not editable
+        if (data.configSets[s] !== 1) {
+          $scope.schemas.push(s);
+        }
+
+        // 0 means not published yet (so can't copy from it yet)
+        if (data.configSets[s] > 0) {
+          $scope.publishedSchemas.push(s);
+        }
+      }
+
+      $scope.schemas.sort();
+      $scope.publishedSchemas.sort();
+
+      // if no schemas available to select, open the pop-up immediately
+      if ($scope.schemas.length === 0) {
+        $scope.firstSchemaMessage = true;
+        $scope.showNewSchemaDialog();
+      }
+    }, function(e) {
+      if (e.status === 401 || e.status === 403) {
+        $scope.isSchemaDesignerEnabled = false;
+        $scope.hideAll();
+      }
+    });
+  };
+
+  $scope.selectNodeInTree = function(nodeId) {
+    nodeId = stripAnchorSuffix(nodeId);
+    if (!nodeId) return;
+
+    var jst = $('#schemaJsTree').jstree(true);
+    if (jst) {
+      var selectedId = null;
+      var selected_node = jst.get_selected();
+      if (selected_node && selected_node.length > 0) {
+        selectedId = selected_node[0];
+      }
+      if (selectedId) {
+        try {
+          jst.deselect_node(selectedId);
+        } catch (err) {
+          // just ignore
+          //console.log("error deselecting "+selectedId);
+        }
+      }
+
+      try {
+        jst.select_node(nodeId, true);
+      } catch (err) {
+        // just ignore, some low-level tree issue
+        //console.log("error selecting "+nodeId);
+      }
+    }
+  };
+
+  $scope.loadFile = function (event) {
+    var t = event.target || event.srcElement || event.currentTarget;
+    if (t && t.text) {
+      $scope.onSelectFileNode("files/" + t.text, true);
+    }
+  };
+
+  $scope.confirmEditSchema = function () {
+    $scope.showConfirmEditSchema = false;
+    if ($scope.hasDocsOnServer || $scope.published) {
+      $scope.doAnalyze();
+    } else {
+      $scope.sampleMessage = "Please upload or paste some sample documents to analyze for building the '" + $scope.currentSchema + "' schema.";
+    }
+  };
+
+  $scope.cancelEditSchema = function () {
+    $scope.currentSchema = "";
+    $scope.showConfirmEditSchema = false;
+  };
+
+  $scope.loadSchema = function () {
+
+    if (!$scope.currentSchema) {
+      return;
+    }
+
+    $scope.resetSchema();
+    var params = {path: "info", configSet: $scope.currentSchema};
+    SchemaDesigner.get(params, function (data) {
+      $scope.currentSchema = data.configSet;
+      $("#select-schema").trigger("chosen:updated");
+
+      $scope.confirmSchema = data.configSet;
+      $scope.collectionsForConfig = data.collections;
+      $scope.hasDocsOnServer = data.numDocs > 0;
+      $scope.published = data.published;
+      $scope.initDesignerSettingsFromResponse(data);
+      if ($scope.collectionsForConfig && $scope.collectionsForConfig.length > 0) {
+        $scope.showConfirmEditSchema = true;
+      } else {
+        if ($scope.hasDocsOnServer || $scope.published) {
+          $scope.doAnalyze();
+        } else {
+          $scope.sampleMessage = "Please upload or paste some sample documents to build the '" + $scope.currentSchema + "' schema.";
+        }
+      }
+    });
+  };
+
+  $scope.showNewSchemaDialog = function () {
+    $scope.hideAll();
+    $scope.showNewSchema = true;
+    $scope.newSchema = "";
+  };
+
+  $scope.addSchema = function () {
+    $scope.firstSchemaMessage = false;
+    delete $scope.addMessage;
+
+    if (!$scope.newSchema) {
+      $scope.addMessage = "Please provide a schema name!";
+      return;
+    }
+
+    $scope.newSchema = $scope.newSchema.trim();
+    if ($scope.newSchema.length > 50) {
+      $scope.addMessage = "Schema name be 50 characters or less";
+      return;
+    }
+
+    if ($scope.newSchema.indexOf(" ") !== -1 || $scope.newSchema.indexOf("/") !== -1) {
+      $scope.addMessage = "Schema name should not contain spaces or /";
+      return;
+    }
+
+    if ($scope.publishedSchemas.includes($scope.newSchema) || $scope.schemas.includes($scope.newSchema)) {
+      $scope.addMessage = "Schema '" + $scope.newSchema + "' already exists!";
+      return;
+    }
+
+    delete $scope.addMessage;
+    if (!$scope.copyFrom) {
+      $scope.copyFrom = "_default";
+    }
+
+    $scope.resetSchema();
+    $scope.schemas.push($scope.newSchema);
+    $scope.showNewSchema = false;
+    $scope.currentSchema = $scope.newSchema;
+    $scope.sampleMessage = "Please upload or paste some sample documents to analyze for building the '" + $scope.currentSchema + "' schema.";
+
+    SchemaDesigner.post({path: "prep", configSet: $scope.newSchema, copyFrom: $scope.copyFrom}, null, function (data) {
+      $scope.initDesignerSettingsFromResponse(data);
+    }, $scope.errorHandler);
+  };
+
+  $scope.cancelAddSchema = function () {
+    delete $scope.addMessage;
+    delete $scope.sampleMessage;
+
+    $scope.showNewSchema = false
+  };
+
+  $scope.hideAll = function () {
+    delete $scope.helpId;
+    $scope.showPublish = false;
+    $scope.showDiff = false;
+    $scope.showNewSchema = false;
+    $scope.showAddField = false;
+    $scope.showAddDynamicField = false;
+    $scope.showAddCopyField = false;
+    $scope.showAnalysis = false;
+    // add more dialogs here
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.hideData = function () {
+    $scope.showData = false;
+  };
+
+  $scope.rootChanged = function () {
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  $scope.updateUniqueKey = function () {
+    delete $scope.schemaRootMessage;
+    var jst = $('#schemaJsTree').jstree();
+    if (jst) {
+      var node = jst.get_node("field/" + $scope.updateUniqueKeyField);
+      if (node && node.a_attr) {
+        var attrs = node.a_attr;
+        if (attrs.multiValued || attrs.tokenized || !attrs.stored || !attrs.indexed) {
+          $scope.schemaRootMessage = "Field '" + $scope.updateUniqueKeyField +
+              "' cannot be used as the uniqueKey field! Must be single-valued, stored, indexed, and not tokenized.";
+          $scope.updateUniqueKeyField = $scope.uniqueKeyField;
+          return;
+        }
+      }
+    }
+    $scope.uniqueKeyField = $scope.updateUniqueKeyField;
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  $scope.resetSchema = function () {
+    $scope.hideAll();
+    $scope.analysisVerbose = false;
+    $scope.showSchemaActions = false;
+    $scope.showAnalysis = false;
+    $scope.showFieldDetails = false;
+    $scope.hasDocsOnServer = false;
+    $scope.query = {q: '*:*', sortBy: 'score', sortDir: 'desc'};
+    $scope.schemaVersion = -1;
+
+    $scope.updateWorking = false;
+    $scope.isVersionMismatch = false;
+    delete $scope.updateStatusMessage;
+    delete $scope.designerAPIError;
+    delete $scope.designerAPIErrorDetails;
+    delete $scope.selectedFacets;
+    delete $scope.sampleDocuments;
+    delete $scope.selectedNode;
+    delete $scope.queryResultsTree;
+  };
+
+  $scope.onSchemaUpdated = function (schema, data, nodeId) {
+    $scope.hasDocsOnServer = data.numDocs && data.numDocs > 0;
+    $scope.uniqueKeyField = data.uniqueKeyField;
+    $scope.updateUniqueKeyField = $scope.uniqueKeyField;
+    $scope.initDesignerSettingsFromResponse(data);
+
+    var fieldTypes = fieldTypesToTree(data.fieldTypes);
+    var files = filesToTree(data.files);
+
+    var rootChildren = [];
+    $scope.fieldsSrc = fieldsToTree(data.fields);
+    $scope.fieldsNode = {
+      "id": "fields",
+      "text": "Fields",
+      "state": {"opened": true},
+      "a_attr": {"href": "fields"},
+      "children": $scope.fieldsSrc
+    };
+    rootChildren.push($scope.fieldsNode);
+
+    if ($scope.enableDynamicFields === "true") {
+      $scope.dynamicFieldsSrc = fieldsToTree(data.dynamicFields);
+      $scope.dynamicFieldsNode = {
+        "id": "dynamicFields",
+        "text": "Dynamic Fields",
+        "a_attr": {"href": "dynamicFields"},
+        "children": $scope.dynamicFieldsSrc
+      };
+      rootChildren.push($scope.dynamicFieldsNode);
+    } else {
+      delete $scope.dynamicFieldsNode;
+      delete $scope.dynamicFieldsSrc;
+    }
+
+    rootChildren.push({"id":"fieldTypes", "text": "Field Types", "a_attr": {"href": "fieldTypes"}, "children": fieldTypes});
+    rootChildren.push(files);
+
+    var tree = [{"id":"/", "text": schema, "a_attr": {"href": "/"}, "state":{"opened": true}, "children": rootChildren}];
+
+    $scope.fields = data.fields;
+    $scope.fieldNames = data.fields.map(f => f.name).sort();
+    $scope.possibleIdFields = data.fields.filter(f => f.indexed && f.stored && !f.tokenized).map(f => f.name).sort();
+    $scope.sortableFields = data.fields.filter(f => (f.indexed || f.docValues) && !f.multiValued && !f.tokenized).map(f => f.name).sort();
+    $scope.sortableFields.push("score");
+
+    $scope.facetFields = data.fields.filter(f => (f.indexed || f.docValues) && !f.tokenized && f.name !== '_version_').map(f => f.name).sort();
+    $scope.hlFields = data.fields.filter(f => f.stored && f.tokenized).map(f => f.name).sort();
+
+    $scope.schemaVersion = data.schemaVersion;
+    $scope.currentSchema = data.configSet;
+    $scope.fieldTypes = fieldTypes;
+    $scope.core = data.core;
+    $scope.schemaTree = tree;
+    $scope.refreshTree();
+
+    $scope.collectionsForConfig = data.collectionsForConfig;
+
+    if (data.docIds) {
+      $scope.sampleDocIds = data.docIds;
+    }
+
+    // re-apply the filters on the updated schema
+    $scope.onTreeFilterOptionChanged();
+
+    // Load the Luke schema
+    Luke.schema({core: data.core}, function (schema) {
+      Luke.raw({core: data.core}, function (index) {
+        $scope.luke = mergeIndexAndSchemaData(index, schema.schema);
+        $scope.types = Object.keys(schema.schema.types);
+        $scope.showSchemaActions = true;
+        if (!nodeId) {
+          nodeId = "/";
+        }
+        $scope.onSelectSchemaTreeNode(nodeId);
+
+        $scope.updateWorking = false;
+
+        if (data.updateError != null) {
+          $scope.onError(data.updateError, data.updateErrorCode, data.errorDetails);
+        } else {
+          if ($scope.selectedUpdated) {
+            $scope.selectedUpdated = false;
+            $scope.updateStatusMessage = "Changes applied successfully.";
+            var waitMs = 3000;
+            if (data.rebuild) {
+              $scope.updateStatusMessage += " Did full re-index of sample docs due to incompatible update.";
+              waitMs = 5000; // longer message, more time to read
+            }
+
+            if (data.analysisError) {
+              var updateType = data["updateType"];
+              var updatedObject = data[updateType];
+              var updatedName = updatedObject && updatedObject.name ? updatedObject.name : "";
+              var warnMsg = "Changes to "+updateType+" "+updatedName+" applied, but required the temp collection to be deleted " +
+                  "and re-created due to an incompatible Lucene change, see details below.";
+              $scope.onWarning(warnMsg, data.analysisError);
+            } else {
+              $timeout(function () { delete $scope.updateStatusMessage; }, waitMs);
+            }
+          } else {
+            var source = data.sampleSource;
+            if (source) {
+              if (source === "paste") {
+                source = "pasted sample"
+              } else if (source === "blob") {
+                source = "previous upload stored on the server"
+              }
+              if (data.numDocs > 0) {
+                $scope.updateStatusMessage = "Analyzed "+data.numDocs+" docs from "+source;
+              } else {
+                $scope.updateStatusMessage = "Schema '"+$scope.currentSchema+"' loaded.";
+              }
+            }
+            $timeout(function () {
+              delete $scope.updateStatusMessage;
+            }, 5000);
+          }
+        }
+
+        // re-fire the current query to reflect the updated schema
+        $scope.doQuery();
+        $scope.selectNodeInTree(nodeId);
+      });
+    });
+  };
+
+  $scope.toggleAddField = function (type) {
+    if ($scope.showAddField) {
+      $scope.hideAll();
+    } else {
+      $scope.hideAll();
+      $scope.showAddField = true;
+      $scope.adding = type;
+
+      $scope.newField = {
+        stored: "true",
+        indexed: "true",
+        uninvertible: "true",
+        docValues: "true"
+      }
+
+      if (type === "field") {
+        $scope.newField.type = "string";
+      }
+
+      delete $scope.addErrors;
+    }
+  };
+
+  function applyConstraintsOnField(f) {
+
+    if (!f.docValues) {
+      delete f.useDocValuesAsStored;
+    }
+
+    if (!f.docValues && !f.uninvertible) {
+      delete f.sortMissingLast; // remove this setting if no docValues / uninvertible
+    }
+
+    if (f.indexed) {
+      if (f.omitTermFreqAndPositions && !f.omitPositions) {
+        delete f.omitPositions; // :shrug ~ see SchemaField ln 295
+      }
+      if (!f.termVectors) {
+        delete f.termPositions;
+        delete f.termOffsets;
+        delete f.termPayloads;
+      }
+    } else {
+      // if not indexed, a bunch of fields are false
+      f.tokenized = false;
+      f.uninvertible = false;
+
+      // drop these from the request
+      delete f.termVectors;
+      delete f.termPositions;
+      delete f.termOffsets;
+      delete f.termPayloads;
+      delete f.omitNorms;
+      delete f.omitPositions;
+      delete f.omitTermFreqAndPositions;
+      delete f.storeOffsetsWithPositions;
+    }
+
+    return f;
+  }
+
+  $scope.addField = function () {
+
+    // validate the form input
+    $scope.addErrors = [];
+    if (!$scope.newField.name) {
+      $scope.addErrors.push($scope.adding + " name is required!");
+    }
+
+    if ($scope.newField.name.indexOf(" ") != -1) {
+      $scope.addErrors.push($scope.adding + " name should not have whitespace");
+    }
+
+    var command = "add-field-type";
+    if ("field" === $scope.adding) {
+
+      if ($scope.fieldNames.includes($scope.newField.name)) {
+        $scope.addErrors.push("Field '" + $scope.newField.name + "' already exists!");
+        return;
+      }
+
+      // TODO: is this the correct logic for detecting dynamic? Probably good enough for the designer
+      var isDynamic = $scope.newField.name.startsWith("*") || $scope.newField.name.endsWith("*");
+      if (isDynamic) {
+        if ($scope.luke && $scope.luke.dynamic_fields[$scope.newField.name]) {
+          $scope.addErrors.push("dynamic field '" + $scope.newField.name + "' already exists!");
+        }
+      } else {
+        if ($scope.luke && $scope.luke.fields[$scope.newField.name]) {
+          $scope.addErrors.push("field '" + $scope.newField.name + "' already exists!");
+        }
+      }
+
+      if (!$scope.newField.type) {
+        $scope.addErrors.push("field type is required!");
+      }
+
+      command = isDynamic ? "add-dynamic-field" : "add-field";
+    } else if ("type" === $scope.adding) {
+      if ($scope.types.includes($scope.newField.name)) {
+        $scope.addErrors.push("Type '" + $scope.newField.name + "' already exists!");
+      }
+
+      if (!$scope.newField.class) {
+        $scope.addErrors.push("class is required when creating a new field type!");
+      }
+    }
+
+    var addData = {};
+    addData[command] = applyConstraintsOnField($scope.newField);
+    if ($scope.textAnalysisJson) {
+      var text = $scope.textAnalysisJson.trim();
+      if (text.length > 0) {
+        text = text.replace(/\s+/g, ' ');
+        if (!text.startsWith("{")) {
+          text = "{ " + text + " }";
+        }
+        try {
+          var textJson = JSON.parse(text);
+          if (textJson.analyzer) {
+            addData[command].analyzer = textJson.analyzer;
+          } else {
+            if (!textJson.indexAnalyzer || !textJson.queryAnalyzer) {
+              $scope.addErrors.push("Text analysis JSON should define either an 'analyzer' or an 'indexAnalyzer' and 'queryAnalyzer'");
+              return;
+            }
+            addData[command].indexAnalyzer = textJson.indexAnalyzer;
+            addData[command].queryAnalyzer = textJson.queryAnalyzer;
+          }
+        } catch (e) {
+          $scope.addErrors.push("Failed to parse analysis as JSON due to: " + e.message +
+              "; expected JSON object with either an 'analyzer' or 'indexAnalyzer' and 'queryAnalyzer'");
+          return;
+        }
+      }
+    }
+
+    if ($scope.addErrors.length > 0) {
+      return;
+    }
+    delete $scope.addErrors; // no errors!
+
+    SchemaDesigner.post({
+      path: "add",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, addData, function (data) {
+      if (data.errors) {
+        $scope.addErrors = data.errors[0].errorMessages;
+        if (typeof $scope.addErrors === "string") {
+          $scope.addErrors = [$scope.addErrors];
+        }
+      } else {
+        delete $scope.textAnalysisJson;
+        $scope.added = true;
+        $timeout(function () {
+          $scope.showAddField = false;
+          $scope.added = false;
+          var nodeId = "/";
+          if ("field" === $scope.adding) {
+            nodeId = "field/" + data[command];
+          } else if ("type" === $scope.adding) {
+            nodeId = "type/" + data[command];
+          }
+          $scope.onSchemaUpdated(data.configSet, data, nodeId);
+        }, 500);
+      }
+    }, $scope.errorHandler);
+  }
+
+  function toSortedNameAndTypeList(fields, typeAttr) {
+    var list = [];
+    var keys = Object.keys(fields);
+    for (var f in keys) {
+      var field = fields[keys[f]];
+      var type = field[typeAttr];
+      if (type) {
+        list.push(field.name + ": "+type);
+      } else {
+        list.push(field.name);
+      }
+    }
+    return list.sort();
+  }
+
+  function toSortedFieldList(fields) {
+    var list = [];
+    var keys = Object.keys(fields);
+    for (var f in keys) {
+      list.push(fields[keys[f]]);
+    }
+    return list.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  $scope.toggleDiff = function (event) {
+    if ($scope.showDiff) {
+      // toggle, close dialog
+      $scope.showDiff = false;
+      return;
+    }
+
+    if (event) {
+      var t = event.target || event.currentTarget;
+      var leftPos = t.getBoundingClientRect().left - 600;
+      if (leftPos < 0) leftPos = 0;
+      $('#show-diff-dialog').css({left: leftPos});
+    }
+
+    SchemaDesigner.get({ path: "diff", configSet: $scope.currentSchema }, function (data) {
+      var diff = data.diff;
+
+      var dynamicFields = diff.dynamicFields;
+      var enableDynamicFields = data.enableDynamicFields !== null ? data.enableDynamicFields : true;
+      if (!enableDynamicFields) {
+        dynamicFields = null;
+      }
+
+      $scope.diffSource = data["diff-source"];
+      $scope.schemaDiff = {
+        "fieldsDiff": diff.fields,
+        "addedFields": [],
+        "removedFields": [],
+        "fieldTypesDiff": diff.fieldTypes,
+        "removedTypes": [],
+        "dynamicFieldsDiff": dynamicFields,
+        "copyFieldsDiff": diff.copyFields
+      }
+      if (diff.fields && diff.fields.added) {
+        $scope.schemaDiff.addedFields = toSortedFieldList(diff.fields.added);
+      }
+      if (diff.fields && diff.fields.removed) {
+        $scope.schemaDiff.removedFields = toSortedNameAndTypeList(diff.fields.removed, "type");
+      }
+      if (diff.fieldTypes && diff.fieldTypes.removed) {
+        $scope.schemaDiff.removedTypes = toSortedNameAndTypeList(diff.fieldTypes.removed, "class");
+      }
+
+      $scope.schemaDiffExists = !(diff.fields == null && diff.fieldTypes == null && dynamicFields == null && diff.copyFields == null);
+      $scope.showDiff = true;
+    });
+  }
+
+  $scope.togglePublish = function (event) {
+    if (event) {
+      var t = event.target || event.currentTarget;
+      var leftPos = t.getBoundingClientRect().left - 515;
+      if (leftPos < 0) leftPos = 0;
+      $('#publish-dialog').css({left: leftPos});
+    }
+
+    $scope.showDiff = false;
+    $scope.showPublish = !$scope.showPublish;
+    delete $scope.publishErrors;
+
+    $scope.disableDesigner = "false";
+
+    if ($scope.showPublish && !$scope.newCollection) {
+      $scope.newCollection = {numShards: 1, replicationFactor: 1, indexToCollection: "true"};
+    }
+  };
+
+  $scope.toggleAddCopyField = function () {
+    if ($scope.showAddCopyField) {
+      $scope.hideAll();
+      $scope.showFieldDetails = true;
+    } else {
+      $scope.hideAll();
+      $scope.showAddCopyField = true;
+      $scope.showFieldDetails = false;
+
+      $scope.copyField = {};
+      delete $scope.addCopyFieldErrors;
+    }
+  }
+  $scope.addCopyField = function () {
+    delete $scope.addCopyFieldErrors;
+    var data = {"add-copy-field": $scope.copyField};
+    SchemaDesigner.post({
+      path: "add",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, data, function (data) {
+      if (data.errors) {
+        $scope.addCopyFieldErrors = data.errors[0].errorMessages;
+        if (typeof $scope.addCopyFieldErrors === "string") {
+          $scope.addCopyFieldErrors = [$scope.addCopyFieldErrors];
+        }
+      } else {
+        $scope.showAddCopyField = false;
+        // TODO:
+        //$timeout($scope.refresh, 1500);
+      }
+    }, $scope.errorHandler);
+  }
+
+  $scope.toggleAnalyzer = function (analyzer) {
+    analyzer.show = !analyzer.show;
+  }
+
+  $scope.initTypeAnalysisInfo = function (typeName) {
+    $scope.analysis = getAnalysisInfo($scope.luke, {type: true}, typeName);
+    if ($scope.analysis && $scope.analysis.data) {
+      $scope.className = $scope.analysis.data.className
+    }
+    $scope.editAnalysis = "Edit JSON";
+    $scope.showAnalysisJson = false;
+    delete $scope.analysisJsonText;
+  };
+
+  $scope.toggleVerbose = function () {
+    $scope.analysisVerbose = !$scope.analysisVerbose;
+  };
+
+  $scope.updateSampleDocId = function () {
+    $scope.indexText = "";
+    $scope.result = {};
+
+    if (!$scope.selectedNode) {
+      return;
+    }
+
+    var field = $scope.selectedNode.name;
+    var params = {path: "sample"};
+    params.configSet = $scope.currentSchema;
+    params.uniqueKeyField = $scope.uniqueKeyField;
+    params.field = field;
+
+    if ($scope.sampleDocId) {
+      params.docId = $scope.sampleDocId;
+    } // else the server will pick the first doc with a non-empty text value for the desired field
+
+    SchemaDesigner.get(params, function (data) {
+      $scope.sampleDocId = data[$scope.uniqueKeyField];
+      $scope.indexText = data[field];
+      if (data.analysis && data.analysis["field_names"]) {
+        $scope.result = processFieldAnalysisData(data.analysis["field_names"][field]);
+      }
+    });
+  };
+
+  $scope.changeLanguages = function () {
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  function getType(typeName) {
+    if ($scope.fieldTypes) {
+      for (i in $scope.fieldTypes) {
+        if ($scope.fieldTypes[i].text === typeName) {
+          return $scope.fieldTypes[i];
+        }
+      }
+    }
+    return null;
+  }
+
+  $scope.refreshTree = function() {
+    var jst = $('#schemaJsTree').jstree(true);
+    if (jst) {
+      jst.refresh();
+    }
+  };
+
+  $scope.onSchemaTreeLoaded = function (id) {
+    //console.log(">> on tree loaded");
+  };
+
+  $scope.updateFile = function () {
+    var nodeId = "files/" + $scope.selectedFile;
+    var params = {path: "file", file: $scope.selectedFile, configSet: $scope.currentSchema};
+
+    $scope.updateWorking = true;
+    $scope.updateStatusMessage = "Updating file ...";
+
+    SchemaDesigner.post(params, $scope.fileNodeText, function (data) {
+      if (data.updateFileError) {
+        if (data[$scope.selectedFile]) {
+          $scope.fileNodeText = data[$scope.selectedFile];
+        }
+        $scope.updateFileError = data.updateFileError;
+      } else {
+        delete $scope.updateFileError;
+        $scope.updateStatusMessage = "File '"+$scope.selectedFile+"' updated.";
+        $scope.onSchemaUpdated(data.configSet, data, nodeId);
+      }
+    }, $scope.errorHandler);
+  };
+
+  $scope.onSelectFileNode = function (id, doSelectOnTree) {
+    $scope.selectedFile = id.startsWith("files/") ? id.substring("files/".length) : id;
+
+    var params = {path: "file", file: $scope.selectedFile, configSet: $scope.currentSchema};
+    SchemaDesigner.get(params, function (data) {
+      $scope.fileNodeText = data[$scope.selectedFile];
+      $scope.isLeafNode = false;
+      if (doSelectOnTree) {
+        delete $scope.selectedNode;
+        $scope.isLeafNode = false;
+        $scope.showFieldDetails = true;
+        delete $scope.sampleDocId;
+        $scope.showAnalysis = false;
+        $scope.selectNodeInTree(id);
+      }
+    });
+  };
+  
+  function fieldNodes(src, type) {
+    var nodes = [];
+    for (var c in src) {
+      var childNode = src[c];
+      if (childNode && childNode.a_attr) {
+        var a = childNode.a_attr;
+        var stored = a.stored || (a.docValues && a.useDocValuesAsStored);
+        var obj = {"name":a.name, "indexed":a.indexed, "docValues": a.docValues,
+          "multiValued":a.multiValued, "stored":stored, "tokenized": a.tokenized};
+        if (type === "field" || type === "dynamicField") {
+          obj.type = a.type;
+        } else if (type === "type") {
+          obj.class = a.class;
+        }
+        nodes.push(obj);
+      }
+    }
+    return nodes;
+  }
+
+  function stripAnchorSuffix(id) {
+    if (id && id.endsWith("_anchor")) {
+      id = id.substring(0, id.length - "_anchor".length);
+    }
+    return id;
+  }
+
+  $scope.onSelectSchemaTreeNode = function (id) {
+    id = stripAnchorSuffix(id);
+    $scope.showFieldDetails = false;
+    $scope.isSchemaRoot = false;
+    $scope.isLeafNode = false;
+    delete $scope.containerNodeLabel;
+    delete $scope.containerNode;
+    delete $scope.containerNodes;
+    delete $scope.selectedFile;
+
+    if (id === "/") {
+      $scope.selectedType = "Schema";
+      $scope.selectedNode = null;
+      $scope.isSchemaRoot = true;
+      $scope.isLeafNode = false;
+      $scope.isContainerNode = false;
+      $scope.showFieldDetails = true;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+
+      if (!$scope.treeFilter) {
+        $scope.treeFilter = "type";
+        $scope.treeFilterOption = "*";
+        $scope.initTreeFilters();
+      }
+      return;
+    }
+
+    var jst = $('#schemaJsTree').jstree(true);
+    if (!jst) {
+      return;
+    }
+
+    var node = jst.get_node(id);
+    if (!node) {
+      return;
+    }
+
+    if (id === "files") {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      return;
+    }
+
+    if (id.indexOf("/") === -1) {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      $scope.containerNode = id;
+
+      if (id === "fields") {
+        $scope.containerNodes = fieldNodes($scope.fieldsNode ? $scope.fieldsNode.children : $scope.fieldsSrc, "field");
+      } else if (id === "dynamicFields") {
+        $scope.containerNodes = fieldNodes($scope.dynamicFieldsNode ? $scope.dynamicFieldsNode.children : $scope.dynamicFieldsSrc, "dynamicField");
+      } else if (id === "fieldTypes") {
+        $scope.containerNodes = fieldNodes($scope.fieldTypes, "type");
+      }
+
+      $scope.containerNodeLabel = node.text;
+      $scope.showFieldDetails = true;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+      return;
+    }
+
+    if (id.startsWith("files/")) {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+      if (node.children.length === 0) {
+        // file
+        $scope.showFieldDetails = true;
+        $scope.onSelectFileNode(id, false);
+      } else {
+        // folder
+        $scope.showFieldDetails = false;
+        delete $scope.selectedFile;
+      }
+      return;
+    }
+
+    delete $scope.selectedFile;
+    $scope.selectedNode = node["a_attr"]; // all the info we need is in the a_attr object
+    if (!$scope.selectedNode) {
+      // a node in the tree that isn't a field was selected, just ignore
+      return;
+    }
+
+    $scope.selectedNode.fieldType = getType($scope.selectedNode.type);
+    $scope.isLeafNode = true;
+
+    var nodeType = id.substring(0, id.indexOf("/"));
+    var name = null;
+    if (nodeType === "field") {
+      $scope.selectedType = "Field";
+      name = $scope.selectedNode.type;
+    } else if (nodeType === "dynamic") {
+      $scope.selectedType = "Dynamic Field";
+    } else if (nodeType === "type") {
+      $scope.selectedType = "Type";
+      name = $scope.selectedNode.name;
+    }
+
+    if (name) {
+      $scope.initTypeAnalysisInfo(name, "type");
+    }
+
+    // apply some sanity to the checkboxes
+    $scope.selectedNode = applyConstraintsOnField($scope.selectedNode);
+    $scope.showFieldDetails = true;
+
+    if (nodeType === "field" && $scope.selectedNode.tokenized && $scope.selectedNode.stored) {
+      $scope.showAnalysis = true;
+      $scope.updateSampleDocId();
+    } else {
+      $scope.showAnalysis = false;
+      $scope.indexText = "";
+      $scope.result = {};
+    }
+  };
+
+  function addFileNode(dirs, parent, f) {
+    var path = f.split("/");
+    if (path.length === 1) {
+      if (!parent.children) {
+        parent.children = [];
+        dirs.push(parent); // now parent has children, so track in dirs ...
+      }
+      var nodeId = parent.id + "/" + f;
+      parent.children.push({"text": f, "id": nodeId, "a_attr": {"href": nodeId}});
+    } else {
+      // get the parent for this path
+      var parentId = "files/" + path.slice(0, path.length - 1).join("/");
+      var dir = null;
+      for (var d in dirs) {
+        if (dirs[d].id === parentId) {
+          dir = dirs[d];
+          break;
+        }
+      }
+      if (!dir) {
+        dir = {"text": path[0], "id": parentId, "a_attr": {"href": parentId}, "children": []};
+        dirs.push(dir);
+        parent.children.push(dir);
+      }
+
+      // walk down the next level in this path
+      addFileNode(dirs, dir, path.slice(1).join("/"));
+    }
+  }
+
+  // transform a flat list structure into the nested tree structure
+  function filesToTree(files) {
+    var filesNode = {"text": "Files", "a_attr": {"href": "files"}, "id": "files", "children": []};
+    if (files) {
+      var dirs = []; // lookup for known dirs by path since nodes don't keep a ref to their parent node
+      for (var i in files) {
+        // hide the configoverlay.json from the UI
+        if (files[i] === "configoverlay.json") {
+          continue;
+        }
+
+        addFileNode(dirs, filesNode, files[i]);
+      }
+      delete dirs;
+    }
+    return filesNode;
+  }
+
+  function fieldsToTree(fields) {
+    var children = [];
+    if (fields) {
+      for (var i in fields) {
+        var id = "field/" + fields[i].name;
+        fields[i].href = id;
+        var text = fields[i].name;
+        if (fields[i].name === $scope.uniqueKeyField) {
+          text += "*"; // unique key field
+        }
+        children.push({"text": text, "a_attr": fields[i], "id": id});
+      }
+    }
+    return children;
+  }
+
+  function fieldTypesToTree(types) {
+    var children = [];
+    for (var i in types) {
+      var ft = types[i]
+      var id = "type/" + ft.name;
+      ft.href = id;
+      children.push({"text": ft.name, "a_attr": ft, "id": id});
+    }
+    return children;
+  }
+
+  $scope.onSampleDocumentsChanged = function () {
+    $scope.hasDocsOnServer = false; // so the updates get sent on next analyze action
+  };
+
+  $scope.initDesignerSettingsFromResponse = function (data) {
+    $scope.enableDynamicFields = data.enableDynamicFields !== null ? "" + data.enableDynamicFields : "true";
+    $scope.enableFieldGuessing = data.enableFieldGuessing !== null ? "" + data.enableFieldGuessing : "true";
+    $scope.enableNestedDocs = data.enableNestedDocs !== null ? "" + data.enableNestedDocs : "false";
+    $scope.languages = data.languages !== null && data.languages.length > 0 ? data.languages : ["*"];
+    $scope.copyFrom = data.copyFrom !== null ? data.copyFrom : "_default";
+  };
+
+  $scope.doAnalyze = function (nodeId) {
+    delete $scope.sampleMessage;
+
+    var schema = $scope.currentSchema;
+    if (schema) {
+      delete $scope.copyFrom;
+    } else {
+      schema = $scope.newSchema;
+      if (!$scope.copyFrom) {
+        $scope.copyFrom = "_default";
+      }
+    }
+
+    if (!schema) {
+      return;
+    }
+
+    var params = {path: "analyze", configSet: schema};
+    if ($scope.schemaVersion && $scope.schemaVersion !== -1) {
+      params.schemaVersion = $scope.schemaVersion;
+    }
+
+    if ($scope.enableDynamicFields) {
+      params.enableDynamicFields = $scope.enableDynamicFields;
+    }
+    if ($scope.enableFieldGuessing) {
+      params.enableFieldGuessing = $scope.enableFieldGuessing;
+    }
+    if ($scope.enableNestedDocs) {
+      params.enableNestedDocs = $scope.enableNestedDocs;
+    }
+
+    if ($scope.languages && $scope.languages.length > 0) {
+      params.languages = $scope.languages;
+    }
+
+    if ($scope.copyFrom) {
+      params.copyFrom = $scope.copyFrom;
+    }
+
+    $scope.updateWorking = true;
+    if ($scope.selectedUpdated) {
+      $scope.updateStatusMessage = "Applying " + $scope.selectedType + " updates ..."
+    } else {
+      $scope.updateStatusMessage = "Analyzing your sample data, schema will load momentarily ..."
+    }
+
+    if (!nodeId && $scope.selectedNode) {
+      nodeId = $scope.selectedNode.id;
+    }
+
+    // a bit tricky ...
+    // so users can upload a file or paste in docs
+    // if they upload a file containing a small (<15K) of text data, then we'll show it in the textarea
+    // they can change the text and re-analyze too
+    // if no changes or nothing uploaded, the server-side uses the latest sample data stored in the blob store
+    if ($scope.fileUpload) {
+      var file = $scope.fileUpload;
+      var fd = new FormData();
+      fd.append('file', file);
+      SchemaDesigner.upload(params, fd, function (data) {
+        $("#upload-file").val("");
+        delete $scope.fileUpload;
+        $scope.onSchemaUpdated(schema, data, nodeId);
+      }, $scope.errorHandler);
+    } else {
+      // don't need to keep re-posting the same sample if already stored in the blob store
+      var postData = null;
+      if (!$scope.hasDocsOnServer) {
+        postData = $scope.sampleDocuments;
+        if (!postData && !$scope.published) {
+          return;
+        }
+      }
+
+      var respHandler = function (data) {
+        $scope.onSchemaUpdated(schema, data, nodeId);
+      };
+
+      // TODO: need a better approach to detecting the content type from text content
+      var contentType = "text/plain";
+      if (postData != null) {
+        var txt = postData.trim();
+        if ((txt.startsWith("[") && txt.includes("]")) || (txt.startsWith("{") && txt.includes("}"))) {
+          contentType = "application/json"
+        } else if (txt.startsWith("<") || txt.includes("<add>") || txt.includes("<!--")) {
+          contentType = "text/xml";
+        } else {
+          contentType = "application/csv";
+        }
+      }
+
+      if (contentType === "text/xml") {
+        SchemaDesigner.postXml(params, postData, respHandler, $scope.errorHandler);
+      } else if (contentType === "application/csv") {
+        SchemaDesigner.postCsv(params, postData, respHandler, $scope.errorHandler);
+      } else {
+        SchemaDesigner.post(params, postData, respHandler, $scope.errorHandler);
+      }
+    }
+  };
+
+  $scope.onFieldTypeChanged = function () {
+
+    var copyFromType = ["stored", "indexed", "multiValued", "docValues", "useDocValuesAsStored", "tokenized", "uninvertible", "termVectors", "termPositions", "termOffsets",
+      "omitNorms", "omitTermFreqAndPositions", "omitPositions", "storeOffsetsWithPositions"];
+
+    var type = $scope.selectedNode.type
+
+    // when the user updates the selected field's type, we go apply the
+    // new field type's properties to the selected node
+    for (var i in $scope.fieldTypes) {
+      if ($scope.fieldTypes[i].text == type) {
+        var ft = $scope.fieldTypes[i];
+        $scope.selectedNode.fieldType = ft;
+        for (var i in copyFromType) {
+          var x = copyFromType[i];
+          if (ft.a_attr[x] !== null) {
+            $scope.selectedNode[x] = ft.a_attr[x];
+          } else {
+            delete $scope.selectedNode[x];
+          }
+        }
+        $scope.selectedUpdated = true;
+        break;
+      }
+    }
+
+    $scope.selectedNode = applyConstraintsOnField($scope.selectedNode);
+    if ($scope.selectedUpdated) {
+      // for luke analysis, we need the type info here, not the specific field into b/c it just changed.
+      $scope.initTypeAnalysisInfo(type, "type");
+    }
+  };
+
+  $scope.isDisabled = function (dep) {
+    if (!$scope.selectedNode) {
+      return false;
+    }
+
+    if (dep === "termVectors") {
+      // termVectors dependency depends on indexed
+      return !($scope.selectedNode.indexed && $scope.selectedNode.termVectors);
+    }
+
+    if (dep === "not-text" || dep === "docValues") {
+      if ($scope.selectedNode.fieldType && $scope.selectedNode.fieldType.a_attr.class === "solr.TextField") {
+        // no doc values for TextField
+        return true;
+      }
+    }
+
+    return $scope.selectedNode[dep] === false;
+  };
+
+  // this updates checkboxes based on the current settings
+  $scope.markSelectedUpdated = function (event) {
+    delete $scope.updateStatusMessage;
+    $scope.selectedUpdated = true; // enable the update button for this field
+  };
+
+  $scope.deleteSelected = function () {
+    // console.log(">> deleteSelected");
+  };
+
+  $scope.updateSelected = function () {
+    if (!$scope.selectedNode) {
+
+      if ($scope.selectedUpdated) {
+        // some root level property changed ... re-analyze
+        $scope.doAnalyze("/");
+      }
+
+      return;
+    }
+
+    delete $scope.updateSelectedError;
+
+    // make a copy for the PUT
+    var href = $scope.selectedNode.href;
+    var id = $scope.selectedNode.id;
+
+    var putData = JSON.parse(JSON.stringify($scope.selectedNode));
+    if ($scope.selectedType === "Field" && putData.copyDest) {
+      var fields = putData.copyDest.split(",");
+      for (var f in fields) {
+        var name = fields[f].trim();
+        if (!$scope.fieldNames.includes(name)) {
+          $scope.updateSelectedError = "Copy to field '" + name + "' doesn't exist!";
+          return;
+        }
+        if (name === $scope.selectedNode.name) {
+          $scope.updateSelectedError = "Cannot copy a field to itself!";
+          return;
+        }
+      }
+    } else {
+      delete putData.copyDest;
+    }
+
+    delete putData.fieldType;
+    delete putData.href;
+    delete putData.id;
+
+    putData = applyConstraintsOnField(putData);
+
+    if ($scope.analysisJsonText && !$scope.selectedNode.type) {
+      var text = $scope.analysisJsonText.trim();
+      if (text.length > 0) {
+        text = text.replace(/\s+/g, ' ');
+        if (!text.startsWith("{")) {
+          text = "{ " + text + " }";
+        }
+        try {
+          var textJson = JSON.parse(text);
+          if (textJson.analyzer) {
+            putData.analyzer = textJson.analyzer;
+          } else {
+            if (textJson.indexAnalyzer && textJson.queryAnalyzer) {
+              putData.indexAnalyzer = textJson.indexAnalyzer;
+              putData.queryAnalyzer = textJson.queryAnalyzer;
+            }
+          }
+        } catch (e) {
+          $scope.updateSelectedError = "Failed to parse analysis as JSON due to: " + e.message +
+              "; expected JSON object with either an 'analyzer' or 'indexAnalyzer' and 'queryAnalyzer'";
+          return;
+        }
+      }
+    }
+
+    $scope.updateWorking = true;
+    $scope.updateStatusMessage = "Updating " + $scope.selectedType + " ...";
+
+    SchemaDesigner.put({
+      path: "update",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, putData, function (data) {
+
+      var nodeType = data.updateType;
+      $scope.schemaVersion = data.schemaVersion;
+      $scope.currentSchema = data.configSet;
+      $scope.core = data.core;
+
+      $scope.selectedNode = data[nodeType];
+      $scope.selectedNode.href = href;
+      $scope.selectedNode.id = id;
+
+      var name = nodeType === "field" ? $scope.selectedNode.type : $scope.selectedNode.name;
+      $scope.initTypeAnalysisInfo(name, "type");
+      $scope.showFieldDetails = true;
+
+      if (nodeType === "field" && $scope.selectedNode.tokenized) {
+        $scope.showAnalysis = true;
+        $scope.updateSampleDocId();
+      }
+
+      $scope.onSchemaUpdated($scope.currentSchema, data, href);
+    }, $scope.errorHandler);
+  };
+
+  // TODO: These are copied from analysis.js, so move to a shared location for both vs. duplicating
+  var getShortComponentName = function (longname) {
+    var short = -1 !== longname.indexOf('$')
+        ? longname.split('$')[1]
+        : longname.split('.').pop();
+    return short.match(/[A-Z]/g).join('');
+  };
+
+  var getCaptionsForComponent = function (data) {
+    var captions = [];
+    for (var key in data[0]) {
+      key = key.replace(/.*#/, '');
+      if (key != "match" && key != "positionHistory") {
+        captions.push(key.replace(/.*#/, ''));
+      }
+    }
+    return captions;
+  };
+
+  var getTokensForComponent = function (data) {
+    var tokens = [];
+    var previousPosition = 0;
+    var index = 0;
+    for (var i in data) {
+      var tokenhash = data[i];
+      var positionDifference = tokenhash.position - previousPosition;
+      for (var j = positionDifference; j > 1; j--) {
+        tokens.push({position: tokenhash.position - j + 1, blank: true, index: index++});
+      }
+
+      var token = {position: tokenhash.position, keys: [], index: index++};
+
+      for (key in tokenhash) {
+        if (key == "match" || key == "positionHistory") {
+          //skip, to not display these keys in the UI
+        } else {
+          var tokenInfo = new Object();
+          tokenInfo.name = key;
+          tokenInfo.value = tokenhash[key];
+          if ('text' === key || 'raw_bytes' === key) {
+            if (tokenhash.match) {
+              tokenInfo.extraclass = 'match'; //to highlight matching text strings
+            }
+          }
+          token.keys.push(tokenInfo);
+        }
+      }
+      tokens.push(token);
+      previousPosition = tokenhash.position;
+    }
+    return tokens;
+  };
+
+  var extractComponents = function (data, result, name) {
+    if (data) {
+      result[name] = [];
+      for (var i = 0; i < data.length; i += 2) {
+        var component = {
+          name: data[i],
+          short: getShortComponentName(data[i]),
+          captions: getCaptionsForComponent(data[i + 1]),
+          tokens: getTokensForComponent(data[i + 1])
+        };
+        result[name].push(component);
+      }
+    }
+  };
+
+  var processFieldAnalysisData = function (analysis) {
+    var response = {};
+    extractComponents(analysis.index, response, "index");
+    return response;
+  };
+
+  $scope.doPublish = function () {
+    var params = {
+      path: "publish",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion,
+      reloadCollections: $scope.reloadOnPublish,
+      cleanupTemp: true,
+      disableDesigner: $scope.disableDesigner
+    };
+    if ($scope.newCollection && $scope.newCollection.name) {
+      params.newCollection = $scope.newCollection.name;
+      params.numShards = $scope.newCollection.numShards;
+      params.replicationFactor = $scope.newCollection.replicationFactor;
+      params.indexToCollection = $scope.newCollection.indexToCollection;
+    }
+    SchemaDesigner.put(params, null, function (data) {
+      $scope.schemaVersion = data.schemaVersion;
+      $scope.currentSchema = data.configSet;
+
+      delete $scope.selectedNode;
+      $scope.currentSchema = "";
+      delete $scope.newSchema;
+      $scope.showPublish = false;
+      $scope.refresh();
+
+      if (data.newCollection) {
+        $window.location.href = "#/" + data.newCollection + "/collection-overview";
+      }
+    }, $scope.errorHandler);
+  };
+
+  $scope.downloadConfig = function () {
+    // have to use an AJAX request so we can supply the Authorization header
+    if (sessionStorage.getItem("auth.header")) {
+      var fileName = $scope.currentSchema+"_configset.zip";
+      var xhr = new XMLHttpRequest();
+      xhr.open("GET", "/api/schema-designer/download/"+fileName+"?wt=raw&configSet="+$scope.currentSchema, true);
+      xhr.setRequestHeader('Authorization', sessionStorage.getItem("auth.header"));
+      xhr.responseType = 'blob';
+      xhr.addEventListener('load',function() {
+        if (xhr.status === 200) {
+          var url = window.URL.createObjectURL(xhr.response);
+          var a = document.createElement('a');
+          a.href = url;
+          a.download = fileName;
+          document.body.append(a);
+          a.click();
+          a.remove();
+          window.URL.revokeObjectURL(url);
+        }
+      })
+      xhr.send();
+    } else {
+      location.href = "/api/schema-designer/download/"+$scope.currentSchema+"_configset.zip?wt=raw&configSet=" + $scope.currentSchema;
+    }
+  };
+
+  function docsToTree(docs) {
+    var children = [];
+    for (var i in docs) {
+      var id = docs[i][$scope.uniqueKeyField];
+      if (!id) {
+        id = "" + i; // id not in results so use the position in results as the value
+      }
+      var nodeId = "doc/" + id;
+      docs[i].href = nodeId;
+      children.push({"text": id, "a_attr": docs[i], "id": nodeId});
+    }
+    return children;
+  }
+
+  function debugToTree(debugObj) {
+    var children = [];
+    for (var x in debugObj) {
+      if (typeof debugObj[x] === 'object') {
+        var obj = debugObj[x];
+        var nodeId = "debug/" + x;
+        var tdata = [];
+        for (var a in obj) {
+          if (typeof obj[a] !== 'object') {
+            tdata.push({name: a, value: obj[a]});
+          }
+        }
+        children.push({"text": x, "a_attr": {"href": nodeId}, "tdata": tdata, "id": nodeId, "children": []});
+      }
+    }
+    return children;
+  }
+
+  function facetsToTree(ff) {
+    var children = [];
+    for (var f in ff) {
+      var nodeId = "facet/" + f;
+      if (ff[f] && ff[f].length > 0) {
+        var facet = ff[f];
+        var tdata = [];
+        for (let i = 0; i < facet.length; i += 2) {
+          tdata.push({name: facet[i], value: facet[i + 1]});
+        }
+        children.push({"text": f, "a_attr": {"href": nodeId}, "tdata": tdata, "id": nodeId, "children": []});
+      }
+    }
+    return children;
+  }
+
+  function hlToTree(hl) {
+    var children = [];
+    for (var f in hl) {
+      var nodeId = "hl/" + f;
+      var tdata = [];
+      var obj = hl[f];
+      for (var a in obj) {
+        var v = obj[a];
+        if (v && v.length > 0) {
+          tdata.push({name: a, value: v[0]});
+        }
+      }
+      if (tdata.length > 0) {
+        children.push({"text": f, "a_attr": {"href": nodeId}, "tdata": tdata, "id": nodeId, "children": []});
+      }
+    }
+    return children;
+  }
+
+  $scope.selectField = function (event) {
+    var t = event.target || event.srcElement || event.currentTarget;
+    if (t && t.text) {
+      var nodeId = "field/" + t.text;
+      $scope.onSelectSchemaTreeNode(nodeId);
+      $scope.selectNodeInTree(nodeId);
+    }
+  };
+
+  $scope.selectFieldType = function (event) {
+    var t = event.target || event.srcElement || event.currentTarget;
+    if (t && t.text) {
+      var nodeId = "type/" + t.text;
+      $scope.onSelectSchemaTreeNode(nodeId);
+      $scope.selectNodeInTree(nodeId);
+    }
+  };
+
+  function omitVersionField(key,value) {
+    return (key === "_version_") ? undefined : value;
+  }
+
+  $scope.editDocuments = function() {
+    delete $scope.helpId;
+    if ($scope.queryDocs) {
+      $scope.hasDocsOnServer = false; // so the updated docs apply
+      $("#upload-file").val("");
+      delete $scope.fileUpload;
+      $scope.sampleDocuments = $scope.queryDocs;
+      $scope.onSampleDocumentsChanged();
+    }
+  };
+
+  $scope.renderResultsTree = function (data) {
+    var h = data.responseHeader;
+    var sort = h.params.sort;
+    if (!sort) {
+      sort = "score desc";
+    }
+
+    if (data.response && data.response.docs && data.response.docs.length > 0) {
+      $scope.queryDocs = JSON.stringify(data.response.docs, omitVersionField, 2);
+    } else {
+      delete $scope.queryDocs;
+    }
+
+    $scope.resultsMeta = [
+      {name: "Query", value: h.params.q},
+      {name: "QTime", value: h.QTime},
+      {name: "Hits", value: data.response.numFound},
+      {name: "sort", value: sort},
+    ];
+
+    var excParams = ["q", "handler", "debug", "configSet", "wt", "version", "_", "sort"];
+    for (var p in h.params) {
+      if (!excParams.includes(p)) {
+        $scope.resultsMeta.push({name: p, value: h.params[p]});
+      }
+    }
+
+    $scope.debugMeta = [];
+    for (var d in data.debug) {
+      if (typeof data.debug[d] !== 'object') {
+        var nvp = {name: d, value: data.debug[d]};
+        $scope.debugMeta.push(nvp);
+        $scope.resultsMeta.push(nvp);
+      }
+    }
+
+    var rootChildren = [{
+      "id":"docs",
+      "text": "Documents",
+      "state": {"opened": true},
+      "a_attr": {"href": "docs"},
+      "children": docsToTree(data.response.docs)
+    }];
+
+    if (data.facet_counts && data.facet_counts.facet_fields) {
+      rootChildren.push({
+        "id":"facets",
+        "text": "Facets",
+        "state": {"opened": true},
+        "a_attr": {"href": "facets"},
+        "children": facetsToTree(data.facet_counts.facet_fields)
+      });
+    }
+
+    if (data.highlighting) {
+      var hlNodes = hlToTree(data.highlighting);
+      if (hlNodes.length > 0) {
+        rootChildren.push({
+          "id":"hl",
+          "text": "Highlighting",
+          "state": {"opened": true},
+          "a_attr": {"href": "hl"},
+          "children": hlNodes
+        });
+      }
+    }
+
+    if (data.debug) {
+      rootChildren.push({"id":"debug", "text": "Debug", "a_attr": {"href": "debug"}, "children": debugToTree(data.debug)});
+    }
+
+    var tree = [{"id":"/","text": "Results", "a_attr": {"href": "/"}, "children": rootChildren}];
+    $scope.queryResultsTree = tree;
+  };
+
+  $scope.onSelectQueryResultsNode = function (id) {
+    $scope.selectedResultsNode = id;
+
+    if (id === "/" || id === "docs") {
+      $scope.resultsData = $scope.resultsMeta;
+      return;
+    }
+
+    if (id === "debug") {
+      $scope.resultsData = $scope.debugMeta;
+      return;
+    }
+
+    var jst = $('#queryResultsJsTree').jstree();
+    var node = jst.get_node(id);
+    if (!node || !node.a_attr) {
+      $scope.resultsData = $scope.resultsMeta;
+      return;
+    }
+
+    if (node.original && node.original.tdata) {
+      $scope.resultsData = node.original.tdata;
+    } else {
+      $scope.resultsData = [];
+      for (var a in node.a_attr) {
+        if (a === "href") continue;
+        var row = {name: a, value: node.a_attr[a]};
+        if (id.startsWith("doc/")) {
+          row.type = "f"; // so we can link to fields in the schema tree from results!
+        }
+
+        $scope.resultsData.push(row);
+      }
+    }
+
+    if (id.startsWith("doc/")) {
+      $scope.sampleDocId = id.substring(4);
+      $scope.updateSampleDocId();
+    }
+  };
+
+  $scope.doQuery = function () {
+
+    delete $scope.queryDocs;
+
+    var params = {path: "query", configSet: $scope.currentSchema, debug: "true", "wt": "json"};
+
+    if ($scope.selectedFacets && $scope.selectedFacets.length > 0) {
+      params["facet"] = true;
+      params["facet.field"] = $scope.selectedFacets;
+      params["facet.limit"] = 20;
+      params["facet.mincount"] = 1;
+    } else {
+      params["facet"] = false;
+      delete params["facet.field"];
+    }
+
+    var set = function (key, value) {
+      if (params[key]) {
+        params[key].push(value);
+      } else {
+        params[key] = [value];
+      }
+    }
+
+    params["sort"] = $scope.query.sortBy + " " + $scope.query.sortDir;
+    params["q"] = $scope.query.q.trim();
+    if (!params["q"]) {
+      params["q"] = "*:*";
+    }
+
+    if ($scope.rawParams) {
+      var rawParams = $scope.rawParams.split(/[&\n]/);
+      for (var i in rawParams) {
+        var param = rawParams[i];
+        var equalPos = param.indexOf("=");
+        if (equalPos > -1) {
+          set(param.substring(0, equalPos), param.substring(equalPos + 1));
+        } else {
+          set(param, ""); // Use empty value for params without "="
+        }
+      }
+    }
+
+    if (params["q"] !== '*:*' && $scope.query.highlight) {
+      params["hl"] = true;
+      params["hl.fl"] = $scope.query.highlight;
+      if (!params["hl.method"]) {
+        // lookup the field props
+        var method = "unified";
+        var field = $scope.fields.find(f => f.name === $scope.query.highlight);
+        if (field) {
+          if (field.termVectors && field.termOffsets && field.termPositions) {
+            method = "fastVector";
+          }
+        }
+        params["hl.method"] = method;
+      }
+    } else {
+      delete params["hl"];
+      delete params["hl.fl"];
+    }
+
+    var qt = params["qt"] ? params["qt"] : "/select";
+    if (qt[0] === '/') {
+      params.handler = qt.substring(1);
+    } else { // Support legacy style handleSelect=true configs
+      params.handler = "select";
+      params["qt"] = qt;

Review comment:
       *W069:*  ['qt'] is better written in dot notation.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/schema-designer.js
##########
@@ -0,0 +1,2014 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $cookies, $window, Constants, SchemaDesigner, Luke) {
+  $scope.resetMenu("schema-designer", Constants.IS_ROOT_PAGE);
+
+  $scope.onWarning = function (warnMsg, warnDetails) {
+    $scope.updateWorking = false;
+    delete $scope.updateStatusMessage;
+    $scope.apiWarning = warnMsg;
+    $scope.apiWarningDetails = warnDetails;
+  };
+  
+  $scope.onError = function (errorMsg, errorCode, errorDetails) {
+    $scope.updateWorking = false;
+    delete $scope.updateStatusMessage;
+    $scope.designerAPIError = errorMsg;
+    if (errorDetails) {
+      var errorDetailsStr = "";
+      if (errorDetails["error"]) {
+        errorDetailsStr = errorDetails["error"];
+      } else {
+        for (var id in errorDetails) {
+          var msg = errorDetails[id];
+          var at = msg.indexOf("ERROR: ");
+          if (at !== -1) {
+            msg = msg.substring(at+7);
+          }
+          if (!msg.includes(id)) {
+            msg = id+": "+msg;
+          }
+          errorDetailsStr += msg+"\n\n";
+        }
+      }
+      $scope.designerAPIErrorDetails = errorDetailsStr;
+    } else {
+      delete $scope.designerAPIErrorDetails;
+    }
+
+    if (errorCode === 409) {
+      $scope.schemaVersion = -1; // reset to get the latest
+      $scope.isVersionMismatch = true;
+      $scope.errorButton = "Reload Schema";
+    } else if (errorCode < 500) {
+      $scope.isVersionMismatch = false;
+      $scope.errorButton = "OK";
+    } // else 500 errors get the top-level error message
+  };
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error) {
+      $scope.onError(error.msg, error.code, e.data.errorDetails);
+    } else {
+      // when a timeout occurs, the error details are sparse so just give the user a hint that something was off
+      var path = e.config && e.config.url ? e.config.url : "/api/schema-designer";
+      var reloadMsg = "";
+      if (path.includes("/analyze")) {
+        reloadMsg = " Re-try analyzing your sample docs by clicking on 'Analyze Documents' again."
+      }
+      $scope.onError("Request to "+path+" failed!", 408,
+          {"error":"Most likely the request timed out; check server log for more details."+reloadMsg});
+    }
+  };
+
+  $scope.closeWarnDialog = function () {
+    delete $scope.apiWarning;
+    delete $scope.apiWarningDetails;
+  };
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.designerAPIError;
+    delete $scope.designerAPIErrorDetails;
+    if ($scope.isVersionMismatch) {
+      $scope.isVersionMismatch = false;
+      var nodeId = "/";
+      if ($scope.selectedNode) {
+        nodeId = $scope.selectedNode.href;
+      }
+      $scope.doAnalyze(nodeId);
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.isSchemaDesignerEnabled = true;
+
+    delete $scope.helpId;
+
+    $scope.updateStatusMessage = "";
+    $scope.analysisVerbose = false;
+    $scope.updateWorking = false;
+    $scope.currentSchema = "";
+
+    delete $scope.hasDocsOnServer;
+    delete $scope.queryResultsTree;
+
+    $scope.languages = ["*"];
+    $scope.copyFrom = "_default";
+    delete $scope.sampleMessage;
+    delete $scope.sampleDocuments;
+
+    $scope.schemaVersion = -1;
+    $scope.schemaTree = {};
+    $scope.showSchemaActions = false;
+    $scope.sampleDocIds = [];
+    $scope.isSchemaRoot = false;
+
+    delete $scope.enableNestedDocs;
+    delete $scope.enableDynamicFields;
+    delete $scope.enableFieldGuessing;
+
+    // schema editor
+    $scope.showFieldDetails = false;
+    $scope.selectedNode = null;
+    $scope.selectedUpdated = false;
+    delete $scope.updateStatusMessage;
+
+    // text field analysis
+    $scope.showAnalysis = false;
+    $scope.sampleDocId = null;
+    $scope.indexText = "";
+    $scope.result = {};
+
+    // publish vars
+    delete $scope.newCollection;
+    $scope.reloadOnPublish = "true";
+
+    // query form
+    $scope.query = {q: '*:*', sortBy: 'score', sortDir: 'desc'};
+
+    SchemaDesigner.get({path: "configs"}, function (data) {
+
+      $scope.schemas = [];
+      $scope.publishedSchemas = ["_default"];
+
+      for (var s in data.configSets) {
+        // 1 means published but not editable
+        if (data.configSets[s] !== 1) {
+          $scope.schemas.push(s);
+        }
+
+        // 0 means not published yet (so can't copy from it yet)
+        if (data.configSets[s] > 0) {
+          $scope.publishedSchemas.push(s);
+        }
+      }
+
+      $scope.schemas.sort();
+      $scope.publishedSchemas.sort();
+
+      // if no schemas available to select, open the pop-up immediately
+      if ($scope.schemas.length === 0) {
+        $scope.firstSchemaMessage = true;
+        $scope.showNewSchemaDialog();
+      }
+    }, function(e) {
+      if (e.status === 401 || e.status === 403) {
+        $scope.isSchemaDesignerEnabled = false;
+        $scope.hideAll();
+      }
+    });
+  };
+
+  $scope.selectNodeInTree = function(nodeId) {
+    nodeId = stripAnchorSuffix(nodeId);
+    if (!nodeId) return;
+
+    var jst = $('#schemaJsTree').jstree(true);
+    if (jst) {
+      var selectedId = null;
+      var selected_node = jst.get_selected();
+      if (selected_node && selected_node.length > 0) {
+        selectedId = selected_node[0];
+      }
+      if (selectedId) {
+        try {
+          jst.deselect_node(selectedId);
+        } catch (err) {
+          // just ignore
+          //console.log("error deselecting "+selectedId);
+        }
+      }
+
+      try {
+        jst.select_node(nodeId, true);
+      } catch (err) {
+        // just ignore, some low-level tree issue
+        //console.log("error selecting "+nodeId);
+      }
+    }
+  };
+
+  $scope.loadFile = function (event) {
+    var t = event.target || event.srcElement || event.currentTarget;
+    if (t && t.text) {
+      $scope.onSelectFileNode("files/" + t.text, true);
+    }
+  };
+
+  $scope.confirmEditSchema = function () {
+    $scope.showConfirmEditSchema = false;
+    if ($scope.hasDocsOnServer || $scope.published) {
+      $scope.doAnalyze();
+    } else {
+      $scope.sampleMessage = "Please upload or paste some sample documents to analyze for building the '" + $scope.currentSchema + "' schema.";
+    }
+  };
+
+  $scope.cancelEditSchema = function () {
+    $scope.currentSchema = "";
+    $scope.showConfirmEditSchema = false;
+  };
+
+  $scope.loadSchema = function () {
+
+    if (!$scope.currentSchema) {
+      return;
+    }
+
+    $scope.resetSchema();
+    var params = {path: "info", configSet: $scope.currentSchema};
+    SchemaDesigner.get(params, function (data) {
+      $scope.currentSchema = data.configSet;
+      $("#select-schema").trigger("chosen:updated");
+
+      $scope.confirmSchema = data.configSet;
+      $scope.collectionsForConfig = data.collections;
+      $scope.hasDocsOnServer = data.numDocs > 0;
+      $scope.published = data.published;
+      $scope.initDesignerSettingsFromResponse(data);
+      if ($scope.collectionsForConfig && $scope.collectionsForConfig.length > 0) {
+        $scope.showConfirmEditSchema = true;
+      } else {
+        if ($scope.hasDocsOnServer || $scope.published) {
+          $scope.doAnalyze();
+        } else {
+          $scope.sampleMessage = "Please upload or paste some sample documents to build the '" + $scope.currentSchema + "' schema.";
+        }
+      }
+    });
+  };
+
+  $scope.showNewSchemaDialog = function () {
+    $scope.hideAll();
+    $scope.showNewSchema = true;
+    $scope.newSchema = "";
+  };
+
+  $scope.addSchema = function () {
+    $scope.firstSchemaMessage = false;
+    delete $scope.addMessage;
+
+    if (!$scope.newSchema) {
+      $scope.addMessage = "Please provide a schema name!";
+      return;
+    }
+
+    $scope.newSchema = $scope.newSchema.trim();
+    if ($scope.newSchema.length > 50) {
+      $scope.addMessage = "Schema name be 50 characters or less";
+      return;
+    }
+
+    if ($scope.newSchema.indexOf(" ") !== -1 || $scope.newSchema.indexOf("/") !== -1) {
+      $scope.addMessage = "Schema name should not contain spaces or /";
+      return;
+    }
+
+    if ($scope.publishedSchemas.includes($scope.newSchema) || $scope.schemas.includes($scope.newSchema)) {
+      $scope.addMessage = "Schema '" + $scope.newSchema + "' already exists!";
+      return;
+    }
+
+    delete $scope.addMessage;
+    if (!$scope.copyFrom) {
+      $scope.copyFrom = "_default";
+    }
+
+    $scope.resetSchema();
+    $scope.schemas.push($scope.newSchema);
+    $scope.showNewSchema = false;
+    $scope.currentSchema = $scope.newSchema;
+    $scope.sampleMessage = "Please upload or paste some sample documents to analyze for building the '" + $scope.currentSchema + "' schema.";
+
+    SchemaDesigner.post({path: "prep", configSet: $scope.newSchema, copyFrom: $scope.copyFrom}, null, function (data) {
+      $scope.initDesignerSettingsFromResponse(data);
+    }, $scope.errorHandler);
+  };
+
+  $scope.cancelAddSchema = function () {
+    delete $scope.addMessage;
+    delete $scope.sampleMessage;
+
+    $scope.showNewSchema = false
+  };
+
+  $scope.hideAll = function () {
+    delete $scope.helpId;
+    $scope.showPublish = false;
+    $scope.showDiff = false;
+    $scope.showNewSchema = false;
+    $scope.showAddField = false;
+    $scope.showAddDynamicField = false;
+    $scope.showAddCopyField = false;
+    $scope.showAnalysis = false;
+    // add more dialogs here
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.hideData = function () {
+    $scope.showData = false;
+  };
+
+  $scope.rootChanged = function () {
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  $scope.updateUniqueKey = function () {
+    delete $scope.schemaRootMessage;
+    var jst = $('#schemaJsTree').jstree();
+    if (jst) {
+      var node = jst.get_node("field/" + $scope.updateUniqueKeyField);
+      if (node && node.a_attr) {
+        var attrs = node.a_attr;
+        if (attrs.multiValued || attrs.tokenized || !attrs.stored || !attrs.indexed) {
+          $scope.schemaRootMessage = "Field '" + $scope.updateUniqueKeyField +
+              "' cannot be used as the uniqueKey field! Must be single-valued, stored, indexed, and not tokenized.";
+          $scope.updateUniqueKeyField = $scope.uniqueKeyField;
+          return;
+        }
+      }
+    }
+    $scope.uniqueKeyField = $scope.updateUniqueKeyField;
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  $scope.resetSchema = function () {
+    $scope.hideAll();
+    $scope.analysisVerbose = false;
+    $scope.showSchemaActions = false;
+    $scope.showAnalysis = false;
+    $scope.showFieldDetails = false;
+    $scope.hasDocsOnServer = false;
+    $scope.query = {q: '*:*', sortBy: 'score', sortDir: 'desc'};
+    $scope.schemaVersion = -1;
+
+    $scope.updateWorking = false;
+    $scope.isVersionMismatch = false;
+    delete $scope.updateStatusMessage;
+    delete $scope.designerAPIError;
+    delete $scope.designerAPIErrorDetails;
+    delete $scope.selectedFacets;
+    delete $scope.sampleDocuments;
+    delete $scope.selectedNode;
+    delete $scope.queryResultsTree;
+  };
+
+  $scope.onSchemaUpdated = function (schema, data, nodeId) {
+    $scope.hasDocsOnServer = data.numDocs && data.numDocs > 0;
+    $scope.uniqueKeyField = data.uniqueKeyField;
+    $scope.updateUniqueKeyField = $scope.uniqueKeyField;
+    $scope.initDesignerSettingsFromResponse(data);
+
+    var fieldTypes = fieldTypesToTree(data.fieldTypes);
+    var files = filesToTree(data.files);
+
+    var rootChildren = [];
+    $scope.fieldsSrc = fieldsToTree(data.fields);
+    $scope.fieldsNode = {
+      "id": "fields",
+      "text": "Fields",
+      "state": {"opened": true},
+      "a_attr": {"href": "fields"},
+      "children": $scope.fieldsSrc
+    };
+    rootChildren.push($scope.fieldsNode);
+
+    if ($scope.enableDynamicFields === "true") {
+      $scope.dynamicFieldsSrc = fieldsToTree(data.dynamicFields);
+      $scope.dynamicFieldsNode = {
+        "id": "dynamicFields",
+        "text": "Dynamic Fields",
+        "a_attr": {"href": "dynamicFields"},
+        "children": $scope.dynamicFieldsSrc
+      };
+      rootChildren.push($scope.dynamicFieldsNode);
+    } else {
+      delete $scope.dynamicFieldsNode;
+      delete $scope.dynamicFieldsSrc;
+    }
+
+    rootChildren.push({"id":"fieldTypes", "text": "Field Types", "a_attr": {"href": "fieldTypes"}, "children": fieldTypes});
+    rootChildren.push(files);
+
+    var tree = [{"id":"/", "text": schema, "a_attr": {"href": "/"}, "state":{"opened": true}, "children": rootChildren}];
+
+    $scope.fields = data.fields;
+    $scope.fieldNames = data.fields.map(f => f.name).sort();
+    $scope.possibleIdFields = data.fields.filter(f => f.indexed && f.stored && !f.tokenized).map(f => f.name).sort();
+    $scope.sortableFields = data.fields.filter(f => (f.indexed || f.docValues) && !f.multiValued && !f.tokenized).map(f => f.name).sort();
+    $scope.sortableFields.push("score");
+
+    $scope.facetFields = data.fields.filter(f => (f.indexed || f.docValues) && !f.tokenized && f.name !== '_version_').map(f => f.name).sort();
+    $scope.hlFields = data.fields.filter(f => f.stored && f.tokenized).map(f => f.name).sort();
+
+    $scope.schemaVersion = data.schemaVersion;
+    $scope.currentSchema = data.configSet;
+    $scope.fieldTypes = fieldTypes;
+    $scope.core = data.core;
+    $scope.schemaTree = tree;
+    $scope.refreshTree();
+
+    $scope.collectionsForConfig = data.collectionsForConfig;
+
+    if (data.docIds) {
+      $scope.sampleDocIds = data.docIds;
+    }
+
+    // re-apply the filters on the updated schema
+    $scope.onTreeFilterOptionChanged();
+
+    // Load the Luke schema
+    Luke.schema({core: data.core}, function (schema) {
+      Luke.raw({core: data.core}, function (index) {
+        $scope.luke = mergeIndexAndSchemaData(index, schema.schema);
+        $scope.types = Object.keys(schema.schema.types);
+        $scope.showSchemaActions = true;
+        if (!nodeId) {
+          nodeId = "/";
+        }
+        $scope.onSelectSchemaTreeNode(nodeId);
+
+        $scope.updateWorking = false;
+
+        if (data.updateError != null) {
+          $scope.onError(data.updateError, data.updateErrorCode, data.errorDetails);
+        } else {
+          if ($scope.selectedUpdated) {
+            $scope.selectedUpdated = false;
+            $scope.updateStatusMessage = "Changes applied successfully.";
+            var waitMs = 3000;
+            if (data.rebuild) {
+              $scope.updateStatusMessage += " Did full re-index of sample docs due to incompatible update.";
+              waitMs = 5000; // longer message, more time to read
+            }
+
+            if (data.analysisError) {
+              var updateType = data["updateType"];
+              var updatedObject = data[updateType];
+              var updatedName = updatedObject && updatedObject.name ? updatedObject.name : "";
+              var warnMsg = "Changes to "+updateType+" "+updatedName+" applied, but required the temp collection to be deleted " +
+                  "and re-created due to an incompatible Lucene change, see details below.";
+              $scope.onWarning(warnMsg, data.analysisError);
+            } else {
+              $timeout(function () { delete $scope.updateStatusMessage; }, waitMs);
+            }
+          } else {
+            var source = data.sampleSource;
+            if (source) {
+              if (source === "paste") {
+                source = "pasted sample"
+              } else if (source === "blob") {
+                source = "previous upload stored on the server"
+              }
+              if (data.numDocs > 0) {
+                $scope.updateStatusMessage = "Analyzed "+data.numDocs+" docs from "+source;
+              } else {
+                $scope.updateStatusMessage = "Schema '"+$scope.currentSchema+"' loaded.";
+              }
+            }
+            $timeout(function () {
+              delete $scope.updateStatusMessage;
+            }, 5000);
+          }
+        }
+
+        // re-fire the current query to reflect the updated schema
+        $scope.doQuery();
+        $scope.selectNodeInTree(nodeId);
+      });
+    });
+  };
+
+  $scope.toggleAddField = function (type) {
+    if ($scope.showAddField) {
+      $scope.hideAll();
+    } else {
+      $scope.hideAll();
+      $scope.showAddField = true;
+      $scope.adding = type;
+
+      $scope.newField = {
+        stored: "true",
+        indexed: "true",
+        uninvertible: "true",
+        docValues: "true"
+      }
+
+      if (type === "field") {
+        $scope.newField.type = "string";
+      }
+
+      delete $scope.addErrors;
+    }
+  };
+
+  function applyConstraintsOnField(f) {
+
+    if (!f.docValues) {
+      delete f.useDocValuesAsStored;
+    }
+
+    if (!f.docValues && !f.uninvertible) {
+      delete f.sortMissingLast; // remove this setting if no docValues / uninvertible
+    }
+
+    if (f.indexed) {
+      if (f.omitTermFreqAndPositions && !f.omitPositions) {
+        delete f.omitPositions; // :shrug ~ see SchemaField ln 295
+      }
+      if (!f.termVectors) {
+        delete f.termPositions;
+        delete f.termOffsets;
+        delete f.termPayloads;
+      }
+    } else {
+      // if not indexed, a bunch of fields are false
+      f.tokenized = false;
+      f.uninvertible = false;
+
+      // drop these from the request
+      delete f.termVectors;
+      delete f.termPositions;
+      delete f.termOffsets;
+      delete f.termPayloads;
+      delete f.omitNorms;
+      delete f.omitPositions;
+      delete f.omitTermFreqAndPositions;
+      delete f.storeOffsetsWithPositions;
+    }
+
+    return f;
+  }
+
+  $scope.addField = function () {
+
+    // validate the form input
+    $scope.addErrors = [];
+    if (!$scope.newField.name) {
+      $scope.addErrors.push($scope.adding + " name is required!");
+    }
+
+    if ($scope.newField.name.indexOf(" ") != -1) {
+      $scope.addErrors.push($scope.adding + " name should not have whitespace");
+    }
+
+    var command = "add-field-type";
+    if ("field" === $scope.adding) {
+
+      if ($scope.fieldNames.includes($scope.newField.name)) {
+        $scope.addErrors.push("Field '" + $scope.newField.name + "' already exists!");
+        return;
+      }
+
+      // TODO: is this the correct logic for detecting dynamic? Probably good enough for the designer
+      var isDynamic = $scope.newField.name.startsWith("*") || $scope.newField.name.endsWith("*");
+      if (isDynamic) {
+        if ($scope.luke && $scope.luke.dynamic_fields[$scope.newField.name]) {
+          $scope.addErrors.push("dynamic field '" + $scope.newField.name + "' already exists!");
+        }
+      } else {
+        if ($scope.luke && $scope.luke.fields[$scope.newField.name]) {
+          $scope.addErrors.push("field '" + $scope.newField.name + "' already exists!");
+        }
+      }
+
+      if (!$scope.newField.type) {
+        $scope.addErrors.push("field type is required!");
+      }
+
+      command = isDynamic ? "add-dynamic-field" : "add-field";
+    } else if ("type" === $scope.adding) {
+      if ($scope.types.includes($scope.newField.name)) {
+        $scope.addErrors.push("Type '" + $scope.newField.name + "' already exists!");
+      }
+
+      if (!$scope.newField.class) {
+        $scope.addErrors.push("class is required when creating a new field type!");
+      }
+    }
+
+    var addData = {};
+    addData[command] = applyConstraintsOnField($scope.newField);
+    if ($scope.textAnalysisJson) {
+      var text = $scope.textAnalysisJson.trim();
+      if (text.length > 0) {
+        text = text.replace(/\s+/g, ' ');
+        if (!text.startsWith("{")) {
+          text = "{ " + text + " }";
+        }
+        try {
+          var textJson = JSON.parse(text);
+          if (textJson.analyzer) {
+            addData[command].analyzer = textJson.analyzer;
+          } else {
+            if (!textJson.indexAnalyzer || !textJson.queryAnalyzer) {
+              $scope.addErrors.push("Text analysis JSON should define either an 'analyzer' or an 'indexAnalyzer' and 'queryAnalyzer'");
+              return;
+            }
+            addData[command].indexAnalyzer = textJson.indexAnalyzer;
+            addData[command].queryAnalyzer = textJson.queryAnalyzer;
+          }
+        } catch (e) {
+          $scope.addErrors.push("Failed to parse analysis as JSON due to: " + e.message +
+              "; expected JSON object with either an 'analyzer' or 'indexAnalyzer' and 'queryAnalyzer'");
+          return;
+        }
+      }
+    }
+
+    if ($scope.addErrors.length > 0) {
+      return;
+    }
+    delete $scope.addErrors; // no errors!
+
+    SchemaDesigner.post({
+      path: "add",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, addData, function (data) {
+      if (data.errors) {
+        $scope.addErrors = data.errors[0].errorMessages;
+        if (typeof $scope.addErrors === "string") {
+          $scope.addErrors = [$scope.addErrors];
+        }
+      } else {
+        delete $scope.textAnalysisJson;
+        $scope.added = true;
+        $timeout(function () {
+          $scope.showAddField = false;
+          $scope.added = false;
+          var nodeId = "/";
+          if ("field" === $scope.adding) {
+            nodeId = "field/" + data[command];
+          } else if ("type" === $scope.adding) {
+            nodeId = "type/" + data[command];
+          }
+          $scope.onSchemaUpdated(data.configSet, data, nodeId);
+        }, 500);
+      }
+    }, $scope.errorHandler);
+  }
+
+  function toSortedNameAndTypeList(fields, typeAttr) {
+    var list = [];
+    var keys = Object.keys(fields);
+    for (var f in keys) {
+      var field = fields[keys[f]];
+      var type = field[typeAttr];
+      if (type) {
+        list.push(field.name + ": "+type);
+      } else {
+        list.push(field.name);
+      }
+    }
+    return list.sort();
+  }
+
+  function toSortedFieldList(fields) {
+    var list = [];
+    var keys = Object.keys(fields);
+    for (var f in keys) {
+      list.push(fields[keys[f]]);
+    }
+    return list.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  $scope.toggleDiff = function (event) {
+    if ($scope.showDiff) {
+      // toggle, close dialog
+      $scope.showDiff = false;
+      return;
+    }
+
+    if (event) {
+      var t = event.target || event.currentTarget;
+      var leftPos = t.getBoundingClientRect().left - 600;
+      if (leftPos < 0) leftPos = 0;
+      $('#show-diff-dialog').css({left: leftPos});
+    }
+
+    SchemaDesigner.get({ path: "diff", configSet: $scope.currentSchema }, function (data) {
+      var diff = data.diff;
+
+      var dynamicFields = diff.dynamicFields;
+      var enableDynamicFields = data.enableDynamicFields !== null ? data.enableDynamicFields : true;
+      if (!enableDynamicFields) {
+        dynamicFields = null;
+      }
+
+      $scope.diffSource = data["diff-source"];
+      $scope.schemaDiff = {
+        "fieldsDiff": diff.fields,
+        "addedFields": [],
+        "removedFields": [],
+        "fieldTypesDiff": diff.fieldTypes,
+        "removedTypes": [],
+        "dynamicFieldsDiff": dynamicFields,
+        "copyFieldsDiff": diff.copyFields
+      }
+      if (diff.fields && diff.fields.added) {
+        $scope.schemaDiff.addedFields = toSortedFieldList(diff.fields.added);
+      }
+      if (diff.fields && diff.fields.removed) {
+        $scope.schemaDiff.removedFields = toSortedNameAndTypeList(diff.fields.removed, "type");
+      }
+      if (diff.fieldTypes && diff.fieldTypes.removed) {
+        $scope.schemaDiff.removedTypes = toSortedNameAndTypeList(diff.fieldTypes.removed, "class");
+      }
+
+      $scope.schemaDiffExists = !(diff.fields == null && diff.fieldTypes == null && dynamicFields == null && diff.copyFields == null);
+      $scope.showDiff = true;
+    });
+  }
+
+  $scope.togglePublish = function (event) {
+    if (event) {
+      var t = event.target || event.currentTarget;
+      var leftPos = t.getBoundingClientRect().left - 515;
+      if (leftPos < 0) leftPos = 0;
+      $('#publish-dialog').css({left: leftPos});
+    }
+
+    $scope.showDiff = false;
+    $scope.showPublish = !$scope.showPublish;
+    delete $scope.publishErrors;
+
+    $scope.disableDesigner = "false";
+
+    if ($scope.showPublish && !$scope.newCollection) {
+      $scope.newCollection = {numShards: 1, replicationFactor: 1, indexToCollection: "true"};
+    }
+  };
+
+  $scope.toggleAddCopyField = function () {
+    if ($scope.showAddCopyField) {
+      $scope.hideAll();
+      $scope.showFieldDetails = true;
+    } else {
+      $scope.hideAll();
+      $scope.showAddCopyField = true;
+      $scope.showFieldDetails = false;
+
+      $scope.copyField = {};
+      delete $scope.addCopyFieldErrors;
+    }
+  }
+  $scope.addCopyField = function () {
+    delete $scope.addCopyFieldErrors;
+    var data = {"add-copy-field": $scope.copyField};
+    SchemaDesigner.post({
+      path: "add",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, data, function (data) {
+      if (data.errors) {
+        $scope.addCopyFieldErrors = data.errors[0].errorMessages;
+        if (typeof $scope.addCopyFieldErrors === "string") {
+          $scope.addCopyFieldErrors = [$scope.addCopyFieldErrors];
+        }
+      } else {
+        $scope.showAddCopyField = false;
+        // TODO:
+        //$timeout($scope.refresh, 1500);
+      }
+    }, $scope.errorHandler);
+  }
+
+  $scope.toggleAnalyzer = function (analyzer) {
+    analyzer.show = !analyzer.show;
+  }
+
+  $scope.initTypeAnalysisInfo = function (typeName) {
+    $scope.analysis = getAnalysisInfo($scope.luke, {type: true}, typeName);
+    if ($scope.analysis && $scope.analysis.data) {
+      $scope.className = $scope.analysis.data.className
+    }
+    $scope.editAnalysis = "Edit JSON";
+    $scope.showAnalysisJson = false;
+    delete $scope.analysisJsonText;
+  };
+
+  $scope.toggleVerbose = function () {
+    $scope.analysisVerbose = !$scope.analysisVerbose;
+  };
+
+  $scope.updateSampleDocId = function () {
+    $scope.indexText = "";
+    $scope.result = {};
+
+    if (!$scope.selectedNode) {
+      return;
+    }
+
+    var field = $scope.selectedNode.name;
+    var params = {path: "sample"};
+    params.configSet = $scope.currentSchema;
+    params.uniqueKeyField = $scope.uniqueKeyField;
+    params.field = field;
+
+    if ($scope.sampleDocId) {
+      params.docId = $scope.sampleDocId;
+    } // else the server will pick the first doc with a non-empty text value for the desired field
+
+    SchemaDesigner.get(params, function (data) {
+      $scope.sampleDocId = data[$scope.uniqueKeyField];
+      $scope.indexText = data[field];
+      if (data.analysis && data.analysis["field_names"]) {
+        $scope.result = processFieldAnalysisData(data.analysis["field_names"][field]);
+      }
+    });
+  };
+
+  $scope.changeLanguages = function () {
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  function getType(typeName) {
+    if ($scope.fieldTypes) {
+      for (i in $scope.fieldTypes) {
+        if ($scope.fieldTypes[i].text === typeName) {
+          return $scope.fieldTypes[i];
+        }
+      }
+    }
+    return null;
+  }
+
+  $scope.refreshTree = function() {
+    var jst = $('#schemaJsTree').jstree(true);
+    if (jst) {
+      jst.refresh();
+    }
+  };
+
+  $scope.onSchemaTreeLoaded = function (id) {
+    //console.log(">> on tree loaded");
+  };
+
+  $scope.updateFile = function () {
+    var nodeId = "files/" + $scope.selectedFile;
+    var params = {path: "file", file: $scope.selectedFile, configSet: $scope.currentSchema};
+
+    $scope.updateWorking = true;
+    $scope.updateStatusMessage = "Updating file ...";
+
+    SchemaDesigner.post(params, $scope.fileNodeText, function (data) {
+      if (data.updateFileError) {
+        if (data[$scope.selectedFile]) {
+          $scope.fileNodeText = data[$scope.selectedFile];
+        }
+        $scope.updateFileError = data.updateFileError;
+      } else {
+        delete $scope.updateFileError;
+        $scope.updateStatusMessage = "File '"+$scope.selectedFile+"' updated.";
+        $scope.onSchemaUpdated(data.configSet, data, nodeId);
+      }
+    }, $scope.errorHandler);
+  };
+
+  $scope.onSelectFileNode = function (id, doSelectOnTree) {
+    $scope.selectedFile = id.startsWith("files/") ? id.substring("files/".length) : id;
+
+    var params = {path: "file", file: $scope.selectedFile, configSet: $scope.currentSchema};
+    SchemaDesigner.get(params, function (data) {
+      $scope.fileNodeText = data[$scope.selectedFile];
+      $scope.isLeafNode = false;
+      if (doSelectOnTree) {
+        delete $scope.selectedNode;
+        $scope.isLeafNode = false;
+        $scope.showFieldDetails = true;
+        delete $scope.sampleDocId;
+        $scope.showAnalysis = false;
+        $scope.selectNodeInTree(id);
+      }
+    });
+  };
+  
+  function fieldNodes(src, type) {
+    var nodes = [];
+    for (var c in src) {
+      var childNode = src[c];
+      if (childNode && childNode.a_attr) {
+        var a = childNode.a_attr;
+        var stored = a.stored || (a.docValues && a.useDocValuesAsStored);
+        var obj = {"name":a.name, "indexed":a.indexed, "docValues": a.docValues,
+          "multiValued":a.multiValued, "stored":stored, "tokenized": a.tokenized};
+        if (type === "field" || type === "dynamicField") {
+          obj.type = a.type;
+        } else if (type === "type") {
+          obj.class = a.class;
+        }
+        nodes.push(obj);
+      }
+    }
+    return nodes;
+  }
+
+  function stripAnchorSuffix(id) {
+    if (id && id.endsWith("_anchor")) {
+      id = id.substring(0, id.length - "_anchor".length);
+    }
+    return id;
+  }
+
+  $scope.onSelectSchemaTreeNode = function (id) {
+    id = stripAnchorSuffix(id);
+    $scope.showFieldDetails = false;
+    $scope.isSchemaRoot = false;
+    $scope.isLeafNode = false;
+    delete $scope.containerNodeLabel;
+    delete $scope.containerNode;
+    delete $scope.containerNodes;
+    delete $scope.selectedFile;
+
+    if (id === "/") {
+      $scope.selectedType = "Schema";
+      $scope.selectedNode = null;
+      $scope.isSchemaRoot = true;
+      $scope.isLeafNode = false;
+      $scope.isContainerNode = false;
+      $scope.showFieldDetails = true;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+
+      if (!$scope.treeFilter) {
+        $scope.treeFilter = "type";
+        $scope.treeFilterOption = "*";
+        $scope.initTreeFilters();
+      }
+      return;
+    }
+
+    var jst = $('#schemaJsTree').jstree(true);
+    if (!jst) {
+      return;
+    }
+
+    var node = jst.get_node(id);
+    if (!node) {
+      return;
+    }
+
+    if (id === "files") {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      return;
+    }
+
+    if (id.indexOf("/") === -1) {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      $scope.containerNode = id;
+
+      if (id === "fields") {
+        $scope.containerNodes = fieldNodes($scope.fieldsNode ? $scope.fieldsNode.children : $scope.fieldsSrc, "field");
+      } else if (id === "dynamicFields") {
+        $scope.containerNodes = fieldNodes($scope.dynamicFieldsNode ? $scope.dynamicFieldsNode.children : $scope.dynamicFieldsSrc, "dynamicField");
+      } else if (id === "fieldTypes") {
+        $scope.containerNodes = fieldNodes($scope.fieldTypes, "type");
+      }
+
+      $scope.containerNodeLabel = node.text;
+      $scope.showFieldDetails = true;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+      return;
+    }
+
+    if (id.startsWith("files/")) {
+      $scope.selectedNode = null;
+      $scope.isLeafNode = false;
+      delete $scope.sampleDocId;
+      $scope.showAnalysis = false;
+      if (node.children.length === 0) {
+        // file
+        $scope.showFieldDetails = true;
+        $scope.onSelectFileNode(id, false);
+      } else {
+        // folder
+        $scope.showFieldDetails = false;
+        delete $scope.selectedFile;
+      }
+      return;
+    }
+
+    delete $scope.selectedFile;
+    $scope.selectedNode = node["a_attr"]; // all the info we need is in the a_attr object
+    if (!$scope.selectedNode) {
+      // a node in the tree that isn't a field was selected, just ignore
+      return;
+    }
+
+    $scope.selectedNode.fieldType = getType($scope.selectedNode.type);
+    $scope.isLeafNode = true;
+
+    var nodeType = id.substring(0, id.indexOf("/"));
+    var name = null;
+    if (nodeType === "field") {
+      $scope.selectedType = "Field";
+      name = $scope.selectedNode.type;
+    } else if (nodeType === "dynamic") {
+      $scope.selectedType = "Dynamic Field";
+    } else if (nodeType === "type") {
+      $scope.selectedType = "Type";
+      name = $scope.selectedNode.name;
+    }
+
+    if (name) {
+      $scope.initTypeAnalysisInfo(name, "type");
+    }
+
+    // apply some sanity to the checkboxes
+    $scope.selectedNode = applyConstraintsOnField($scope.selectedNode);
+    $scope.showFieldDetails = true;
+
+    if (nodeType === "field" && $scope.selectedNode.tokenized && $scope.selectedNode.stored) {
+      $scope.showAnalysis = true;
+      $scope.updateSampleDocId();
+    } else {
+      $scope.showAnalysis = false;
+      $scope.indexText = "";
+      $scope.result = {};
+    }
+  };
+
+  function addFileNode(dirs, parent, f) {
+    var path = f.split("/");
+    if (path.length === 1) {
+      if (!parent.children) {
+        parent.children = [];
+        dirs.push(parent); // now parent has children, so track in dirs ...
+      }
+      var nodeId = parent.id + "/" + f;
+      parent.children.push({"text": f, "id": nodeId, "a_attr": {"href": nodeId}});
+    } else {
+      // get the parent for this path
+      var parentId = "files/" + path.slice(0, path.length - 1).join("/");
+      var dir = null;
+      for (var d in dirs) {
+        if (dirs[d].id === parentId) {
+          dir = dirs[d];
+          break;
+        }
+      }
+      if (!dir) {
+        dir = {"text": path[0], "id": parentId, "a_attr": {"href": parentId}, "children": []};
+        dirs.push(dir);
+        parent.children.push(dir);
+      }
+
+      // walk down the next level in this path
+      addFileNode(dirs, dir, path.slice(1).join("/"));
+    }
+  }
+
+  // transform a flat list structure into the nested tree structure
+  function filesToTree(files) {
+    var filesNode = {"text": "Files", "a_attr": {"href": "files"}, "id": "files", "children": []};
+    if (files) {
+      var dirs = []; // lookup for known dirs by path since nodes don't keep a ref to their parent node
+      for (var i in files) {
+        // hide the configoverlay.json from the UI
+        if (files[i] === "configoverlay.json") {
+          continue;
+        }
+
+        addFileNode(dirs, filesNode, files[i]);
+      }
+      delete dirs;
+    }
+    return filesNode;
+  }
+
+  function fieldsToTree(fields) {
+    var children = [];
+    if (fields) {
+      for (var i in fields) {
+        var id = "field/" + fields[i].name;
+        fields[i].href = id;
+        var text = fields[i].name;
+        if (fields[i].name === $scope.uniqueKeyField) {
+          text += "*"; // unique key field
+        }
+        children.push({"text": text, "a_attr": fields[i], "id": id});
+      }
+    }
+    return children;
+  }
+
+  function fieldTypesToTree(types) {
+    var children = [];
+    for (var i in types) {
+      var ft = types[i]
+      var id = "type/" + ft.name;
+      ft.href = id;
+      children.push({"text": ft.name, "a_attr": ft, "id": id});
+    }
+    return children;
+  }
+
+  $scope.onSampleDocumentsChanged = function () {
+    $scope.hasDocsOnServer = false; // so the updates get sent on next analyze action
+  };
+
+  $scope.initDesignerSettingsFromResponse = function (data) {
+    $scope.enableDynamicFields = data.enableDynamicFields !== null ? "" + data.enableDynamicFields : "true";
+    $scope.enableFieldGuessing = data.enableFieldGuessing !== null ? "" + data.enableFieldGuessing : "true";
+    $scope.enableNestedDocs = data.enableNestedDocs !== null ? "" + data.enableNestedDocs : "false";
+    $scope.languages = data.languages !== null && data.languages.length > 0 ? data.languages : ["*"];
+    $scope.copyFrom = data.copyFrom !== null ? data.copyFrom : "_default";
+  };
+
+  $scope.doAnalyze = function (nodeId) {
+    delete $scope.sampleMessage;
+
+    var schema = $scope.currentSchema;
+    if (schema) {
+      delete $scope.copyFrom;
+    } else {
+      schema = $scope.newSchema;
+      if (!$scope.copyFrom) {
+        $scope.copyFrom = "_default";
+      }
+    }
+
+    if (!schema) {
+      return;
+    }
+
+    var params = {path: "analyze", configSet: schema};
+    if ($scope.schemaVersion && $scope.schemaVersion !== -1) {
+      params.schemaVersion = $scope.schemaVersion;
+    }
+
+    if ($scope.enableDynamicFields) {
+      params.enableDynamicFields = $scope.enableDynamicFields;
+    }
+    if ($scope.enableFieldGuessing) {
+      params.enableFieldGuessing = $scope.enableFieldGuessing;
+    }
+    if ($scope.enableNestedDocs) {
+      params.enableNestedDocs = $scope.enableNestedDocs;
+    }
+
+    if ($scope.languages && $scope.languages.length > 0) {
+      params.languages = $scope.languages;
+    }
+
+    if ($scope.copyFrom) {
+      params.copyFrom = $scope.copyFrom;
+    }
+
+    $scope.updateWorking = true;
+    if ($scope.selectedUpdated) {
+      $scope.updateStatusMessage = "Applying " + $scope.selectedType + " updates ..."
+    } else {
+      $scope.updateStatusMessage = "Analyzing your sample data, schema will load momentarily ..."
+    }
+
+    if (!nodeId && $scope.selectedNode) {
+      nodeId = $scope.selectedNode.id;
+    }
+
+    // a bit tricky ...
+    // so users can upload a file or paste in docs
+    // if they upload a file containing a small (<15K) of text data, then we'll show it in the textarea
+    // they can change the text and re-analyze too
+    // if no changes or nothing uploaded, the server-side uses the latest sample data stored in the blob store
+    if ($scope.fileUpload) {
+      var file = $scope.fileUpload;
+      var fd = new FormData();
+      fd.append('file', file);
+      SchemaDesigner.upload(params, fd, function (data) {
+        $("#upload-file").val("");
+        delete $scope.fileUpload;
+        $scope.onSchemaUpdated(schema, data, nodeId);
+      }, $scope.errorHandler);
+    } else {
+      // don't need to keep re-posting the same sample if already stored in the blob store
+      var postData = null;
+      if (!$scope.hasDocsOnServer) {
+        postData = $scope.sampleDocuments;
+        if (!postData && !$scope.published) {
+          return;
+        }
+      }
+
+      var respHandler = function (data) {
+        $scope.onSchemaUpdated(schema, data, nodeId);
+      };
+
+      // TODO: need a better approach to detecting the content type from text content
+      var contentType = "text/plain";
+      if (postData != null) {
+        var txt = postData.trim();
+        if ((txt.startsWith("[") && txt.includes("]")) || (txt.startsWith("{") && txt.includes("}"))) {
+          contentType = "application/json"
+        } else if (txt.startsWith("<") || txt.includes("<add>") || txt.includes("<!--")) {
+          contentType = "text/xml";
+        } else {
+          contentType = "application/csv";
+        }
+      }
+
+      if (contentType === "text/xml") {
+        SchemaDesigner.postXml(params, postData, respHandler, $scope.errorHandler);
+      } else if (contentType === "application/csv") {
+        SchemaDesigner.postCsv(params, postData, respHandler, $scope.errorHandler);
+      } else {
+        SchemaDesigner.post(params, postData, respHandler, $scope.errorHandler);
+      }
+    }
+  };
+
+  $scope.onFieldTypeChanged = function () {
+
+    var copyFromType = ["stored", "indexed", "multiValued", "docValues", "useDocValuesAsStored", "tokenized", "uninvertible", "termVectors", "termPositions", "termOffsets",
+      "omitNorms", "omitTermFreqAndPositions", "omitPositions", "storeOffsetsWithPositions"];
+
+    var type = $scope.selectedNode.type
+
+    // when the user updates the selected field's type, we go apply the
+    // new field type's properties to the selected node
+    for (var i in $scope.fieldTypes) {
+      if ($scope.fieldTypes[i].text == type) {
+        var ft = $scope.fieldTypes[i];
+        $scope.selectedNode.fieldType = ft;
+        for (var i in copyFromType) {
+          var x = copyFromType[i];
+          if (ft.a_attr[x] !== null) {
+            $scope.selectedNode[x] = ft.a_attr[x];
+          } else {
+            delete $scope.selectedNode[x];
+          }
+        }
+        $scope.selectedUpdated = true;
+        break;
+      }
+    }
+
+    $scope.selectedNode = applyConstraintsOnField($scope.selectedNode);
+    if ($scope.selectedUpdated) {
+      // for luke analysis, we need the type info here, not the specific field into b/c it just changed.
+      $scope.initTypeAnalysisInfo(type, "type");
+    }
+  };
+
+  $scope.isDisabled = function (dep) {
+    if (!$scope.selectedNode) {
+      return false;
+    }
+
+    if (dep === "termVectors") {
+      // termVectors dependency depends on indexed
+      return !($scope.selectedNode.indexed && $scope.selectedNode.termVectors);
+    }
+
+    if (dep === "not-text" || dep === "docValues") {
+      if ($scope.selectedNode.fieldType && $scope.selectedNode.fieldType.a_attr.class === "solr.TextField") {
+        // no doc values for TextField
+        return true;
+      }
+    }
+
+    return $scope.selectedNode[dep] === false;
+  };
+
+  // this updates checkboxes based on the current settings
+  $scope.markSelectedUpdated = function (event) {
+    delete $scope.updateStatusMessage;
+    $scope.selectedUpdated = true; // enable the update button for this field
+  };
+
+  $scope.deleteSelected = function () {
+    // console.log(">> deleteSelected");
+  };
+
+  $scope.updateSelected = function () {
+    if (!$scope.selectedNode) {
+
+      if ($scope.selectedUpdated) {
+        // some root level property changed ... re-analyze
+        $scope.doAnalyze("/");
+      }
+
+      return;
+    }
+
+    delete $scope.updateSelectedError;
+
+    // make a copy for the PUT
+    var href = $scope.selectedNode.href;
+    var id = $scope.selectedNode.id;
+
+    var putData = JSON.parse(JSON.stringify($scope.selectedNode));
+    if ($scope.selectedType === "Field" && putData.copyDest) {
+      var fields = putData.copyDest.split(",");
+      for (var f in fields) {
+        var name = fields[f].trim();
+        if (!$scope.fieldNames.includes(name)) {
+          $scope.updateSelectedError = "Copy to field '" + name + "' doesn't exist!";
+          return;
+        }
+        if (name === $scope.selectedNode.name) {
+          $scope.updateSelectedError = "Cannot copy a field to itself!";
+          return;
+        }
+      }
+    } else {
+      delete putData.copyDest;
+    }
+
+    delete putData.fieldType;
+    delete putData.href;
+    delete putData.id;
+
+    putData = applyConstraintsOnField(putData);
+
+    if ($scope.analysisJsonText && !$scope.selectedNode.type) {
+      var text = $scope.analysisJsonText.trim();
+      if (text.length > 0) {
+        text = text.replace(/\s+/g, ' ');
+        if (!text.startsWith("{")) {
+          text = "{ " + text + " }";
+        }
+        try {
+          var textJson = JSON.parse(text);
+          if (textJson.analyzer) {
+            putData.analyzer = textJson.analyzer;
+          } else {
+            if (textJson.indexAnalyzer && textJson.queryAnalyzer) {
+              putData.indexAnalyzer = textJson.indexAnalyzer;
+              putData.queryAnalyzer = textJson.queryAnalyzer;
+            }
+          }
+        } catch (e) {
+          $scope.updateSelectedError = "Failed to parse analysis as JSON due to: " + e.message +
+              "; expected JSON object with either an 'analyzer' or 'indexAnalyzer' and 'queryAnalyzer'";
+          return;
+        }
+      }
+    }
+
+    $scope.updateWorking = true;
+    $scope.updateStatusMessage = "Updating " + $scope.selectedType + " ...";
+
+    SchemaDesigner.put({
+      path: "update",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion
+    }, putData, function (data) {
+
+      var nodeType = data.updateType;
+      $scope.schemaVersion = data.schemaVersion;
+      $scope.currentSchema = data.configSet;
+      $scope.core = data.core;
+
+      $scope.selectedNode = data[nodeType];
+      $scope.selectedNode.href = href;
+      $scope.selectedNode.id = id;
+
+      var name = nodeType === "field" ? $scope.selectedNode.type : $scope.selectedNode.name;
+      $scope.initTypeAnalysisInfo(name, "type");
+      $scope.showFieldDetails = true;
+
+      if (nodeType === "field" && $scope.selectedNode.tokenized) {
+        $scope.showAnalysis = true;
+        $scope.updateSampleDocId();
+      }
+
+      $scope.onSchemaUpdated($scope.currentSchema, data, href);
+    }, $scope.errorHandler);
+  };
+
+  // TODO: These are copied from analysis.js, so move to a shared location for both vs. duplicating
+  var getShortComponentName = function (longname) {
+    var short = -1 !== longname.indexOf('$')
+        ? longname.split('$')[1]
+        : longname.split('.').pop();
+    return short.match(/[A-Z]/g).join('');
+  };
+
+  var getCaptionsForComponent = function (data) {
+    var captions = [];
+    for (var key in data[0]) {
+      key = key.replace(/.*#/, '');
+      if (key != "match" && key != "positionHistory") {
+        captions.push(key.replace(/.*#/, ''));
+      }
+    }
+    return captions;
+  };
+
+  var getTokensForComponent = function (data) {
+    var tokens = [];
+    var previousPosition = 0;
+    var index = 0;
+    for (var i in data) {
+      var tokenhash = data[i];
+      var positionDifference = tokenhash.position - previousPosition;
+      for (var j = positionDifference; j > 1; j--) {
+        tokens.push({position: tokenhash.position - j + 1, blank: true, index: index++});
+      }
+
+      var token = {position: tokenhash.position, keys: [], index: index++};
+
+      for (key in tokenhash) {
+        if (key == "match" || key == "positionHistory") {
+          //skip, to not display these keys in the UI
+        } else {
+          var tokenInfo = new Object();
+          tokenInfo.name = key;
+          tokenInfo.value = tokenhash[key];
+          if ('text' === key || 'raw_bytes' === key) {
+            if (tokenhash.match) {
+              tokenInfo.extraclass = 'match'; //to highlight matching text strings
+            }
+          }
+          token.keys.push(tokenInfo);
+        }
+      }
+      tokens.push(token);
+      previousPosition = tokenhash.position;
+    }
+    return tokens;
+  };
+
+  var extractComponents = function (data, result, name) {
+    if (data) {
+      result[name] = [];
+      for (var i = 0; i < data.length; i += 2) {
+        var component = {
+          name: data[i],
+          short: getShortComponentName(data[i]),
+          captions: getCaptionsForComponent(data[i + 1]),
+          tokens: getTokensForComponent(data[i + 1])
+        };
+        result[name].push(component);
+      }
+    }
+  };
+
+  var processFieldAnalysisData = function (analysis) {
+    var response = {};
+    extractComponents(analysis.index, response, "index");
+    return response;
+  };
+
+  $scope.doPublish = function () {
+    var params = {
+      path: "publish",
+      configSet: $scope.currentSchema,
+      schemaVersion: $scope.schemaVersion,
+      reloadCollections: $scope.reloadOnPublish,
+      cleanupTemp: true,
+      disableDesigner: $scope.disableDesigner
+    };
+    if ($scope.newCollection && $scope.newCollection.name) {
+      params.newCollection = $scope.newCollection.name;
+      params.numShards = $scope.newCollection.numShards;
+      params.replicationFactor = $scope.newCollection.replicationFactor;
+      params.indexToCollection = $scope.newCollection.indexToCollection;
+    }
+    SchemaDesigner.put(params, null, function (data) {
+      $scope.schemaVersion = data.schemaVersion;
+      $scope.currentSchema = data.configSet;
+
+      delete $scope.selectedNode;
+      $scope.currentSchema = "";
+      delete $scope.newSchema;
+      $scope.showPublish = false;
+      $scope.refresh();
+
+      if (data.newCollection) {
+        $window.location.href = "#/" + data.newCollection + "/collection-overview";
+      }
+    }, $scope.errorHandler);
+  };
+
+  $scope.downloadConfig = function () {
+    // have to use an AJAX request so we can supply the Authorization header
+    if (sessionStorage.getItem("auth.header")) {
+      var fileName = $scope.currentSchema+"_configset.zip";
+      var xhr = new XMLHttpRequest();
+      xhr.open("GET", "/api/schema-designer/download/"+fileName+"?wt=raw&configSet="+$scope.currentSchema, true);
+      xhr.setRequestHeader('Authorization', sessionStorage.getItem("auth.header"));
+      xhr.responseType = 'blob';
+      xhr.addEventListener('load',function() {
+        if (xhr.status === 200) {
+          var url = window.URL.createObjectURL(xhr.response);
+          var a = document.createElement('a');
+          a.href = url;
+          a.download = fileName;
+          document.body.append(a);
+          a.click();
+          a.remove();
+          window.URL.revokeObjectURL(url);
+        }
+      })
+      xhr.send();
+    } else {
+      location.href = "/api/schema-designer/download/"+$scope.currentSchema+"_configset.zip?wt=raw&configSet=" + $scope.currentSchema;
+    }
+  };
+
+  function docsToTree(docs) {
+    var children = [];
+    for (var i in docs) {
+      var id = docs[i][$scope.uniqueKeyField];
+      if (!id) {
+        id = "" + i; // id not in results so use the position in results as the value
+      }
+      var nodeId = "doc/" + id;
+      docs[i].href = nodeId;
+      children.push({"text": id, "a_attr": docs[i], "id": nodeId});
+    }
+    return children;
+  }
+
+  function debugToTree(debugObj) {
+    var children = [];
+    for (var x in debugObj) {
+      if (typeof debugObj[x] === 'object') {
+        var obj = debugObj[x];
+        var nodeId = "debug/" + x;
+        var tdata = [];
+        for (var a in obj) {
+          if (typeof obj[a] !== 'object') {
+            tdata.push({name: a, value: obj[a]});
+          }
+        }
+        children.push({"text": x, "a_attr": {"href": nodeId}, "tdata": tdata, "id": nodeId, "children": []});
+      }
+    }
+    return children;
+  }
+
+  function facetsToTree(ff) {
+    var children = [];
+    for (var f in ff) {
+      var nodeId = "facet/" + f;
+      if (ff[f] && ff[f].length > 0) {
+        var facet = ff[f];
+        var tdata = [];
+        for (let i = 0; i < facet.length; i += 2) {
+          tdata.push({name: facet[i], value: facet[i + 1]});
+        }
+        children.push({"text": f, "a_attr": {"href": nodeId}, "tdata": tdata, "id": nodeId, "children": []});
+      }
+    }
+    return children;
+  }
+
+  function hlToTree(hl) {
+    var children = [];
+    for (var f in hl) {
+      var nodeId = "hl/" + f;
+      var tdata = [];
+      var obj = hl[f];
+      for (var a in obj) {
+        var v = obj[a];
+        if (v && v.length > 0) {
+          tdata.push({name: a, value: v[0]});
+        }
+      }
+      if (tdata.length > 0) {
+        children.push({"text": f, "a_attr": {"href": nodeId}, "tdata": tdata, "id": nodeId, "children": []});
+      }
+    }
+    return children;
+  }
+
+  $scope.selectField = function (event) {
+    var t = event.target || event.srcElement || event.currentTarget;
+    if (t && t.text) {
+      var nodeId = "field/" + t.text;
+      $scope.onSelectSchemaTreeNode(nodeId);
+      $scope.selectNodeInTree(nodeId);
+    }
+  };
+
+  $scope.selectFieldType = function (event) {
+    var t = event.target || event.srcElement || event.currentTarget;
+    if (t && t.text) {
+      var nodeId = "type/" + t.text;
+      $scope.onSelectSchemaTreeNode(nodeId);
+      $scope.selectNodeInTree(nodeId);
+    }
+  };
+
+  function omitVersionField(key,value) {
+    return (key === "_version_") ? undefined : value;
+  }
+
+  $scope.editDocuments = function() {
+    delete $scope.helpId;
+    if ($scope.queryDocs) {
+      $scope.hasDocsOnServer = false; // so the updated docs apply
+      $("#upload-file").val("");
+      delete $scope.fileUpload;
+      $scope.sampleDocuments = $scope.queryDocs;
+      $scope.onSampleDocumentsChanged();
+    }
+  };
+
+  $scope.renderResultsTree = function (data) {
+    var h = data.responseHeader;
+    var sort = h.params.sort;
+    if (!sort) {
+      sort = "score desc";
+    }
+
+    if (data.response && data.response.docs && data.response.docs.length > 0) {
+      $scope.queryDocs = JSON.stringify(data.response.docs, omitVersionField, 2);
+    } else {
+      delete $scope.queryDocs;
+    }
+
+    $scope.resultsMeta = [
+      {name: "Query", value: h.params.q},
+      {name: "QTime", value: h.QTime},
+      {name: "Hits", value: data.response.numFound},
+      {name: "sort", value: sort},
+    ];
+
+    var excParams = ["q", "handler", "debug", "configSet", "wt", "version", "_", "sort"];
+    for (var p in h.params) {
+      if (!excParams.includes(p)) {
+        $scope.resultsMeta.push({name: p, value: h.params[p]});
+      }
+    }
+
+    $scope.debugMeta = [];
+    for (var d in data.debug) {
+      if (typeof data.debug[d] !== 'object') {
+        var nvp = {name: d, value: data.debug[d]};
+        $scope.debugMeta.push(nvp);
+        $scope.resultsMeta.push(nvp);
+      }
+    }
+
+    var rootChildren = [{
+      "id":"docs",
+      "text": "Documents",
+      "state": {"opened": true},
+      "a_attr": {"href": "docs"},
+      "children": docsToTree(data.response.docs)
+    }];
+
+    if (data.facet_counts && data.facet_counts.facet_fields) {
+      rootChildren.push({
+        "id":"facets",
+        "text": "Facets",
+        "state": {"opened": true},
+        "a_attr": {"href": "facets"},
+        "children": facetsToTree(data.facet_counts.facet_fields)
+      });
+    }
+
+    if (data.highlighting) {
+      var hlNodes = hlToTree(data.highlighting);
+      if (hlNodes.length > 0) {
+        rootChildren.push({
+          "id":"hl",
+          "text": "Highlighting",
+          "state": {"opened": true},
+          "a_attr": {"href": "hl"},
+          "children": hlNodes
+        });
+      }
+    }
+
+    if (data.debug) {
+      rootChildren.push({"id":"debug", "text": "Debug", "a_attr": {"href": "debug"}, "children": debugToTree(data.debug)});
+    }
+
+    var tree = [{"id":"/","text": "Results", "a_attr": {"href": "/"}, "children": rootChildren}];
+    $scope.queryResultsTree = tree;
+  };
+
+  $scope.onSelectQueryResultsNode = function (id) {
+    $scope.selectedResultsNode = id;
+
+    if (id === "/" || id === "docs") {
+      $scope.resultsData = $scope.resultsMeta;
+      return;
+    }
+
+    if (id === "debug") {
+      $scope.resultsData = $scope.debugMeta;
+      return;
+    }
+
+    var jst = $('#queryResultsJsTree').jstree();
+    var node = jst.get_node(id);
+    if (!node || !node.a_attr) {
+      $scope.resultsData = $scope.resultsMeta;
+      return;
+    }
+
+    if (node.original && node.original.tdata) {
+      $scope.resultsData = node.original.tdata;
+    } else {
+      $scope.resultsData = [];
+      for (var a in node.a_attr) {
+        if (a === "href") continue;
+        var row = {name: a, value: node.a_attr[a]};
+        if (id.startsWith("doc/")) {
+          row.type = "f"; // so we can link to fields in the schema tree from results!
+        }
+
+        $scope.resultsData.push(row);
+      }
+    }
+
+    if (id.startsWith("doc/")) {
+      $scope.sampleDocId = id.substring(4);
+      $scope.updateSampleDocId();
+    }
+  };
+
+  $scope.doQuery = function () {
+
+    delete $scope.queryDocs;
+
+    var params = {path: "query", configSet: $scope.currentSchema, debug: "true", "wt": "json"};
+
+    if ($scope.selectedFacets && $scope.selectedFacets.length > 0) {
+      params["facet"] = true;
+      params["facet.field"] = $scope.selectedFacets;
+      params["facet.limit"] = 20;
+      params["facet.mincount"] = 1;
+    } else {
+      params["facet"] = false;
+      delete params["facet.field"];
+    }
+
+    var set = function (key, value) {
+      if (params[key]) {
+        params[key].push(value);
+      } else {
+        params[key] = [value];
+      }
+    }
+
+    params["sort"] = $scope.query.sortBy + " " + $scope.query.sortDir;

Review comment:
       *W069:*  ['sort'] is better written in dot notation.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/schema-designer.js
##########
@@ -0,0 +1,2014 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $cookies, $window, Constants, SchemaDesigner, Luke) {
+  $scope.resetMenu("schema-designer", Constants.IS_ROOT_PAGE);
+
+  $scope.onWarning = function (warnMsg, warnDetails) {
+    $scope.updateWorking = false;
+    delete $scope.updateStatusMessage;
+    $scope.apiWarning = warnMsg;
+    $scope.apiWarningDetails = warnDetails;
+  };
+  
+  $scope.onError = function (errorMsg, errorCode, errorDetails) {
+    $scope.updateWorking = false;
+    delete $scope.updateStatusMessage;
+    $scope.designerAPIError = errorMsg;
+    if (errorDetails) {
+      var errorDetailsStr = "";
+      if (errorDetails["error"]) {
+        errorDetailsStr = errorDetails["error"];
+      } else {
+        for (var id in errorDetails) {
+          var msg = errorDetails[id];
+          var at = msg.indexOf("ERROR: ");
+          if (at !== -1) {
+            msg = msg.substring(at+7);
+          }
+          if (!msg.includes(id)) {
+            msg = id+": "+msg;
+          }
+          errorDetailsStr += msg+"\n\n";
+        }
+      }
+      $scope.designerAPIErrorDetails = errorDetailsStr;
+    } else {
+      delete $scope.designerAPIErrorDetails;
+    }
+
+    if (errorCode === 409) {
+      $scope.schemaVersion = -1; // reset to get the latest
+      $scope.isVersionMismatch = true;
+      $scope.errorButton = "Reload Schema";
+    } else if (errorCode < 500) {
+      $scope.isVersionMismatch = false;
+      $scope.errorButton = "OK";
+    } // else 500 errors get the top-level error message
+  };
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error) {
+      $scope.onError(error.msg, error.code, e.data.errorDetails);
+    } else {
+      // when a timeout occurs, the error details are sparse so just give the user a hint that something was off
+      var path = e.config && e.config.url ? e.config.url : "/api/schema-designer";
+      var reloadMsg = "";
+      if (path.includes("/analyze")) {
+        reloadMsg = " Re-try analyzing your sample docs by clicking on 'Analyze Documents' again."
+      }
+      $scope.onError("Request to "+path+" failed!", 408,
+          {"error":"Most likely the request timed out; check server log for more details."+reloadMsg});
+    }
+  };
+
+  $scope.closeWarnDialog = function () {
+    delete $scope.apiWarning;
+    delete $scope.apiWarningDetails;
+  };
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.designerAPIError;
+    delete $scope.designerAPIErrorDetails;
+    if ($scope.isVersionMismatch) {
+      $scope.isVersionMismatch = false;
+      var nodeId = "/";
+      if ($scope.selectedNode) {
+        nodeId = $scope.selectedNode.href;
+      }
+      $scope.doAnalyze(nodeId);
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.isSchemaDesignerEnabled = true;
+
+    delete $scope.helpId;
+
+    $scope.updateStatusMessage = "";
+    $scope.analysisVerbose = false;
+    $scope.updateWorking = false;
+    $scope.currentSchema = "";
+
+    delete $scope.hasDocsOnServer;
+    delete $scope.queryResultsTree;
+
+    $scope.languages = ["*"];
+    $scope.copyFrom = "_default";
+    delete $scope.sampleMessage;
+    delete $scope.sampleDocuments;
+
+    $scope.schemaVersion = -1;
+    $scope.schemaTree = {};
+    $scope.showSchemaActions = false;
+    $scope.sampleDocIds = [];
+    $scope.isSchemaRoot = false;
+
+    delete $scope.enableNestedDocs;
+    delete $scope.enableDynamicFields;
+    delete $scope.enableFieldGuessing;
+
+    // schema editor
+    $scope.showFieldDetails = false;
+    $scope.selectedNode = null;
+    $scope.selectedUpdated = false;
+    delete $scope.updateStatusMessage;
+
+    // text field analysis
+    $scope.showAnalysis = false;
+    $scope.sampleDocId = null;
+    $scope.indexText = "";
+    $scope.result = {};
+
+    // publish vars
+    delete $scope.newCollection;
+    $scope.reloadOnPublish = "true";
+
+    // query form
+    $scope.query = {q: '*:*', sortBy: 'score', sortDir: 'desc'};
+
+    SchemaDesigner.get({path: "configs"}, function (data) {
+
+      $scope.schemas = [];
+      $scope.publishedSchemas = ["_default"];
+
+      for (var s in data.configSets) {
+        // 1 means published but not editable
+        if (data.configSets[s] !== 1) {
+          $scope.schemas.push(s);
+        }
+
+        // 0 means not published yet (so can't copy from it yet)
+        if (data.configSets[s] > 0) {
+          $scope.publishedSchemas.push(s);
+        }
+      }
+
+      $scope.schemas.sort();
+      $scope.publishedSchemas.sort();
+
+      // if no schemas available to select, open the pop-up immediately
+      if ($scope.schemas.length === 0) {
+        $scope.firstSchemaMessage = true;
+        $scope.showNewSchemaDialog();
+      }
+    }, function(e) {
+      if (e.status === 401 || e.status === 403) {
+        $scope.isSchemaDesignerEnabled = false;
+        $scope.hideAll();
+      }
+    });
+  };
+
+  $scope.selectNodeInTree = function(nodeId) {
+    nodeId = stripAnchorSuffix(nodeId);
+    if (!nodeId) return;
+
+    var jst = $('#schemaJsTree').jstree(true);
+    if (jst) {
+      var selectedId = null;
+      var selected_node = jst.get_selected();
+      if (selected_node && selected_node.length > 0) {
+        selectedId = selected_node[0];
+      }
+      if (selectedId) {
+        try {
+          jst.deselect_node(selectedId);
+        } catch (err) {
+          // just ignore
+          //console.log("error deselecting "+selectedId);
+        }
+      }
+
+      try {
+        jst.select_node(nodeId, true);
+      } catch (err) {
+        // just ignore, some low-level tree issue
+        //console.log("error selecting "+nodeId);
+      }
+    }
+  };
+
+  $scope.loadFile = function (event) {
+    var t = event.target || event.srcElement || event.currentTarget;
+    if (t && t.text) {
+      $scope.onSelectFileNode("files/" + t.text, true);
+    }
+  };
+
+  $scope.confirmEditSchema = function () {
+    $scope.showConfirmEditSchema = false;
+    if ($scope.hasDocsOnServer || $scope.published) {
+      $scope.doAnalyze();
+    } else {
+      $scope.sampleMessage = "Please upload or paste some sample documents to analyze for building the '" + $scope.currentSchema + "' schema.";
+    }
+  };
+
+  $scope.cancelEditSchema = function () {
+    $scope.currentSchema = "";
+    $scope.showConfirmEditSchema = false;
+  };
+
+  $scope.loadSchema = function () {
+
+    if (!$scope.currentSchema) {
+      return;
+    }
+
+    $scope.resetSchema();
+    var params = {path: "info", configSet: $scope.currentSchema};
+    SchemaDesigner.get(params, function (data) {
+      $scope.currentSchema = data.configSet;
+      $("#select-schema").trigger("chosen:updated");
+
+      $scope.confirmSchema = data.configSet;
+      $scope.collectionsForConfig = data.collections;
+      $scope.hasDocsOnServer = data.numDocs > 0;
+      $scope.published = data.published;
+      $scope.initDesignerSettingsFromResponse(data);
+      if ($scope.collectionsForConfig && $scope.collectionsForConfig.length > 0) {
+        $scope.showConfirmEditSchema = true;
+      } else {
+        if ($scope.hasDocsOnServer || $scope.published) {
+          $scope.doAnalyze();
+        } else {
+          $scope.sampleMessage = "Please upload or paste some sample documents to build the '" + $scope.currentSchema + "' schema.";
+        }
+      }
+    });
+  };
+
+  $scope.showNewSchemaDialog = function () {
+    $scope.hideAll();
+    $scope.showNewSchema = true;
+    $scope.newSchema = "";
+  };
+
+  $scope.addSchema = function () {
+    $scope.firstSchemaMessage = false;
+    delete $scope.addMessage;
+
+    if (!$scope.newSchema) {
+      $scope.addMessage = "Please provide a schema name!";
+      return;
+    }
+
+    $scope.newSchema = $scope.newSchema.trim();
+    if ($scope.newSchema.length > 50) {
+      $scope.addMessage = "Schema name be 50 characters or less";
+      return;
+    }
+
+    if ($scope.newSchema.indexOf(" ") !== -1 || $scope.newSchema.indexOf("/") !== -1) {
+      $scope.addMessage = "Schema name should not contain spaces or /";
+      return;
+    }
+
+    if ($scope.publishedSchemas.includes($scope.newSchema) || $scope.schemas.includes($scope.newSchema)) {
+      $scope.addMessage = "Schema '" + $scope.newSchema + "' already exists!";
+      return;
+    }
+
+    delete $scope.addMessage;
+    if (!$scope.copyFrom) {
+      $scope.copyFrom = "_default";
+    }
+
+    $scope.resetSchema();
+    $scope.schemas.push($scope.newSchema);
+    $scope.showNewSchema = false;
+    $scope.currentSchema = $scope.newSchema;
+    $scope.sampleMessage = "Please upload or paste some sample documents to analyze for building the '" + $scope.currentSchema + "' schema.";
+
+    SchemaDesigner.post({path: "prep", configSet: $scope.newSchema, copyFrom: $scope.copyFrom}, null, function (data) {
+      $scope.initDesignerSettingsFromResponse(data);
+    }, $scope.errorHandler);
+  };
+
+  $scope.cancelAddSchema = function () {
+    delete $scope.addMessage;
+    delete $scope.sampleMessage;
+
+    $scope.showNewSchema = false
+  };
+
+  $scope.hideAll = function () {
+    delete $scope.helpId;
+    $scope.showPublish = false;
+    $scope.showDiff = false;
+    $scope.showNewSchema = false;
+    $scope.showAddField = false;
+    $scope.showAddDynamicField = false;
+    $scope.showAddCopyField = false;
+    $scope.showAnalysis = false;
+    // add more dialogs here
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.hideData = function () {
+    $scope.showData = false;
+  };
+
+  $scope.rootChanged = function () {
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  $scope.updateUniqueKey = function () {
+    delete $scope.schemaRootMessage;
+    var jst = $('#schemaJsTree').jstree();
+    if (jst) {
+      var node = jst.get_node("field/" + $scope.updateUniqueKeyField);
+      if (node && node.a_attr) {
+        var attrs = node.a_attr;
+        if (attrs.multiValued || attrs.tokenized || !attrs.stored || !attrs.indexed) {
+          $scope.schemaRootMessage = "Field '" + $scope.updateUniqueKeyField +
+              "' cannot be used as the uniqueKey field! Must be single-valued, stored, indexed, and not tokenized.";
+          $scope.updateUniqueKeyField = $scope.uniqueKeyField;
+          return;
+        }
+      }
+    }
+    $scope.uniqueKeyField = $scope.updateUniqueKeyField;
+    $scope.selectedUpdated = true;
+    $scope.selectedType = "Schema";
+  };
+
+  $scope.resetSchema = function () {
+    $scope.hideAll();
+    $scope.analysisVerbose = false;
+    $scope.showSchemaActions = false;
+    $scope.showAnalysis = false;
+    $scope.showFieldDetails = false;
+    $scope.hasDocsOnServer = false;
+    $scope.query = {q: '*:*', sortBy: 'score', sortDir: 'desc'};
+    $scope.schemaVersion = -1;
+
+    $scope.updateWorking = false;
+    $scope.isVersionMismatch = false;
+    delete $scope.updateStatusMessage;
+    delete $scope.designerAPIError;
+    delete $scope.designerAPIErrorDetails;
+    delete $scope.selectedFacets;
+    delete $scope.sampleDocuments;
+    delete $scope.selectedNode;
+    delete $scope.queryResultsTree;
+  };
+
+  $scope.onSchemaUpdated = function (schema, data, nodeId) {
+    $scope.hasDocsOnServer = data.numDocs && data.numDocs > 0;
+    $scope.uniqueKeyField = data.uniqueKeyField;
+    $scope.updateUniqueKeyField = $scope.uniqueKeyField;
+    $scope.initDesignerSettingsFromResponse(data);
+
+    var fieldTypes = fieldTypesToTree(data.fieldTypes);
+    var files = filesToTree(data.files);
+
+    var rootChildren = [];
+    $scope.fieldsSrc = fieldsToTree(data.fields);
+    $scope.fieldsNode = {
+      "id": "fields",
+      "text": "Fields",
+      "state": {"opened": true},
+      "a_attr": {"href": "fields"},
+      "children": $scope.fieldsSrc
+    };
+    rootChildren.push($scope.fieldsNode);
+
+    if ($scope.enableDynamicFields === "true") {
+      $scope.dynamicFieldsSrc = fieldsToTree(data.dynamicFields);
+      $scope.dynamicFieldsNode = {
+        "id": "dynamicFields",
+        "text": "Dynamic Fields",
+        "a_attr": {"href": "dynamicFields"},
+        "children": $scope.dynamicFieldsSrc
+      };
+      rootChildren.push($scope.dynamicFieldsNode);
+    } else {
+      delete $scope.dynamicFieldsNode;
+      delete $scope.dynamicFieldsSrc;
+    }
+
+    rootChildren.push({"id":"fieldTypes", "text": "Field Types", "a_attr": {"href": "fieldTypes"}, "children": fieldTypes});
+    rootChildren.push(files);
+
+    var tree = [{"id":"/", "text": schema, "a_attr": {"href": "/"}, "state":{"opened": true}, "children": rootChildren}];
+
+    $scope.fields = data.fields;
+    $scope.fieldNames = data.fields.map(f => f.name).sort();
+    $scope.possibleIdFields = data.fields.filter(f => f.indexed && f.stored && !f.tokenized).map(f => f.name).sort();
+    $scope.sortableFields = data.fields.filter(f => (f.indexed || f.docValues) && !f.multiValued && !f.tokenized).map(f => f.name).sort();
+    $scope.sortableFields.push("score");
+
+    $scope.facetFields = data.fields.filter(f => (f.indexed || f.docValues) && !f.tokenized && f.name !== '_version_').map(f => f.name).sort();
+    $scope.hlFields = data.fields.filter(f => f.stored && f.tokenized).map(f => f.name).sort();
+
+    $scope.schemaVersion = data.schemaVersion;
+    $scope.currentSchema = data.configSet;
+    $scope.fieldTypes = fieldTypes;
+    $scope.core = data.core;
+    $scope.schemaTree = tree;
+    $scope.refreshTree();
+
+    $scope.collectionsForConfig = data.collectionsForConfig;
+
+    if (data.docIds) {
+      $scope.sampleDocIds = data.docIds;
+    }
+
+    // re-apply the filters on the updated schema
+    $scope.onTreeFilterOptionChanged();
+
+    // Load the Luke schema
+    Luke.schema({core: data.core}, function (schema) {
+      Luke.raw({core: data.core}, function (index) {
+        $scope.luke = mergeIndexAndSchemaData(index, schema.schema);
+        $scope.types = Object.keys(schema.schema.types);
+        $scope.showSchemaActions = true;
+        if (!nodeId) {
+          nodeId = "/";
+        }
+        $scope.onSelectSchemaTreeNode(nodeId);
+
+        $scope.updateWorking = false;
+
+        if (data.updateError != null) {
+          $scope.onError(data.updateError, data.updateErrorCode, data.errorDetails);
+        } else {
+          if ($scope.selectedUpdated) {
+            $scope.selectedUpdated = false;
+            $scope.updateStatusMessage = "Changes applied successfully.";
+            var waitMs = 3000;
+            if (data.rebuild) {
+              $scope.updateStatusMessage += " Did full re-index of sample docs due to incompatible update.";
+              waitMs = 5000; // longer message, more time to read
+            }
+
+            if (data.analysisError) {
+              var updateType = data["updateType"];

Review comment:
       *W069:*  ['updateType'] is better written in dot notation.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/security.js
##########
@@ -0,0 +1,1124 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cookies, $window, Constants, System, Security) {
+  $scope.resetMenu("security", Constants.IS_ROOT_PAGE);
+
+  $scope.params = [];
+
+  var strongPasswordRegex = /^(?=.*[0-9])(?=.*[!@#$%^&*\-_()[\]])[a-zA-Z0-9!@#$%^&*\-_()[\]]{8,30}$/;
+
+  function toList(str) {
+    if (Array.isArray(str)) {
+      return str; // already a list
+    }
+    return str.trim().split(",").map(s => s.trim()).filter(s => s !== "");
+  }
+
+  function asList(listOrStr) {
+    return Array.isArray(listOrStr) ? listOrStr : (listOrStr ? [listOrStr] : []);
+  }
+
+  function transposeUserRoles(userRoles) {
+    var roleUsers = {};
+    for (var u in userRoles) {
+      var roleList = asList(userRoles[u]);
+      for (var i in roleList) {
+        var role = roleList[i];
+        if (!roleUsers[role]) roleUsers[role] = []
+        roleUsers[role].push(u);
+      }
+    }
+
+    var roles = [];
+    for (var r in roleUsers) {
+      roles.push({"name":r, "users":Array.from(new Set(roleUsers[r]))});
+    }
+    return roles.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  function roleMatch(roles, rolesForUser) {
+    for (r in rolesForUser) {
+      if (roles.includes(rolesForUser[r]))
+        return true;
+    }
+    return false;
+  }
+
+  function permRow(perm, i) {
+    var roles = asList(perm.role);
+    var paths = asList(perm.path);
+
+    var collectionNames = "";
+    var collections = [];
+    if ("collection" in perm) {
+      if (perm["collection"] == null) {
+        collectionNames = "null";
+      } else {
+        collections = asList(perm.collection);
+        collectionNames = collections.sort().join(", ");
+      }
+    } else {
+      // no collection property on the perm, so the default "*" applies
+      collectionNames = "";
+      collections.push("*");
+    }
+
+    var method = asList(perm.method);
+
+    // perms don't always have an index ?!?
+    var index = "index" in perm ? perm["index"] : ""+i;
+
+    return { "index": index, "name": perm.name, "collectionNames": collectionNames, "collections": collections,
+      "roles": roles, "paths": paths, "method": method, "params": perm.params };
+  }
+
+  function checkError(data) {
+    var cause = null;
+    if ("errorMessages" in data && Array.isArray(data["errorMessages"]) && data["errorMessages"].length > 0) {
+      cause = "?";
+      if ("errorMessages" in data["errorMessages"][0] && Array.isArray(data["errorMessages"][0]["errorMessages"]) && data["errorMessages"][0]["errorMessages"].length > 0) {
+        cause = data["errorMessages"][0]["errorMessages"][0];
+      }
+    }
+    return cause;
+  }
+
+  function truncateTo(str, maxLen, delim) {
+    // allow for a little on either side of maxLen for better display
+    var varLen = Math.min(Math.round(maxLen * 0.1), 15);
+    if (str.length <= maxLen + varLen) {
+      return str;
+    }
+
+    var total = str.split(delim).length;
+    var at = str.indexOf(delim, maxLen - varLen);
+    str = (at !== -1 && at < maxLen + varLen) ? str.substring(0, at) : str.substring(0, maxLen);
+    var trimmed = str.split(delim).length;
+    var diff = total - trimmed;
+    str += " ... "+(diff > 1 ? "(+"+diff+" more)" : "");
+    return str;
+  }
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.securityAPIError;
+    delete $scope.securityAPIErrorDetails;
+  };
+
+  $scope.displayList = function(listOrStr) {
+    if (!listOrStr) return "";
+    var str = Array.isArray(listOrStr) ? listOrStr.sort().join(", ") : (""+listOrStr).trim();
+    return truncateTo(str, 160, ", ");
+  };
+
+  $scope.displayParams = function(obj) {
+    if (!obj) return "";
+    if (Array.isArray(obj)) return obj.sort().join(", ");
+
+    var display = "";
+    for (const [key, value] of Object.entries(obj)) {
+      if (display.length > 0) display += "; ";
+      display += (key + "=" + (Array.isArray(value)?value.sort().join(","):value+""));
+    }
+    return truncateTo(display, 160, "; ");
+  };
+
+  $scope.displayRoles = function(obj) {
+    return (!obj || (Array.isArray(obj) && obj.length === 0)) ? "null" : $scope.displayList(obj);
+  };
+
+  $scope.predefinedPermissions = ["collection-admin-edit", "collection-admin-read", "core-admin-read", "core-admin-edit", "zk-read",
+    "read", "update", "all", "config-edit", "config-read", "schema-read", "schema-edit", "security-edit", "security-read",
+    "metrics-read", "filestore-read", "filestore-write", "package-edit", "package-read"].sort();
+
+  $scope.predefinedPermissionCollection = {"read":"*", "update":"*", "config-edit":"*", "config-read":"*", "schema-edit":"*", "schema-read":"*"};
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error && error.msg) {
+      $scope.securityAPIError = error.msg;
+      $scope.securityAPIErrorDetails = e.data.errorDetails;
+    } else if (e.data && e.data.message) {
+      $scope.securityAPIError = e.data.message;
+      $scope.securityAPIErrorDetails = JSON.stringify(e.data);
+    }
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.hideAll();
+
+    $scope.tls = false;
+    $scope.blockUnknown = "false"; // default setting
+    $scope.realmName = "solr";
+    $scope.forwardCredentials = "false";
+
+    $scope.currentUser = sessionStorage.getItem("auth.username");
+
+    $scope.userFilter = "";
+    $scope.userFilterOption = "";
+    $scope.userFilterText = "";
+    $scope.userFilterOptions = [];
+
+    $scope.permFilter = "";
+    $scope.permFilterOption = "";
+    $scope.permFilterOptions = [];
+    $scope.permFilterTypes = ["", "name", "role", "path", "collection"];
+
+    System.get(function(data) {
+      $scope.tls = data.security ? data.security["tls"] : false;
+      $scope.authenticationPlugin = data.security ? data.security["authenticationPlugin"] : null;

Review comment:
       *W069:*  ['authenticationPlugin'] is better written in dot notation.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/security.js
##########
@@ -0,0 +1,1124 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cookies, $window, Constants, System, Security) {
+  $scope.resetMenu("security", Constants.IS_ROOT_PAGE);
+
+  $scope.params = [];
+
+  var strongPasswordRegex = /^(?=.*[0-9])(?=.*[!@#$%^&*\-_()[\]])[a-zA-Z0-9!@#$%^&*\-_()[\]]{8,30}$/;
+
+  function toList(str) {
+    if (Array.isArray(str)) {
+      return str; // already a list
+    }
+    return str.trim().split(",").map(s => s.trim()).filter(s => s !== "");
+  }
+
+  function asList(listOrStr) {
+    return Array.isArray(listOrStr) ? listOrStr : (listOrStr ? [listOrStr] : []);
+  }
+
+  function transposeUserRoles(userRoles) {
+    var roleUsers = {};
+    for (var u in userRoles) {
+      var roleList = asList(userRoles[u]);
+      for (var i in roleList) {
+        var role = roleList[i];
+        if (!roleUsers[role]) roleUsers[role] = []
+        roleUsers[role].push(u);
+      }
+    }
+
+    var roles = [];
+    for (var r in roleUsers) {
+      roles.push({"name":r, "users":Array.from(new Set(roleUsers[r]))});
+    }
+    return roles.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  function roleMatch(roles, rolesForUser) {
+    for (r in rolesForUser) {
+      if (roles.includes(rolesForUser[r]))
+        return true;
+    }
+    return false;
+  }
+
+  function permRow(perm, i) {
+    var roles = asList(perm.role);
+    var paths = asList(perm.path);
+
+    var collectionNames = "";
+    var collections = [];
+    if ("collection" in perm) {
+      if (perm["collection"] == null) {
+        collectionNames = "null";
+      } else {
+        collections = asList(perm.collection);
+        collectionNames = collections.sort().join(", ");
+      }
+    } else {
+      // no collection property on the perm, so the default "*" applies
+      collectionNames = "";
+      collections.push("*");
+    }
+
+    var method = asList(perm.method);
+
+    // perms don't always have an index ?!?
+    var index = "index" in perm ? perm["index"] : ""+i;
+
+    return { "index": index, "name": perm.name, "collectionNames": collectionNames, "collections": collections,
+      "roles": roles, "paths": paths, "method": method, "params": perm.params };
+  }
+
+  function checkError(data) {
+    var cause = null;
+    if ("errorMessages" in data && Array.isArray(data["errorMessages"]) && data["errorMessages"].length > 0) {
+      cause = "?";
+      if ("errorMessages" in data["errorMessages"][0] && Array.isArray(data["errorMessages"][0]["errorMessages"]) && data["errorMessages"][0]["errorMessages"].length > 0) {
+        cause = data["errorMessages"][0]["errorMessages"][0];
+      }
+    }
+    return cause;
+  }
+
+  function truncateTo(str, maxLen, delim) {
+    // allow for a little on either side of maxLen for better display
+    var varLen = Math.min(Math.round(maxLen * 0.1), 15);
+    if (str.length <= maxLen + varLen) {
+      return str;
+    }
+
+    var total = str.split(delim).length;
+    var at = str.indexOf(delim, maxLen - varLen);
+    str = (at !== -1 && at < maxLen + varLen) ? str.substring(0, at) : str.substring(0, maxLen);
+    var trimmed = str.split(delim).length;
+    var diff = total - trimmed;
+    str += " ... "+(diff > 1 ? "(+"+diff+" more)" : "");
+    return str;
+  }
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.securityAPIError;
+    delete $scope.securityAPIErrorDetails;
+  };
+
+  $scope.displayList = function(listOrStr) {
+    if (!listOrStr) return "";
+    var str = Array.isArray(listOrStr) ? listOrStr.sort().join(", ") : (""+listOrStr).trim();
+    return truncateTo(str, 160, ", ");
+  };
+
+  $scope.displayParams = function(obj) {
+    if (!obj) return "";
+    if (Array.isArray(obj)) return obj.sort().join(", ");
+
+    var display = "";
+    for (const [key, value] of Object.entries(obj)) {
+      if (display.length > 0) display += "; ";
+      display += (key + "=" + (Array.isArray(value)?value.sort().join(","):value+""));
+    }
+    return truncateTo(display, 160, "; ");
+  };
+
+  $scope.displayRoles = function(obj) {
+    return (!obj || (Array.isArray(obj) && obj.length === 0)) ? "null" : $scope.displayList(obj);
+  };
+
+  $scope.predefinedPermissions = ["collection-admin-edit", "collection-admin-read", "core-admin-read", "core-admin-edit", "zk-read",
+    "read", "update", "all", "config-edit", "config-read", "schema-read", "schema-edit", "security-edit", "security-read",
+    "metrics-read", "filestore-read", "filestore-write", "package-edit", "package-read"].sort();
+
+  $scope.predefinedPermissionCollection = {"read":"*", "update":"*", "config-edit":"*", "config-read":"*", "schema-edit":"*", "schema-read":"*"};
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error && error.msg) {
+      $scope.securityAPIError = error.msg;
+      $scope.securityAPIErrorDetails = e.data.errorDetails;
+    } else if (e.data && e.data.message) {
+      $scope.securityAPIError = e.data.message;
+      $scope.securityAPIErrorDetails = JSON.stringify(e.data);
+    }
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.hideAll();
+
+    $scope.tls = false;
+    $scope.blockUnknown = "false"; // default setting
+    $scope.realmName = "solr";
+    $scope.forwardCredentials = "false";
+
+    $scope.currentUser = sessionStorage.getItem("auth.username");
+
+    $scope.userFilter = "";
+    $scope.userFilterOption = "";
+    $scope.userFilterText = "";
+    $scope.userFilterOptions = [];
+
+    $scope.permFilter = "";
+    $scope.permFilterOption = "";
+    $scope.permFilterOptions = [];
+    $scope.permFilterTypes = ["", "name", "role", "path", "collection"];
+
+    System.get(function(data) {
+      $scope.tls = data.security ? data.security["tls"] : false;
+      $scope.authenticationPlugin = data.security ? data.security["authenticationPlugin"] : null;
+      $scope.authorizationPlugin = data.security ? data.security["authorizationPlugin"] : null;

Review comment:
       *W069:*  ['authorizationPlugin'] is better written in dot notation.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/security.js
##########
@@ -0,0 +1,1124 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cookies, $window, Constants, System, Security) {
+  $scope.resetMenu("security", Constants.IS_ROOT_PAGE);
+
+  $scope.params = [];
+
+  var strongPasswordRegex = /^(?=.*[0-9])(?=.*[!@#$%^&*\-_()[\]])[a-zA-Z0-9!@#$%^&*\-_()[\]]{8,30}$/;
+
+  function toList(str) {
+    if (Array.isArray(str)) {
+      return str; // already a list
+    }
+    return str.trim().split(",").map(s => s.trim()).filter(s => s !== "");
+  }
+
+  function asList(listOrStr) {
+    return Array.isArray(listOrStr) ? listOrStr : (listOrStr ? [listOrStr] : []);
+  }
+
+  function transposeUserRoles(userRoles) {
+    var roleUsers = {};
+    for (var u in userRoles) {
+      var roleList = asList(userRoles[u]);
+      for (var i in roleList) {
+        var role = roleList[i];
+        if (!roleUsers[role]) roleUsers[role] = []
+        roleUsers[role].push(u);
+      }
+    }
+
+    var roles = [];
+    for (var r in roleUsers) {
+      roles.push({"name":r, "users":Array.from(new Set(roleUsers[r]))});
+    }
+    return roles.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  function roleMatch(roles, rolesForUser) {
+    for (r in rolesForUser) {
+      if (roles.includes(rolesForUser[r]))
+        return true;
+    }
+    return false;
+  }
+
+  function permRow(perm, i) {
+    var roles = asList(perm.role);
+    var paths = asList(perm.path);
+
+    var collectionNames = "";
+    var collections = [];
+    if ("collection" in perm) {
+      if (perm["collection"] == null) {
+        collectionNames = "null";
+      } else {
+        collections = asList(perm.collection);
+        collectionNames = collections.sort().join(", ");
+      }
+    } else {
+      // no collection property on the perm, so the default "*" applies
+      collectionNames = "";
+      collections.push("*");
+    }
+
+    var method = asList(perm.method);
+
+    // perms don't always have an index ?!?
+    var index = "index" in perm ? perm["index"] : ""+i;
+
+    return { "index": index, "name": perm.name, "collectionNames": collectionNames, "collections": collections,
+      "roles": roles, "paths": paths, "method": method, "params": perm.params };
+  }
+
+  function checkError(data) {
+    var cause = null;
+    if ("errorMessages" in data && Array.isArray(data["errorMessages"]) && data["errorMessages"].length > 0) {
+      cause = "?";
+      if ("errorMessages" in data["errorMessages"][0] && Array.isArray(data["errorMessages"][0]["errorMessages"]) && data["errorMessages"][0]["errorMessages"].length > 0) {
+        cause = data["errorMessages"][0]["errorMessages"][0];
+      }
+    }
+    return cause;
+  }
+
+  function truncateTo(str, maxLen, delim) {
+    // allow for a little on either side of maxLen for better display
+    var varLen = Math.min(Math.round(maxLen * 0.1), 15);
+    if (str.length <= maxLen + varLen) {
+      return str;
+    }
+
+    var total = str.split(delim).length;
+    var at = str.indexOf(delim, maxLen - varLen);
+    str = (at !== -1 && at < maxLen + varLen) ? str.substring(0, at) : str.substring(0, maxLen);
+    var trimmed = str.split(delim).length;
+    var diff = total - trimmed;
+    str += " ... "+(diff > 1 ? "(+"+diff+" more)" : "");
+    return str;
+  }
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.securityAPIError;
+    delete $scope.securityAPIErrorDetails;
+  };
+
+  $scope.displayList = function(listOrStr) {
+    if (!listOrStr) return "";
+    var str = Array.isArray(listOrStr) ? listOrStr.sort().join(", ") : (""+listOrStr).trim();
+    return truncateTo(str, 160, ", ");
+  };
+
+  $scope.displayParams = function(obj) {
+    if (!obj) return "";
+    if (Array.isArray(obj)) return obj.sort().join(", ");
+
+    var display = "";
+    for (const [key, value] of Object.entries(obj)) {
+      if (display.length > 0) display += "; ";
+      display += (key + "=" + (Array.isArray(value)?value.sort().join(","):value+""));
+    }
+    return truncateTo(display, 160, "; ");
+  };
+
+  $scope.displayRoles = function(obj) {
+    return (!obj || (Array.isArray(obj) && obj.length === 0)) ? "null" : $scope.displayList(obj);
+  };
+
+  $scope.predefinedPermissions = ["collection-admin-edit", "collection-admin-read", "core-admin-read", "core-admin-edit", "zk-read",
+    "read", "update", "all", "config-edit", "config-read", "schema-read", "schema-edit", "security-edit", "security-read",
+    "metrics-read", "filestore-read", "filestore-write", "package-edit", "package-read"].sort();
+
+  $scope.predefinedPermissionCollection = {"read":"*", "update":"*", "config-edit":"*", "config-read":"*", "schema-edit":"*", "schema-read":"*"};
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error && error.msg) {
+      $scope.securityAPIError = error.msg;
+      $scope.securityAPIErrorDetails = e.data.errorDetails;
+    } else if (e.data && e.data.message) {
+      $scope.securityAPIError = e.data.message;
+      $scope.securityAPIErrorDetails = JSON.stringify(e.data);
+    }
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.hideAll();
+
+    $scope.tls = false;
+    $scope.blockUnknown = "false"; // default setting
+    $scope.realmName = "solr";
+    $scope.forwardCredentials = "false";
+
+    $scope.currentUser = sessionStorage.getItem("auth.username");
+
+    $scope.userFilter = "";
+    $scope.userFilterOption = "";
+    $scope.userFilterText = "";
+    $scope.userFilterOptions = [];
+
+    $scope.permFilter = "";
+    $scope.permFilterOption = "";
+    $scope.permFilterOptions = [];
+    $scope.permFilterTypes = ["", "name", "role", "path", "collection"];
+
+    System.get(function(data) {
+      $scope.tls = data.security ? data.security["tls"] : false;
+      $scope.authenticationPlugin = data.security ? data.security["authenticationPlugin"] : null;
+      $scope.authorizationPlugin = data.security ? data.security["authorizationPlugin"] : null;
+      $scope.myRoles = data.security ? data.security["roles"] : [];
+      $scope.isSecurityAdminEnabled = $scope.authenticationPlugin != null;
+      $scope.isCloudMode = data.mode.match( /solrcloud/i ) != null;
+      $scope.zkHost = $scope.isCloudMode ? data["zkHost"] : "";
+      $scope.solrHome = data["solr_home"];
+      $scope.refreshSecurityPanel();
+    }, function(e) {
+      if (e.status === 401 || e.status === 403) {
+        $scope.isSecurityAdminEnabled = true;
+        $scope.hasSecurityEditPerm = false;
+        $scope.hideAll();
+      }
+    });
+  };
+
+  $scope.hideAll = function () {
+    // add more dialogs here
+    delete $scope.validationError;
+    $scope.showUserDialog = false;
+    $scope.showPermDialog = false;
+    delete $scope.helpId;
+  };
+
+  $scope.getCurrentUserRoles = function() {
+    if ($scope.manageUserRolesEnabled) {
+      return Array.isArray($scope.userRoles[$scope.currentUser]) ? $scope.userRoles[$scope.currentUser] : [$scope.userRoles[$scope.currentUser]];
+    } else {
+      return $scope.myRoles;
+    }
+  };
+
+  $scope.hasPermission = function(permissionName) {
+    var rolesForPermission = $scope.permissionsTable.filter(p => permissionName === p.name).flatMap(p => p.roles);
+    return (rolesForPermission.length > 0 && roleMatch(rolesForPermission, $scope.getCurrentUserRoles()));
+  };
+
+  $scope.refreshSecurityPanel = function() {
+
+    // determine if the authorization plugin supports CRUD permissions
+    $scope.managePermissionsEnabled =
+        ($scope.authorizationPlugin === "org.apache.solr.security.RuleBasedAuthorizationPlugin" ||
+            $scope.authorizationPlugin === "org.apache.solr.security.ExternalRoleRuleBasedAuthorizationPlugin");
+
+    // don't allow CRUD on roles if using external
+    $scope.manageUserRolesEnabled = $scope.authorizationPlugin === "org.apache.solr.security.RuleBasedAuthorizationPlugin";
+
+    Security.get({path: "authorization"}, function (data) {
+      if (!data.authorization) {
+        $scope.isSecurityAdminEnabled = false;
+        $scope.hasSecurityEditPerm = false;
+        return;
+      }
+
+      if ($scope.manageUserRolesEnabled) {
+        $scope.userRoles = data.authorization["user-role"];
+        $scope.roles = transposeUserRoles($scope.userRoles);
+        $scope.filteredRoles = $scope.roles;
+        $scope.roleNames = $scope.roles.map(r => r.name).sort();
+        $scope.roleNamesWithWildcard = ["*"].concat($scope.roleNames);
+        if (!$scope.permFilterTypes.includes("user")) {
+          $scope.permFilterTypes.push("user"); // can only filter perms by user if we have a role to user mapping
+        }
+      } else {
+        $scope.userRoles = {};
+        $scope.roles = [];
+        $scope.filteredRoles = [];
+        $scope.roleNames = [];
+      }
+
+      $scope.permissions = data.authorization["permissions"];
+      $scope.permissionsTable = [];
+      for (p in $scope.permissions) {
+        $scope.permissionsTable.push(permRow($scope.permissions[p], parseInt(p)+1));
+      }
+      $scope.filteredPerms = $scope.permissionsTable;
+
+      $scope.hasSecurityEditPerm = $scope.hasPermission("security-edit");
+      $scope.hasSecurityReadPerm = $scope.hasSecurityEditPerm || $scope.hasPermission("security-read");
+
+      if ($scope.authenticationPlugin === "org.apache.solr.security.BasicAuthPlugin") {
+        $scope.manageUsersEnabled = true;
+
+        Security.get({path: "authentication"}, function (data) {
+          if (!data.authentication) {
+            // TODO: error msg
+            $scope.manageUsersEnabled = false;
+          }
+
+          $scope.blockUnknown = data.authentication["blockUnknown"] === true ? "true" : "false";
+          $scope.forwardCredentials = data.authentication["forwardCredentials"] === true ? "true" : "false";
+
+          if ("realm" in data.authentication) {
+            $scope.realmName = data.authentication["realm"];
+          }
+
+          var users = [];
+          if (data.authentication.credentials) {
+            for (var u in data.authentication.credentials) {
+              var roles = $scope.userRoles[u];
+              if (!roles) roles = [];
+              users.push({"username":u, "roles":roles});
+            }
+          }
+          $scope.users = users.sort((a, b) => (a.username > b.username) ? 1 : -1);
+          $scope.filteredUsers = $scope.users.slice(0,100); // only display first 100
+        }, $scope.errorHandler);
+      } else {
+        $scope.users = [];
+        $scope.filteredUsers = $scope.users;
+        $scope.manageUsersEnabled = false;
+      }
+    }, $scope.errorHandler);
+  };
+
+  $scope.validatePassword = function() {
+    var password = $scope.upsertUser.password.trim();
+    var password2 = $scope.upsertUser.password2 ? $scope.upsertUser.password2.trim() : "";
+    if (password !== password2) {
+      $scope.validationError = "Passwords do not match!";
+      return false;
+    }
+
+    if (!password.match(strongPasswordRegex)) {
+      $scope.validationError = "Password not strong enough! Must contain at least one lowercase letter, one uppercase letter, one digit, and one of these special characters: !@#$%^&*_-[]()";
+      return false;
+    }
+
+    return true;
+  };
+
+  $scope.updateUserRoles = function() {
+    var setUserRoles = {};
+    var roles = [];
+    if ($scope.upsertUser.selectedRoles) {
+      roles = roles.concat($scope.upsertUser.selectedRoles);
+    }
+    if ($scope.upsertUser.newRole && $scope.upsertUser.newRole.trim() !== "") {
+      var newRole = $scope.upsertUser.newRole.trim();
+      if (newRole !== "null" && newRole !== "*" && newRole.length <= 30) {
+        roles.push(newRole);
+      } // else, no new role for you!
+    }
+    var userRoles = Array.from(new Set(roles));
+    setUserRoles[$scope.upsertUser.username] = userRoles.length > 0 ? userRoles : null;
+    Security.post({path: "authorization"}, { "set-user-role": setUserRoles }, function (data) {
+      $scope.toggleUserDialog();
+      $scope.refreshSecurityPanel();
+    });
+  };
+
+  $scope.doUpsertUser = function() {
+    if (!$scope.upsertUser) {
+      delete $scope.validationError;
+      $scope.showUserDialog = false;
+      return;
+    }
+
+    if (!$scope.upsertUser.username || $scope.upsertUser.username.trim() === "") {
+      $scope.validationError = "Username is required!";
+      return;
+    }
+
+    // keep username to a reasonable length? but allow for email addresses
+    var username = $scope.upsertUser.username.trim();
+    if (username.length > 30) {
+      $scope.validationError = "Username must be 30 characters or less!";
+      return;
+    }
+
+    var doSetUser = false;
+    if ($scope.userDialogMode === 'add') {
+      if ($scope.users) {
+        var existing = $scope.users.find(u => u.username === username);
+        if (existing) {
+          $scope.validationError = "User '"+username+"' already exists!";
+          return;
+        }
+      }
+
+      if (!$scope.upsertUser.password) {
+        $scope.validationError = "Password is required!";
+        return;
+      }
+
+      if (!$scope.validatePassword()) {
+        return;
+      }
+      doSetUser = true;
+    } else {
+      if ($scope.upsertUser.password) {
+        if ($scope.validatePassword()) {
+          doSetUser = true;
+        } else {
+          return; // update to password is invalid
+        }
+      } // else no update to password
+    }
+
+    if ($scope.upsertUser.newRole && $scope.upsertUser.newRole.trim() !== "") {
+      var newRole = $scope.upsertUser.newRole.trim();
+      if (newRole === "null" || newRole === "*" || newRole.length > 30) {
+        $scope.validationError = "Invalid new role: "+newRole;
+        return;
+      }
+    }
+
+    delete $scope.validationError;
+
+    if (doSetUser) {
+      var setUserJson = {};
+      setUserJson[username] = $scope.upsertUser.password.trim();
+      Security.post({path: "authentication"}, { "set-user": setUserJson }, function (data) {
+
+        var errorCause = checkError(data);
+        if (errorCause != null) {
+          $scope.securityAPIError = "create user "+username+" failed due to: "+errorCause;
+          $scope.securityAPIErrorDetails = JSON.stringify(data);
+          return;
+        }
+
+        $scope.updateUserRoles();
+      });
+    } else {
+      $scope.updateUserRoles();
+    }
+  };
+
+  $scope.confirmDeleteUser = function() {
+    if (window.confirm("Confirm delete the '"+$scope.upsertUser.username+"' user?")) {
+      // remove all roles for the user and the delete the user
+      var removeRoles = {};
+      removeRoles[$scope.upsertUser.username] = null;
+      Security.post({path: "authorization"}, { "set-user-role": removeRoles }, function (data) {
+        Security.post({path: "authentication"}, {"delete-user": [$scope.upsertUser.username]}, function (data2) {
+          $scope.toggleUserDialog();
+          $scope.refreshSecurityPanel();
+        });
+      });
+    }
+  };
+
+  $scope.showAddUserDialog = function() {
+    $scope.userDialogMode = "add";
+    $scope.userDialogHeader = "Add New User";
+    $scope.userDialogAction = "Add User";
+    $scope.upsertUser = {};
+    $scope.toggleUserDialog();
+  };
+
+  $scope.toggleUserDialog = function() {
+    if ($scope.showUserDialog) {
+      delete $scope.upsertUser;
+      delete $scope.validationError;
+      $scope.showUserDialog = false;
+      return;
+    }
+
+    $scope.hideAll();
+    $('#user-dialog').css({left: 132, top: 132});
+    $scope.showUserDialog = true;
+  };
+
+  $scope.onPredefinedChanged = function() {
+    if (!$scope.upsertPerm) {
+      return;
+    }
+
+    if ($scope.upsertPerm.name && $scope.upsertPerm.name.trim() !== "") {
+      delete $scope.selectedPredefinedPermission;
+    } else {
+      $scope.upsertPerm.name = "";
+    }
+
+    if ($scope.selectedPredefinedPermission && $scope.selectedPredefinedPermission in $scope.predefinedPermissionCollection) {
+      $scope.upsertPerm.collection = $scope.predefinedPermissionCollection[$scope.selectedPredefinedPermission];
+    }
+
+    $scope.isPermFieldDisabled = ($scope.upsertPerm.name === "" && $scope.selectedPredefinedPermission);
+  };
+
+  $scope.showAddPermDialog = function() {
+    $scope.permDialogMode = "add";
+    $scope.permDialogHeader = "Add New Permission";
+    $scope.permDialogAction = "Add Permission";
+    $scope.upsertPerm = {};
+    $scope.upsertPerm.name = "";
+    $scope.upsertPerm.index = "";
+    $scope.upsertPerm["method"] = {"get":"true", "post":"true", "put":"true", "delete":"true"};
+    $scope.isPermFieldDisabled = false;
+    delete $scope.selectedPredefinedPermission;
+
+    $scope.params = [{"name":"", "value":""}];
+
+    var permissionNames = $scope.permissions.map(p => p.name);
+    $scope.filteredPredefinedPermissions = $scope.predefinedPermissions.filter(p => !permissionNames.includes(p));
+
+    $scope.togglePermDialog();
+  };
+
+  $scope.togglePermDialog = function() {
+    if ($scope.showPermDialog) {
+      delete $scope.upsertPerm;
+      delete $scope.validationError;
+      $scope.showPermDialog = false;
+      $scope.isPermFieldDisabled = false;
+      delete $scope.selectedPredefinedPermission;
+      return;
+    }
+
+    $scope.hideAll();
+
+    var leftPos = $scope.permDialogMode === "add" ? 500 : 100;
+    var topPos = $('#permissions').offset().top - 320;
+    if (topPos < 0) topPos = 0;
+    $('#add-permission-dialog').css({left: leftPos, top: topPos});
+
+    $scope.showPermDialog = true;
+  };
+
+  $scope.getMethods = function() {
+    var methods = [];
+    if ($scope.upsertPerm.method.get === "true") {
+      methods.push("GET");
+    }
+    if ($scope.upsertPerm.method.put === "true") {
+      methods.push("PUT");
+    }
+    if ($scope.upsertPerm.method.post === "true") {
+      methods.push("POST");
+    }
+    if ($scope.upsertPerm.method.delete === "true") {
+      methods.push("DELETE");
+    }
+    return methods;
+  };
+
+  $scope.confirmDeletePerm = function() {
+    var permName = $scope.selectedPredefinedPermission ? $scope.selectedPredefinedPermission : $scope.upsertPerm.name.trim();
+    if (window.confirm("Confirm delete the '"+permName+"' permission?")) {
+      var index = parseInt($scope.upsertPerm.index);
+      Security.post({path: "authorization"}, { "delete-permission": index }, function (data) {
+        $scope.togglePermDialog();
+        $scope.refreshSecurityPanel();
+      });
+    }
+  };
+
+  $scope.doUpsertPermission = function() {
+    if (!$scope.upsertPerm) {
+      $scope.upsertPerm = {};
+    }
+
+    var isAdd = $scope.permDialogMode === "add";
+    var name = $scope.selectedPredefinedPermission ? $scope.selectedPredefinedPermission : $scope.upsertPerm.name.trim();
+
+    if (isAdd) {
+      if (!name) {
+        $scope.validationError = "Either select a predefined permission or provide a name for a custom permission";
+        return;
+      }
+      var permissionNames = $scope.permissions.map(p => p.name);
+      if (permissionNames.includes(name)) {
+        $scope.validationError = "Permission '"+name+"' already exists!";
+        return;
+      }
+
+      if (name === "*") {
+        $scope.validationError = "Invalid permission name!";
+        return;
+      }
+    }
+
+    var role = null;
+    if ($scope.manageUserRolesEnabled) {
+      role = $scope.upsertPerm.selectedRoles;
+      if (!role || role.length === 0) {
+        role = null;
+      } else if (role.includes("*")) {
+        role = ["*"];
+      }
+    } else if ($scope.upsertPerm.manualRoles && $scope.upsertPerm.manualRoles.trim() !== "") {
+      var manualRoles = $scope.upsertPerm.manualRoles.trim();
+      role = (manualRoles === "null") ? null : toList(manualRoles);
+    }
+
+    var setPermJson = {"name": name, "role": role };
+
+    if ($scope.selectedPredefinedPermission) {
+      $scope.params = [{"name":"","value":""}];
+    } else {
+      // collection
+      var coll = null;
+      if ($scope.upsertPerm.collection != null && $scope.upsertPerm.collection !== "null") {
+        if ($scope.upsertPerm.collection === "*") {
+          coll = "*";
+        } else {
+          coll = $scope.upsertPerm.collection && $scope.upsertPerm.collection.trim() !== "" ? toList($scope.upsertPerm.collection) : "";
+        }
+      }
+      setPermJson["collection"] = coll;
+
+      // path
+      if (!$scope.upsertPerm.path || (Array.isArray($scope.upsertPerm.path) && $scope.upsertPerm.path.length === 0)) {
+        $scope.validationError = "Path is required for custom permissions!";
+        return;
+      }
+
+      setPermJson["path"] = toList($scope.upsertPerm.path);
+
+      if ($scope.upsertPerm.method) {
+        var methods = $scope.getMethods();
+        if (methods.length === 0) {
+          $scope.validationError = "Must specify at least one request method for a custom permission!";
+          return;
+        }
+
+        if (methods.length < 4) {
+          setPermJson["method"] = methods;
+        } // else no need to specify, rule applies to all methods
+      }
+
+      // params
+      var params = {};
+      if ($scope.params && $scope.params.length > 0) {
+        for (i in $scope.params) {
+          var p = $scope.params[i];
+          var name = p.name.trim();
+          if (name !== "" && p.value) {
+            if (name in params) {
+              params[name].push(p.value);
+            } else {
+              params[name] = [p.value];
+            }
+          }
+        }
+      }
+      setPermJson["params"] = params;
+    }
+
+    var indexUpdated = false;
+    if ($scope.upsertPerm.index) {
+      var indexOrBefore = isAdd ? "before" : "index";
+      var indexInt = parseInt($scope.upsertPerm.index);
+      if (indexInt < 1) indexInt = 1;
+      if (indexInt >= $scope.permissions.length) indexInt = null;
+      if (indexInt != null) {
+        setPermJson[indexOrBefore] = indexInt;
+      }
+      indexUpdated = (!isAdd && indexInt !== parseInt($scope.upsertPerm.originalIndex));
+    }
+
+    if (indexUpdated) {
+      // changing position is a delete + re-add in new position
+      Security.post({path: "authorization"}, { "delete-permission": parseInt($scope.upsertPerm.originalIndex) }, function (remData) {
+        if (setPermJson.index) {
+          var before = setPermJson.index;
+          delete setPermJson.index;
+          setPermJson["before"] = before;

Review comment:
       *W069:*  ['before'] is better written in dot notation.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/security.js
##########
@@ -0,0 +1,1124 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cookies, $window, Constants, System, Security) {
+  $scope.resetMenu("security", Constants.IS_ROOT_PAGE);
+
+  $scope.params = [];
+
+  var strongPasswordRegex = /^(?=.*[0-9])(?=.*[!@#$%^&*\-_()[\]])[a-zA-Z0-9!@#$%^&*\-_()[\]]{8,30}$/;
+
+  function toList(str) {
+    if (Array.isArray(str)) {
+      return str; // already a list
+    }
+    return str.trim().split(",").map(s => s.trim()).filter(s => s !== "");
+  }
+
+  function asList(listOrStr) {
+    return Array.isArray(listOrStr) ? listOrStr : (listOrStr ? [listOrStr] : []);
+  }
+
+  function transposeUserRoles(userRoles) {
+    var roleUsers = {};
+    for (var u in userRoles) {
+      var roleList = asList(userRoles[u]);
+      for (var i in roleList) {
+        var role = roleList[i];
+        if (!roleUsers[role]) roleUsers[role] = []
+        roleUsers[role].push(u);
+      }
+    }
+
+    var roles = [];
+    for (var r in roleUsers) {
+      roles.push({"name":r, "users":Array.from(new Set(roleUsers[r]))});
+    }
+    return roles.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  function roleMatch(roles, rolesForUser) {
+    for (r in rolesForUser) {
+      if (roles.includes(rolesForUser[r]))
+        return true;
+    }
+    return false;
+  }
+
+  function permRow(perm, i) {
+    var roles = asList(perm.role);
+    var paths = asList(perm.path);
+
+    var collectionNames = "";
+    var collections = [];
+    if ("collection" in perm) {
+      if (perm["collection"] == null) {
+        collectionNames = "null";
+      } else {
+        collections = asList(perm.collection);
+        collectionNames = collections.sort().join(", ");
+      }
+    } else {
+      // no collection property on the perm, so the default "*" applies
+      collectionNames = "";
+      collections.push("*");
+    }
+
+    var method = asList(perm.method);
+
+    // perms don't always have an index ?!?
+    var index = "index" in perm ? perm["index"] : ""+i;
+
+    return { "index": index, "name": perm.name, "collectionNames": collectionNames, "collections": collections,
+      "roles": roles, "paths": paths, "method": method, "params": perm.params };
+  }
+
+  function checkError(data) {
+    var cause = null;
+    if ("errorMessages" in data && Array.isArray(data["errorMessages"]) && data["errorMessages"].length > 0) {
+      cause = "?";
+      if ("errorMessages" in data["errorMessages"][0] && Array.isArray(data["errorMessages"][0]["errorMessages"]) && data["errorMessages"][0]["errorMessages"].length > 0) {
+        cause = data["errorMessages"][0]["errorMessages"][0];
+      }
+    }
+    return cause;
+  }
+
+  function truncateTo(str, maxLen, delim) {
+    // allow for a little on either side of maxLen for better display
+    var varLen = Math.min(Math.round(maxLen * 0.1), 15);
+    if (str.length <= maxLen + varLen) {
+      return str;
+    }
+
+    var total = str.split(delim).length;
+    var at = str.indexOf(delim, maxLen - varLen);
+    str = (at !== -1 && at < maxLen + varLen) ? str.substring(0, at) : str.substring(0, maxLen);
+    var trimmed = str.split(delim).length;
+    var diff = total - trimmed;
+    str += " ... "+(diff > 1 ? "(+"+diff+" more)" : "");
+    return str;
+  }
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.securityAPIError;
+    delete $scope.securityAPIErrorDetails;
+  };
+
+  $scope.displayList = function(listOrStr) {
+    if (!listOrStr) return "";
+    var str = Array.isArray(listOrStr) ? listOrStr.sort().join(", ") : (""+listOrStr).trim();
+    return truncateTo(str, 160, ", ");
+  };
+
+  $scope.displayParams = function(obj) {
+    if (!obj) return "";
+    if (Array.isArray(obj)) return obj.sort().join(", ");
+
+    var display = "";
+    for (const [key, value] of Object.entries(obj)) {
+      if (display.length > 0) display += "; ";
+      display += (key + "=" + (Array.isArray(value)?value.sort().join(","):value+""));
+    }
+    return truncateTo(display, 160, "; ");
+  };
+
+  $scope.displayRoles = function(obj) {
+    return (!obj || (Array.isArray(obj) && obj.length === 0)) ? "null" : $scope.displayList(obj);
+  };
+
+  $scope.predefinedPermissions = ["collection-admin-edit", "collection-admin-read", "core-admin-read", "core-admin-edit", "zk-read",
+    "read", "update", "all", "config-edit", "config-read", "schema-read", "schema-edit", "security-edit", "security-read",
+    "metrics-read", "filestore-read", "filestore-write", "package-edit", "package-read"].sort();
+
+  $scope.predefinedPermissionCollection = {"read":"*", "update":"*", "config-edit":"*", "config-read":"*", "schema-edit":"*", "schema-read":"*"};
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error && error.msg) {
+      $scope.securityAPIError = error.msg;
+      $scope.securityAPIErrorDetails = e.data.errorDetails;
+    } else if (e.data && e.data.message) {
+      $scope.securityAPIError = e.data.message;
+      $scope.securityAPIErrorDetails = JSON.stringify(e.data);
+    }
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.hideAll();
+
+    $scope.tls = false;
+    $scope.blockUnknown = "false"; // default setting
+    $scope.realmName = "solr";
+    $scope.forwardCredentials = "false";
+
+    $scope.currentUser = sessionStorage.getItem("auth.username");
+
+    $scope.userFilter = "";
+    $scope.userFilterOption = "";
+    $scope.userFilterText = "";
+    $scope.userFilterOptions = [];
+
+    $scope.permFilter = "";
+    $scope.permFilterOption = "";
+    $scope.permFilterOptions = [];
+    $scope.permFilterTypes = ["", "name", "role", "path", "collection"];
+
+    System.get(function(data) {
+      $scope.tls = data.security ? data.security["tls"] : false;
+      $scope.authenticationPlugin = data.security ? data.security["authenticationPlugin"] : null;
+      $scope.authorizationPlugin = data.security ? data.security["authorizationPlugin"] : null;
+      $scope.myRoles = data.security ? data.security["roles"] : [];
+      $scope.isSecurityAdminEnabled = $scope.authenticationPlugin != null;
+      $scope.isCloudMode = data.mode.match( /solrcloud/i ) != null;
+      $scope.zkHost = $scope.isCloudMode ? data["zkHost"] : "";
+      $scope.solrHome = data["solr_home"];
+      $scope.refreshSecurityPanel();
+    }, function(e) {
+      if (e.status === 401 || e.status === 403) {
+        $scope.isSecurityAdminEnabled = true;
+        $scope.hasSecurityEditPerm = false;
+        $scope.hideAll();
+      }
+    });
+  };
+
+  $scope.hideAll = function () {
+    // add more dialogs here
+    delete $scope.validationError;
+    $scope.showUserDialog = false;
+    $scope.showPermDialog = false;
+    delete $scope.helpId;
+  };
+
+  $scope.getCurrentUserRoles = function() {
+    if ($scope.manageUserRolesEnabled) {
+      return Array.isArray($scope.userRoles[$scope.currentUser]) ? $scope.userRoles[$scope.currentUser] : [$scope.userRoles[$scope.currentUser]];
+    } else {
+      return $scope.myRoles;
+    }
+  };
+
+  $scope.hasPermission = function(permissionName) {
+    var rolesForPermission = $scope.permissionsTable.filter(p => permissionName === p.name).flatMap(p => p.roles);
+    return (rolesForPermission.length > 0 && roleMatch(rolesForPermission, $scope.getCurrentUserRoles()));
+  };
+
+  $scope.refreshSecurityPanel = function() {
+
+    // determine if the authorization plugin supports CRUD permissions
+    $scope.managePermissionsEnabled =
+        ($scope.authorizationPlugin === "org.apache.solr.security.RuleBasedAuthorizationPlugin" ||
+            $scope.authorizationPlugin === "org.apache.solr.security.ExternalRoleRuleBasedAuthorizationPlugin");
+
+    // don't allow CRUD on roles if using external
+    $scope.manageUserRolesEnabled = $scope.authorizationPlugin === "org.apache.solr.security.RuleBasedAuthorizationPlugin";
+
+    Security.get({path: "authorization"}, function (data) {
+      if (!data.authorization) {
+        $scope.isSecurityAdminEnabled = false;
+        $scope.hasSecurityEditPerm = false;
+        return;
+      }
+
+      if ($scope.manageUserRolesEnabled) {
+        $scope.userRoles = data.authorization["user-role"];
+        $scope.roles = transposeUserRoles($scope.userRoles);
+        $scope.filteredRoles = $scope.roles;
+        $scope.roleNames = $scope.roles.map(r => r.name).sort();
+        $scope.roleNamesWithWildcard = ["*"].concat($scope.roleNames);
+        if (!$scope.permFilterTypes.includes("user")) {
+          $scope.permFilterTypes.push("user"); // can only filter perms by user if we have a role to user mapping
+        }
+      } else {
+        $scope.userRoles = {};
+        $scope.roles = [];
+        $scope.filteredRoles = [];
+        $scope.roleNames = [];
+      }
+
+      $scope.permissions = data.authorization["permissions"];
+      $scope.permissionsTable = [];
+      for (p in $scope.permissions) {
+        $scope.permissionsTable.push(permRow($scope.permissions[p], parseInt(p)+1));
+      }
+      $scope.filteredPerms = $scope.permissionsTable;
+
+      $scope.hasSecurityEditPerm = $scope.hasPermission("security-edit");
+      $scope.hasSecurityReadPerm = $scope.hasSecurityEditPerm || $scope.hasPermission("security-read");
+
+      if ($scope.authenticationPlugin === "org.apache.solr.security.BasicAuthPlugin") {
+        $scope.manageUsersEnabled = true;
+
+        Security.get({path: "authentication"}, function (data) {
+          if (!data.authentication) {
+            // TODO: error msg
+            $scope.manageUsersEnabled = false;
+          }
+
+          $scope.blockUnknown = data.authentication["blockUnknown"] === true ? "true" : "false";

Review comment:
       *W069:*  ['blockUnknown'] is better written in dot notation.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/security.js
##########
@@ -0,0 +1,1124 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cookies, $window, Constants, System, Security) {
+  $scope.resetMenu("security", Constants.IS_ROOT_PAGE);
+
+  $scope.params = [];
+
+  var strongPasswordRegex = /^(?=.*[0-9])(?=.*[!@#$%^&*\-_()[\]])[a-zA-Z0-9!@#$%^&*\-_()[\]]{8,30}$/;
+
+  function toList(str) {
+    if (Array.isArray(str)) {
+      return str; // already a list
+    }
+    return str.trim().split(",").map(s => s.trim()).filter(s => s !== "");
+  }
+
+  function asList(listOrStr) {
+    return Array.isArray(listOrStr) ? listOrStr : (listOrStr ? [listOrStr] : []);
+  }
+
+  function transposeUserRoles(userRoles) {
+    var roleUsers = {};
+    for (var u in userRoles) {
+      var roleList = asList(userRoles[u]);
+      for (var i in roleList) {
+        var role = roleList[i];
+        if (!roleUsers[role]) roleUsers[role] = []
+        roleUsers[role].push(u);
+      }
+    }
+
+    var roles = [];
+    for (var r in roleUsers) {
+      roles.push({"name":r, "users":Array.from(new Set(roleUsers[r]))});
+    }
+    return roles.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  function roleMatch(roles, rolesForUser) {
+    for (r in rolesForUser) {
+      if (roles.includes(rolesForUser[r]))
+        return true;
+    }
+    return false;
+  }
+
+  function permRow(perm, i) {
+    var roles = asList(perm.role);
+    var paths = asList(perm.path);
+
+    var collectionNames = "";
+    var collections = [];
+    if ("collection" in perm) {
+      if (perm["collection"] == null) {
+        collectionNames = "null";
+      } else {
+        collections = asList(perm.collection);
+        collectionNames = collections.sort().join(", ");
+      }
+    } else {
+      // no collection property on the perm, so the default "*" applies
+      collectionNames = "";
+      collections.push("*");
+    }
+
+    var method = asList(perm.method);
+
+    // perms don't always have an index ?!?
+    var index = "index" in perm ? perm["index"] : ""+i;
+
+    return { "index": index, "name": perm.name, "collectionNames": collectionNames, "collections": collections,
+      "roles": roles, "paths": paths, "method": method, "params": perm.params };
+  }
+
+  function checkError(data) {
+    var cause = null;
+    if ("errorMessages" in data && Array.isArray(data["errorMessages"]) && data["errorMessages"].length > 0) {
+      cause = "?";
+      if ("errorMessages" in data["errorMessages"][0] && Array.isArray(data["errorMessages"][0]["errorMessages"]) && data["errorMessages"][0]["errorMessages"].length > 0) {
+        cause = data["errorMessages"][0]["errorMessages"][0];
+      }
+    }
+    return cause;
+  }
+
+  function truncateTo(str, maxLen, delim) {
+    // allow for a little on either side of maxLen for better display
+    var varLen = Math.min(Math.round(maxLen * 0.1), 15);
+    if (str.length <= maxLen + varLen) {
+      return str;
+    }
+
+    var total = str.split(delim).length;
+    var at = str.indexOf(delim, maxLen - varLen);
+    str = (at !== -1 && at < maxLen + varLen) ? str.substring(0, at) : str.substring(0, maxLen);
+    var trimmed = str.split(delim).length;
+    var diff = total - trimmed;
+    str += " ... "+(diff > 1 ? "(+"+diff+" more)" : "");
+    return str;
+  }
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.securityAPIError;
+    delete $scope.securityAPIErrorDetails;
+  };
+
+  $scope.displayList = function(listOrStr) {
+    if (!listOrStr) return "";
+    var str = Array.isArray(listOrStr) ? listOrStr.sort().join(", ") : (""+listOrStr).trim();
+    return truncateTo(str, 160, ", ");
+  };
+
+  $scope.displayParams = function(obj) {
+    if (!obj) return "";
+    if (Array.isArray(obj)) return obj.sort().join(", ");
+
+    var display = "";
+    for (const [key, value] of Object.entries(obj)) {
+      if (display.length > 0) display += "; ";
+      display += (key + "=" + (Array.isArray(value)?value.sort().join(","):value+""));
+    }
+    return truncateTo(display, 160, "; ");
+  };
+
+  $scope.displayRoles = function(obj) {
+    return (!obj || (Array.isArray(obj) && obj.length === 0)) ? "null" : $scope.displayList(obj);
+  };
+
+  $scope.predefinedPermissions = ["collection-admin-edit", "collection-admin-read", "core-admin-read", "core-admin-edit", "zk-read",
+    "read", "update", "all", "config-edit", "config-read", "schema-read", "schema-edit", "security-edit", "security-read",
+    "metrics-read", "filestore-read", "filestore-write", "package-edit", "package-read"].sort();
+
+  $scope.predefinedPermissionCollection = {"read":"*", "update":"*", "config-edit":"*", "config-read":"*", "schema-edit":"*", "schema-read":"*"};
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error && error.msg) {
+      $scope.securityAPIError = error.msg;
+      $scope.securityAPIErrorDetails = e.data.errorDetails;
+    } else if (e.data && e.data.message) {
+      $scope.securityAPIError = e.data.message;
+      $scope.securityAPIErrorDetails = JSON.stringify(e.data);
+    }
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.hideAll();
+
+    $scope.tls = false;
+    $scope.blockUnknown = "false"; // default setting
+    $scope.realmName = "solr";
+    $scope.forwardCredentials = "false";
+
+    $scope.currentUser = sessionStorage.getItem("auth.username");
+
+    $scope.userFilter = "";
+    $scope.userFilterOption = "";
+    $scope.userFilterText = "";
+    $scope.userFilterOptions = [];
+
+    $scope.permFilter = "";
+    $scope.permFilterOption = "";
+    $scope.permFilterOptions = [];
+    $scope.permFilterTypes = ["", "name", "role", "path", "collection"];
+
+    System.get(function(data) {
+      $scope.tls = data.security ? data.security["tls"] : false;
+      $scope.authenticationPlugin = data.security ? data.security["authenticationPlugin"] : null;
+      $scope.authorizationPlugin = data.security ? data.security["authorizationPlugin"] : null;
+      $scope.myRoles = data.security ? data.security["roles"] : [];
+      $scope.isSecurityAdminEnabled = $scope.authenticationPlugin != null;
+      $scope.isCloudMode = data.mode.match( /solrcloud/i ) != null;
+      $scope.zkHost = $scope.isCloudMode ? data["zkHost"] : "";
+      $scope.solrHome = data["solr_home"];
+      $scope.refreshSecurityPanel();
+    }, function(e) {
+      if (e.status === 401 || e.status === 403) {
+        $scope.isSecurityAdminEnabled = true;
+        $scope.hasSecurityEditPerm = false;
+        $scope.hideAll();
+      }
+    });
+  };
+
+  $scope.hideAll = function () {
+    // add more dialogs here
+    delete $scope.validationError;
+    $scope.showUserDialog = false;
+    $scope.showPermDialog = false;
+    delete $scope.helpId;
+  };
+
+  $scope.getCurrentUserRoles = function() {
+    if ($scope.manageUserRolesEnabled) {
+      return Array.isArray($scope.userRoles[$scope.currentUser]) ? $scope.userRoles[$scope.currentUser] : [$scope.userRoles[$scope.currentUser]];
+    } else {
+      return $scope.myRoles;
+    }
+  };
+
+  $scope.hasPermission = function(permissionName) {
+    var rolesForPermission = $scope.permissionsTable.filter(p => permissionName === p.name).flatMap(p => p.roles);
+    return (rolesForPermission.length > 0 && roleMatch(rolesForPermission, $scope.getCurrentUserRoles()));
+  };
+
+  $scope.refreshSecurityPanel = function() {
+
+    // determine if the authorization plugin supports CRUD permissions
+    $scope.managePermissionsEnabled =
+        ($scope.authorizationPlugin === "org.apache.solr.security.RuleBasedAuthorizationPlugin" ||
+            $scope.authorizationPlugin === "org.apache.solr.security.ExternalRoleRuleBasedAuthorizationPlugin");
+
+    // don't allow CRUD on roles if using external
+    $scope.manageUserRolesEnabled = $scope.authorizationPlugin === "org.apache.solr.security.RuleBasedAuthorizationPlugin";
+
+    Security.get({path: "authorization"}, function (data) {
+      if (!data.authorization) {
+        $scope.isSecurityAdminEnabled = false;
+        $scope.hasSecurityEditPerm = false;
+        return;
+      }
+
+      if ($scope.manageUserRolesEnabled) {
+        $scope.userRoles = data.authorization["user-role"];
+        $scope.roles = transposeUserRoles($scope.userRoles);
+        $scope.filteredRoles = $scope.roles;
+        $scope.roleNames = $scope.roles.map(r => r.name).sort();
+        $scope.roleNamesWithWildcard = ["*"].concat($scope.roleNames);
+        if (!$scope.permFilterTypes.includes("user")) {
+          $scope.permFilterTypes.push("user"); // can only filter perms by user if we have a role to user mapping
+        }
+      } else {
+        $scope.userRoles = {};
+        $scope.roles = [];
+        $scope.filteredRoles = [];
+        $scope.roleNames = [];
+      }
+
+      $scope.permissions = data.authorization["permissions"];
+      $scope.permissionsTable = [];
+      for (p in $scope.permissions) {
+        $scope.permissionsTable.push(permRow($scope.permissions[p], parseInt(p)+1));
+      }
+      $scope.filteredPerms = $scope.permissionsTable;
+
+      $scope.hasSecurityEditPerm = $scope.hasPermission("security-edit");
+      $scope.hasSecurityReadPerm = $scope.hasSecurityEditPerm || $scope.hasPermission("security-read");
+
+      if ($scope.authenticationPlugin === "org.apache.solr.security.BasicAuthPlugin") {
+        $scope.manageUsersEnabled = true;
+
+        Security.get({path: "authentication"}, function (data) {
+          if (!data.authentication) {
+            // TODO: error msg
+            $scope.manageUsersEnabled = false;
+          }
+
+          $scope.blockUnknown = data.authentication["blockUnknown"] === true ? "true" : "false";
+          $scope.forwardCredentials = data.authentication["forwardCredentials"] === true ? "true" : "false";
+
+          if ("realm" in data.authentication) {
+            $scope.realmName = data.authentication["realm"];
+          }
+
+          var users = [];
+          if (data.authentication.credentials) {
+            for (var u in data.authentication.credentials) {
+              var roles = $scope.userRoles[u];
+              if (!roles) roles = [];
+              users.push({"username":u, "roles":roles});
+            }
+          }
+          $scope.users = users.sort((a, b) => (a.username > b.username) ? 1 : -1);
+          $scope.filteredUsers = $scope.users.slice(0,100); // only display first 100
+        }, $scope.errorHandler);
+      } else {
+        $scope.users = [];
+        $scope.filteredUsers = $scope.users;
+        $scope.manageUsersEnabled = false;
+      }
+    }, $scope.errorHandler);
+  };
+
+  $scope.validatePassword = function() {
+    var password = $scope.upsertUser.password.trim();
+    var password2 = $scope.upsertUser.password2 ? $scope.upsertUser.password2.trim() : "";
+    if (password !== password2) {
+      $scope.validationError = "Passwords do not match!";
+      return false;
+    }
+
+    if (!password.match(strongPasswordRegex)) {
+      $scope.validationError = "Password not strong enough! Must contain at least one lowercase letter, one uppercase letter, one digit, and one of these special characters: !@#$%^&*_-[]()";
+      return false;
+    }
+
+    return true;
+  };
+
+  $scope.updateUserRoles = function() {
+    var setUserRoles = {};
+    var roles = [];
+    if ($scope.upsertUser.selectedRoles) {
+      roles = roles.concat($scope.upsertUser.selectedRoles);
+    }
+    if ($scope.upsertUser.newRole && $scope.upsertUser.newRole.trim() !== "") {
+      var newRole = $scope.upsertUser.newRole.trim();
+      if (newRole !== "null" && newRole !== "*" && newRole.length <= 30) {
+        roles.push(newRole);
+      } // else, no new role for you!
+    }
+    var userRoles = Array.from(new Set(roles));
+    setUserRoles[$scope.upsertUser.username] = userRoles.length > 0 ? userRoles : null;
+    Security.post({path: "authorization"}, { "set-user-role": setUserRoles }, function (data) {
+      $scope.toggleUserDialog();
+      $scope.refreshSecurityPanel();
+    });
+  };
+
+  $scope.doUpsertUser = function() {
+    if (!$scope.upsertUser) {
+      delete $scope.validationError;
+      $scope.showUserDialog = false;
+      return;
+    }
+
+    if (!$scope.upsertUser.username || $scope.upsertUser.username.trim() === "") {
+      $scope.validationError = "Username is required!";
+      return;
+    }
+
+    // keep username to a reasonable length? but allow for email addresses
+    var username = $scope.upsertUser.username.trim();
+    if (username.length > 30) {
+      $scope.validationError = "Username must be 30 characters or less!";
+      return;
+    }
+
+    var doSetUser = false;
+    if ($scope.userDialogMode === 'add') {
+      if ($scope.users) {
+        var existing = $scope.users.find(u => u.username === username);
+        if (existing) {
+          $scope.validationError = "User '"+username+"' already exists!";
+          return;
+        }
+      }
+
+      if (!$scope.upsertUser.password) {
+        $scope.validationError = "Password is required!";
+        return;
+      }
+
+      if (!$scope.validatePassword()) {
+        return;
+      }
+      doSetUser = true;
+    } else {
+      if ($scope.upsertUser.password) {
+        if ($scope.validatePassword()) {
+          doSetUser = true;
+        } else {
+          return; // update to password is invalid
+        }
+      } // else no update to password
+    }
+
+    if ($scope.upsertUser.newRole && $scope.upsertUser.newRole.trim() !== "") {
+      var newRole = $scope.upsertUser.newRole.trim();
+      if (newRole === "null" || newRole === "*" || newRole.length > 30) {
+        $scope.validationError = "Invalid new role: "+newRole;
+        return;
+      }
+    }
+
+    delete $scope.validationError;
+
+    if (doSetUser) {
+      var setUserJson = {};
+      setUserJson[username] = $scope.upsertUser.password.trim();
+      Security.post({path: "authentication"}, { "set-user": setUserJson }, function (data) {
+
+        var errorCause = checkError(data);
+        if (errorCause != null) {
+          $scope.securityAPIError = "create user "+username+" failed due to: "+errorCause;
+          $scope.securityAPIErrorDetails = JSON.stringify(data);
+          return;
+        }
+
+        $scope.updateUserRoles();
+      });
+    } else {
+      $scope.updateUserRoles();
+    }
+  };
+
+  $scope.confirmDeleteUser = function() {
+    if (window.confirm("Confirm delete the '"+$scope.upsertUser.username+"' user?")) {
+      // remove all roles for the user and the delete the user
+      var removeRoles = {};
+      removeRoles[$scope.upsertUser.username] = null;
+      Security.post({path: "authorization"}, { "set-user-role": removeRoles }, function (data) {
+        Security.post({path: "authentication"}, {"delete-user": [$scope.upsertUser.username]}, function (data2) {
+          $scope.toggleUserDialog();
+          $scope.refreshSecurityPanel();
+        });
+      });
+    }
+  };
+
+  $scope.showAddUserDialog = function() {
+    $scope.userDialogMode = "add";
+    $scope.userDialogHeader = "Add New User";
+    $scope.userDialogAction = "Add User";
+    $scope.upsertUser = {};
+    $scope.toggleUserDialog();
+  };
+
+  $scope.toggleUserDialog = function() {
+    if ($scope.showUserDialog) {
+      delete $scope.upsertUser;
+      delete $scope.validationError;
+      $scope.showUserDialog = false;
+      return;
+    }
+
+    $scope.hideAll();
+    $('#user-dialog').css({left: 132, top: 132});
+    $scope.showUserDialog = true;
+  };
+
+  $scope.onPredefinedChanged = function() {
+    if (!$scope.upsertPerm) {
+      return;
+    }
+
+    if ($scope.upsertPerm.name && $scope.upsertPerm.name.trim() !== "") {
+      delete $scope.selectedPredefinedPermission;
+    } else {
+      $scope.upsertPerm.name = "";
+    }
+
+    if ($scope.selectedPredefinedPermission && $scope.selectedPredefinedPermission in $scope.predefinedPermissionCollection) {
+      $scope.upsertPerm.collection = $scope.predefinedPermissionCollection[$scope.selectedPredefinedPermission];
+    }
+
+    $scope.isPermFieldDisabled = ($scope.upsertPerm.name === "" && $scope.selectedPredefinedPermission);
+  };
+
+  $scope.showAddPermDialog = function() {
+    $scope.permDialogMode = "add";
+    $scope.permDialogHeader = "Add New Permission";
+    $scope.permDialogAction = "Add Permission";
+    $scope.upsertPerm = {};
+    $scope.upsertPerm.name = "";
+    $scope.upsertPerm.index = "";
+    $scope.upsertPerm["method"] = {"get":"true", "post":"true", "put":"true", "delete":"true"};
+    $scope.isPermFieldDisabled = false;
+    delete $scope.selectedPredefinedPermission;
+
+    $scope.params = [{"name":"", "value":""}];
+
+    var permissionNames = $scope.permissions.map(p => p.name);
+    $scope.filteredPredefinedPermissions = $scope.predefinedPermissions.filter(p => !permissionNames.includes(p));
+
+    $scope.togglePermDialog();
+  };
+
+  $scope.togglePermDialog = function() {
+    if ($scope.showPermDialog) {
+      delete $scope.upsertPerm;
+      delete $scope.validationError;
+      $scope.showPermDialog = false;
+      $scope.isPermFieldDisabled = false;
+      delete $scope.selectedPredefinedPermission;
+      return;
+    }
+
+    $scope.hideAll();
+
+    var leftPos = $scope.permDialogMode === "add" ? 500 : 100;
+    var topPos = $('#permissions').offset().top - 320;
+    if (topPos < 0) topPos = 0;
+    $('#add-permission-dialog').css({left: leftPos, top: topPos});
+
+    $scope.showPermDialog = true;
+  };
+
+  $scope.getMethods = function() {
+    var methods = [];
+    if ($scope.upsertPerm.method.get === "true") {
+      methods.push("GET");
+    }
+    if ($scope.upsertPerm.method.put === "true") {
+      methods.push("PUT");
+    }
+    if ($scope.upsertPerm.method.post === "true") {
+      methods.push("POST");
+    }
+    if ($scope.upsertPerm.method.delete === "true") {
+      methods.push("DELETE");
+    }
+    return methods;
+  };
+
+  $scope.confirmDeletePerm = function() {
+    var permName = $scope.selectedPredefinedPermission ? $scope.selectedPredefinedPermission : $scope.upsertPerm.name.trim();
+    if (window.confirm("Confirm delete the '"+permName+"' permission?")) {
+      var index = parseInt($scope.upsertPerm.index);
+      Security.post({path: "authorization"}, { "delete-permission": index }, function (data) {
+        $scope.togglePermDialog();
+        $scope.refreshSecurityPanel();
+      });
+    }
+  };
+
+  $scope.doUpsertPermission = function() {
+    if (!$scope.upsertPerm) {
+      $scope.upsertPerm = {};
+    }
+
+    var isAdd = $scope.permDialogMode === "add";
+    var name = $scope.selectedPredefinedPermission ? $scope.selectedPredefinedPermission : $scope.upsertPerm.name.trim();
+
+    if (isAdd) {
+      if (!name) {
+        $scope.validationError = "Either select a predefined permission or provide a name for a custom permission";
+        return;
+      }
+      var permissionNames = $scope.permissions.map(p => p.name);
+      if (permissionNames.includes(name)) {
+        $scope.validationError = "Permission '"+name+"' already exists!";
+        return;
+      }
+
+      if (name === "*") {
+        $scope.validationError = "Invalid permission name!";
+        return;
+      }
+    }
+
+    var role = null;
+    if ($scope.manageUserRolesEnabled) {
+      role = $scope.upsertPerm.selectedRoles;
+      if (!role || role.length === 0) {
+        role = null;
+      } else if (role.includes("*")) {
+        role = ["*"];
+      }
+    } else if ($scope.upsertPerm.manualRoles && $scope.upsertPerm.manualRoles.trim() !== "") {
+      var manualRoles = $scope.upsertPerm.manualRoles.trim();
+      role = (manualRoles === "null") ? null : toList(manualRoles);
+    }
+
+    var setPermJson = {"name": name, "role": role };
+
+    if ($scope.selectedPredefinedPermission) {
+      $scope.params = [{"name":"","value":""}];
+    } else {
+      // collection
+      var coll = null;
+      if ($scope.upsertPerm.collection != null && $scope.upsertPerm.collection !== "null") {
+        if ($scope.upsertPerm.collection === "*") {
+          coll = "*";
+        } else {
+          coll = $scope.upsertPerm.collection && $scope.upsertPerm.collection.trim() !== "" ? toList($scope.upsertPerm.collection) : "";
+        }
+      }
+      setPermJson["collection"] = coll;

Review comment:
       *W069:*  ['collection'] is better written in dot notation.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/security.js
##########
@@ -0,0 +1,1124 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cookies, $window, Constants, System, Security) {
+  $scope.resetMenu("security", Constants.IS_ROOT_PAGE);
+
+  $scope.params = [];
+
+  var strongPasswordRegex = /^(?=.*[0-9])(?=.*[!@#$%^&*\-_()[\]])[a-zA-Z0-9!@#$%^&*\-_()[\]]{8,30}$/;
+
+  function toList(str) {
+    if (Array.isArray(str)) {
+      return str; // already a list
+    }
+    return str.trim().split(",").map(s => s.trim()).filter(s => s !== "");
+  }
+
+  function asList(listOrStr) {
+    return Array.isArray(listOrStr) ? listOrStr : (listOrStr ? [listOrStr] : []);
+  }
+
+  function transposeUserRoles(userRoles) {
+    var roleUsers = {};
+    for (var u in userRoles) {
+      var roleList = asList(userRoles[u]);
+      for (var i in roleList) {
+        var role = roleList[i];
+        if (!roleUsers[role]) roleUsers[role] = []
+        roleUsers[role].push(u);
+      }
+    }
+
+    var roles = [];
+    for (var r in roleUsers) {
+      roles.push({"name":r, "users":Array.from(new Set(roleUsers[r]))});
+    }
+    return roles.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  function roleMatch(roles, rolesForUser) {
+    for (r in rolesForUser) {
+      if (roles.includes(rolesForUser[r]))
+        return true;
+    }
+    return false;
+  }
+
+  function permRow(perm, i) {
+    var roles = asList(perm.role);
+    var paths = asList(perm.path);
+
+    var collectionNames = "";
+    var collections = [];
+    if ("collection" in perm) {
+      if (perm["collection"] == null) {
+        collectionNames = "null";
+      } else {
+        collections = asList(perm.collection);
+        collectionNames = collections.sort().join(", ");
+      }
+    } else {
+      // no collection property on the perm, so the default "*" applies
+      collectionNames = "";
+      collections.push("*");
+    }
+
+    var method = asList(perm.method);
+
+    // perms don't always have an index ?!?
+    var index = "index" in perm ? perm["index"] : ""+i;
+
+    return { "index": index, "name": perm.name, "collectionNames": collectionNames, "collections": collections,
+      "roles": roles, "paths": paths, "method": method, "params": perm.params };
+  }
+
+  function checkError(data) {
+    var cause = null;
+    if ("errorMessages" in data && Array.isArray(data["errorMessages"]) && data["errorMessages"].length > 0) {
+      cause = "?";
+      if ("errorMessages" in data["errorMessages"][0] && Array.isArray(data["errorMessages"][0]["errorMessages"]) && data["errorMessages"][0]["errorMessages"].length > 0) {
+        cause = data["errorMessages"][0]["errorMessages"][0];

Review comment:
       *W069:*  ['errorMessages'] is better written in dot notation.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/security.js
##########
@@ -0,0 +1,1124 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cookies, $window, Constants, System, Security) {
+  $scope.resetMenu("security", Constants.IS_ROOT_PAGE);
+
+  $scope.params = [];
+
+  var strongPasswordRegex = /^(?=.*[0-9])(?=.*[!@#$%^&*\-_()[\]])[a-zA-Z0-9!@#$%^&*\-_()[\]]{8,30}$/;
+
+  function toList(str) {
+    if (Array.isArray(str)) {
+      return str; // already a list
+    }
+    return str.trim().split(",").map(s => s.trim()).filter(s => s !== "");
+  }
+
+  function asList(listOrStr) {
+    return Array.isArray(listOrStr) ? listOrStr : (listOrStr ? [listOrStr] : []);
+  }
+
+  function transposeUserRoles(userRoles) {
+    var roleUsers = {};
+    for (var u in userRoles) {
+      var roleList = asList(userRoles[u]);
+      for (var i in roleList) {
+        var role = roleList[i];
+        if (!roleUsers[role]) roleUsers[role] = []
+        roleUsers[role].push(u);
+      }
+    }
+
+    var roles = [];
+    for (var r in roleUsers) {
+      roles.push({"name":r, "users":Array.from(new Set(roleUsers[r]))});
+    }
+    return roles.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  function roleMatch(roles, rolesForUser) {
+    for (r in rolesForUser) {
+      if (roles.includes(rolesForUser[r]))
+        return true;
+    }
+    return false;
+  }
+
+  function permRow(perm, i) {
+    var roles = asList(perm.role);
+    var paths = asList(perm.path);
+
+    var collectionNames = "";
+    var collections = [];
+    if ("collection" in perm) {
+      if (perm["collection"] == null) {
+        collectionNames = "null";
+      } else {
+        collections = asList(perm.collection);
+        collectionNames = collections.sort().join(", ");
+      }
+    } else {
+      // no collection property on the perm, so the default "*" applies
+      collectionNames = "";
+      collections.push("*");
+    }
+
+    var method = asList(perm.method);
+
+    // perms don't always have an index ?!?
+    var index = "index" in perm ? perm["index"] : ""+i;
+
+    return { "index": index, "name": perm.name, "collectionNames": collectionNames, "collections": collections,
+      "roles": roles, "paths": paths, "method": method, "params": perm.params };
+  }
+
+  function checkError(data) {
+    var cause = null;
+    if ("errorMessages" in data && Array.isArray(data["errorMessages"]) && data["errorMessages"].length > 0) {
+      cause = "?";
+      if ("errorMessages" in data["errorMessages"][0] && Array.isArray(data["errorMessages"][0]["errorMessages"]) && data["errorMessages"][0]["errorMessages"].length > 0) {
+        cause = data["errorMessages"][0]["errorMessages"][0];
+      }
+    }
+    return cause;
+  }
+
+  function truncateTo(str, maxLen, delim) {
+    // allow for a little on either side of maxLen for better display
+    var varLen = Math.min(Math.round(maxLen * 0.1), 15);
+    if (str.length <= maxLen + varLen) {
+      return str;
+    }
+
+    var total = str.split(delim).length;
+    var at = str.indexOf(delim, maxLen - varLen);
+    str = (at !== -1 && at < maxLen + varLen) ? str.substring(0, at) : str.substring(0, maxLen);
+    var trimmed = str.split(delim).length;
+    var diff = total - trimmed;
+    str += " ... "+(diff > 1 ? "(+"+diff+" more)" : "");
+    return str;
+  }
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.securityAPIError;
+    delete $scope.securityAPIErrorDetails;
+  };
+
+  $scope.displayList = function(listOrStr) {
+    if (!listOrStr) return "";
+    var str = Array.isArray(listOrStr) ? listOrStr.sort().join(", ") : (""+listOrStr).trim();
+    return truncateTo(str, 160, ", ");
+  };
+
+  $scope.displayParams = function(obj) {
+    if (!obj) return "";
+    if (Array.isArray(obj)) return obj.sort().join(", ");
+
+    var display = "";
+    for (const [key, value] of Object.entries(obj)) {
+      if (display.length > 0) display += "; ";
+      display += (key + "=" + (Array.isArray(value)?value.sort().join(","):value+""));
+    }
+    return truncateTo(display, 160, "; ");
+  };
+
+  $scope.displayRoles = function(obj) {
+    return (!obj || (Array.isArray(obj) && obj.length === 0)) ? "null" : $scope.displayList(obj);
+  };
+
+  $scope.predefinedPermissions = ["collection-admin-edit", "collection-admin-read", "core-admin-read", "core-admin-edit", "zk-read",
+    "read", "update", "all", "config-edit", "config-read", "schema-read", "schema-edit", "security-edit", "security-read",
+    "metrics-read", "filestore-read", "filestore-write", "package-edit", "package-read"].sort();
+
+  $scope.predefinedPermissionCollection = {"read":"*", "update":"*", "config-edit":"*", "config-read":"*", "schema-edit":"*", "schema-read":"*"};
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error && error.msg) {
+      $scope.securityAPIError = error.msg;
+      $scope.securityAPIErrorDetails = e.data.errorDetails;
+    } else if (e.data && e.data.message) {
+      $scope.securityAPIError = e.data.message;
+      $scope.securityAPIErrorDetails = JSON.stringify(e.data);
+    }
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.hideAll();
+
+    $scope.tls = false;
+    $scope.blockUnknown = "false"; // default setting
+    $scope.realmName = "solr";
+    $scope.forwardCredentials = "false";
+
+    $scope.currentUser = sessionStorage.getItem("auth.username");
+
+    $scope.userFilter = "";
+    $scope.userFilterOption = "";
+    $scope.userFilterText = "";
+    $scope.userFilterOptions = [];
+
+    $scope.permFilter = "";
+    $scope.permFilterOption = "";
+    $scope.permFilterOptions = [];
+    $scope.permFilterTypes = ["", "name", "role", "path", "collection"];
+
+    System.get(function(data) {
+      $scope.tls = data.security ? data.security["tls"] : false;
+      $scope.authenticationPlugin = data.security ? data.security["authenticationPlugin"] : null;
+      $scope.authorizationPlugin = data.security ? data.security["authorizationPlugin"] : null;
+      $scope.myRoles = data.security ? data.security["roles"] : [];
+      $scope.isSecurityAdminEnabled = $scope.authenticationPlugin != null;
+      $scope.isCloudMode = data.mode.match( /solrcloud/i ) != null;
+      $scope.zkHost = $scope.isCloudMode ? data["zkHost"] : "";
+      $scope.solrHome = data["solr_home"];
+      $scope.refreshSecurityPanel();
+    }, function(e) {
+      if (e.status === 401 || e.status === 403) {
+        $scope.isSecurityAdminEnabled = true;
+        $scope.hasSecurityEditPerm = false;
+        $scope.hideAll();
+      }
+    });
+  };
+
+  $scope.hideAll = function () {
+    // add more dialogs here
+    delete $scope.validationError;
+    $scope.showUserDialog = false;
+    $scope.showPermDialog = false;
+    delete $scope.helpId;
+  };
+
+  $scope.getCurrentUserRoles = function() {
+    if ($scope.manageUserRolesEnabled) {
+      return Array.isArray($scope.userRoles[$scope.currentUser]) ? $scope.userRoles[$scope.currentUser] : [$scope.userRoles[$scope.currentUser]];
+    } else {
+      return $scope.myRoles;
+    }
+  };
+
+  $scope.hasPermission = function(permissionName) {
+    var rolesForPermission = $scope.permissionsTable.filter(p => permissionName === p.name).flatMap(p => p.roles);
+    return (rolesForPermission.length > 0 && roleMatch(rolesForPermission, $scope.getCurrentUserRoles()));
+  };
+
+  $scope.refreshSecurityPanel = function() {
+
+    // determine if the authorization plugin supports CRUD permissions
+    $scope.managePermissionsEnabled =
+        ($scope.authorizationPlugin === "org.apache.solr.security.RuleBasedAuthorizationPlugin" ||
+            $scope.authorizationPlugin === "org.apache.solr.security.ExternalRoleRuleBasedAuthorizationPlugin");
+
+    // don't allow CRUD on roles if using external
+    $scope.manageUserRolesEnabled = $scope.authorizationPlugin === "org.apache.solr.security.RuleBasedAuthorizationPlugin";
+
+    Security.get({path: "authorization"}, function (data) {
+      if (!data.authorization) {
+        $scope.isSecurityAdminEnabled = false;
+        $scope.hasSecurityEditPerm = false;
+        return;
+      }
+
+      if ($scope.manageUserRolesEnabled) {
+        $scope.userRoles = data.authorization["user-role"];
+        $scope.roles = transposeUserRoles($scope.userRoles);
+        $scope.filteredRoles = $scope.roles;
+        $scope.roleNames = $scope.roles.map(r => r.name).sort();
+        $scope.roleNamesWithWildcard = ["*"].concat($scope.roleNames);
+        if (!$scope.permFilterTypes.includes("user")) {
+          $scope.permFilterTypes.push("user"); // can only filter perms by user if we have a role to user mapping
+        }
+      } else {
+        $scope.userRoles = {};
+        $scope.roles = [];
+        $scope.filteredRoles = [];
+        $scope.roleNames = [];
+      }
+
+      $scope.permissions = data.authorization["permissions"];
+      $scope.permissionsTable = [];
+      for (p in $scope.permissions) {
+        $scope.permissionsTable.push(permRow($scope.permissions[p], parseInt(p)+1));
+      }
+      $scope.filteredPerms = $scope.permissionsTable;
+
+      $scope.hasSecurityEditPerm = $scope.hasPermission("security-edit");
+      $scope.hasSecurityReadPerm = $scope.hasSecurityEditPerm || $scope.hasPermission("security-read");
+
+      if ($scope.authenticationPlugin === "org.apache.solr.security.BasicAuthPlugin") {
+        $scope.manageUsersEnabled = true;
+
+        Security.get({path: "authentication"}, function (data) {
+          if (!data.authentication) {
+            // TODO: error msg
+            $scope.manageUsersEnabled = false;
+          }
+
+          $scope.blockUnknown = data.authentication["blockUnknown"] === true ? "true" : "false";
+          $scope.forwardCredentials = data.authentication["forwardCredentials"] === true ? "true" : "false";

Review comment:
       *W069:*  ['forwardCredentials'] is better written in dot notation.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/security.js
##########
@@ -0,0 +1,1124 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cookies, $window, Constants, System, Security) {
+  $scope.resetMenu("security", Constants.IS_ROOT_PAGE);
+
+  $scope.params = [];
+
+  var strongPasswordRegex = /^(?=.*[0-9])(?=.*[!@#$%^&*\-_()[\]])[a-zA-Z0-9!@#$%^&*\-_()[\]]{8,30}$/;
+
+  function toList(str) {
+    if (Array.isArray(str)) {
+      return str; // already a list
+    }
+    return str.trim().split(",").map(s => s.trim()).filter(s => s !== "");
+  }
+
+  function asList(listOrStr) {
+    return Array.isArray(listOrStr) ? listOrStr : (listOrStr ? [listOrStr] : []);
+  }
+
+  function transposeUserRoles(userRoles) {
+    var roleUsers = {};
+    for (var u in userRoles) {
+      var roleList = asList(userRoles[u]);
+      for (var i in roleList) {
+        var role = roleList[i];
+        if (!roleUsers[role]) roleUsers[role] = []
+        roleUsers[role].push(u);
+      }
+    }
+
+    var roles = [];
+    for (var r in roleUsers) {
+      roles.push({"name":r, "users":Array.from(new Set(roleUsers[r]))});
+    }
+    return roles.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  function roleMatch(roles, rolesForUser) {
+    for (r in rolesForUser) {
+      if (roles.includes(rolesForUser[r]))
+        return true;
+    }
+    return false;
+  }
+
+  function permRow(perm, i) {
+    var roles = asList(perm.role);
+    var paths = asList(perm.path);
+
+    var collectionNames = "";
+    var collections = [];
+    if ("collection" in perm) {
+      if (perm["collection"] == null) {
+        collectionNames = "null";
+      } else {
+        collections = asList(perm.collection);
+        collectionNames = collections.sort().join(", ");
+      }
+    } else {
+      // no collection property on the perm, so the default "*" applies
+      collectionNames = "";
+      collections.push("*");
+    }
+
+    var method = asList(perm.method);
+
+    // perms don't always have an index ?!?
+    var index = "index" in perm ? perm["index"] : ""+i;
+
+    return { "index": index, "name": perm.name, "collectionNames": collectionNames, "collections": collections,
+      "roles": roles, "paths": paths, "method": method, "params": perm.params };
+  }
+
+  function checkError(data) {
+    var cause = null;
+    if ("errorMessages" in data && Array.isArray(data["errorMessages"]) && data["errorMessages"].length > 0) {
+      cause = "?";
+      if ("errorMessages" in data["errorMessages"][0] && Array.isArray(data["errorMessages"][0]["errorMessages"]) && data["errorMessages"][0]["errorMessages"].length > 0) {
+        cause = data["errorMessages"][0]["errorMessages"][0];
+      }
+    }
+    return cause;
+  }
+
+  function truncateTo(str, maxLen, delim) {
+    // allow for a little on either side of maxLen for better display
+    var varLen = Math.min(Math.round(maxLen * 0.1), 15);
+    if (str.length <= maxLen + varLen) {
+      return str;
+    }
+
+    var total = str.split(delim).length;
+    var at = str.indexOf(delim, maxLen - varLen);
+    str = (at !== -1 && at < maxLen + varLen) ? str.substring(0, at) : str.substring(0, maxLen);
+    var trimmed = str.split(delim).length;
+    var diff = total - trimmed;
+    str += " ... "+(diff > 1 ? "(+"+diff+" more)" : "");
+    return str;
+  }
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.securityAPIError;
+    delete $scope.securityAPIErrorDetails;
+  };
+
+  $scope.displayList = function(listOrStr) {
+    if (!listOrStr) return "";
+    var str = Array.isArray(listOrStr) ? listOrStr.sort().join(", ") : (""+listOrStr).trim();
+    return truncateTo(str, 160, ", ");
+  };
+
+  $scope.displayParams = function(obj) {
+    if (!obj) return "";
+    if (Array.isArray(obj)) return obj.sort().join(", ");
+
+    var display = "";
+    for (const [key, value] of Object.entries(obj)) {
+      if (display.length > 0) display += "; ";
+      display += (key + "=" + (Array.isArray(value)?value.sort().join(","):value+""));
+    }
+    return truncateTo(display, 160, "; ");
+  };
+
+  $scope.displayRoles = function(obj) {
+    return (!obj || (Array.isArray(obj) && obj.length === 0)) ? "null" : $scope.displayList(obj);
+  };
+
+  $scope.predefinedPermissions = ["collection-admin-edit", "collection-admin-read", "core-admin-read", "core-admin-edit", "zk-read",
+    "read", "update", "all", "config-edit", "config-read", "schema-read", "schema-edit", "security-edit", "security-read",
+    "metrics-read", "filestore-read", "filestore-write", "package-edit", "package-read"].sort();
+
+  $scope.predefinedPermissionCollection = {"read":"*", "update":"*", "config-edit":"*", "config-read":"*", "schema-edit":"*", "schema-read":"*"};
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error && error.msg) {
+      $scope.securityAPIError = error.msg;
+      $scope.securityAPIErrorDetails = e.data.errorDetails;
+    } else if (e.data && e.data.message) {
+      $scope.securityAPIError = e.data.message;
+      $scope.securityAPIErrorDetails = JSON.stringify(e.data);
+    }
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.hideAll();
+
+    $scope.tls = false;
+    $scope.blockUnknown = "false"; // default setting
+    $scope.realmName = "solr";
+    $scope.forwardCredentials = "false";
+
+    $scope.currentUser = sessionStorage.getItem("auth.username");
+
+    $scope.userFilter = "";
+    $scope.userFilterOption = "";
+    $scope.userFilterText = "";
+    $scope.userFilterOptions = [];
+
+    $scope.permFilter = "";
+    $scope.permFilterOption = "";
+    $scope.permFilterOptions = [];
+    $scope.permFilterTypes = ["", "name", "role", "path", "collection"];
+
+    System.get(function(data) {
+      $scope.tls = data.security ? data.security["tls"] : false;
+      $scope.authenticationPlugin = data.security ? data.security["authenticationPlugin"] : null;
+      $scope.authorizationPlugin = data.security ? data.security["authorizationPlugin"] : null;
+      $scope.myRoles = data.security ? data.security["roles"] : [];
+      $scope.isSecurityAdminEnabled = $scope.authenticationPlugin != null;
+      $scope.isCloudMode = data.mode.match( /solrcloud/i ) != null;
+      $scope.zkHost = $scope.isCloudMode ? data["zkHost"] : "";
+      $scope.solrHome = data["solr_home"];
+      $scope.refreshSecurityPanel();
+    }, function(e) {
+      if (e.status === 401 || e.status === 403) {
+        $scope.isSecurityAdminEnabled = true;
+        $scope.hasSecurityEditPerm = false;
+        $scope.hideAll();
+      }
+    });
+  };
+
+  $scope.hideAll = function () {
+    // add more dialogs here
+    delete $scope.validationError;
+    $scope.showUserDialog = false;
+    $scope.showPermDialog = false;
+    delete $scope.helpId;
+  };
+
+  $scope.getCurrentUserRoles = function() {
+    if ($scope.manageUserRolesEnabled) {
+      return Array.isArray($scope.userRoles[$scope.currentUser]) ? $scope.userRoles[$scope.currentUser] : [$scope.userRoles[$scope.currentUser]];
+    } else {
+      return $scope.myRoles;
+    }
+  };
+
+  $scope.hasPermission = function(permissionName) {
+    var rolesForPermission = $scope.permissionsTable.filter(p => permissionName === p.name).flatMap(p => p.roles);
+    return (rolesForPermission.length > 0 && roleMatch(rolesForPermission, $scope.getCurrentUserRoles()));
+  };
+
+  $scope.refreshSecurityPanel = function() {
+
+    // determine if the authorization plugin supports CRUD permissions
+    $scope.managePermissionsEnabled =
+        ($scope.authorizationPlugin === "org.apache.solr.security.RuleBasedAuthorizationPlugin" ||
+            $scope.authorizationPlugin === "org.apache.solr.security.ExternalRoleRuleBasedAuthorizationPlugin");
+
+    // don't allow CRUD on roles if using external
+    $scope.manageUserRolesEnabled = $scope.authorizationPlugin === "org.apache.solr.security.RuleBasedAuthorizationPlugin";
+
+    Security.get({path: "authorization"}, function (data) {
+      if (!data.authorization) {
+        $scope.isSecurityAdminEnabled = false;
+        $scope.hasSecurityEditPerm = false;
+        return;
+      }
+
+      if ($scope.manageUserRolesEnabled) {
+        $scope.userRoles = data.authorization["user-role"];
+        $scope.roles = transposeUserRoles($scope.userRoles);
+        $scope.filteredRoles = $scope.roles;
+        $scope.roleNames = $scope.roles.map(r => r.name).sort();
+        $scope.roleNamesWithWildcard = ["*"].concat($scope.roleNames);
+        if (!$scope.permFilterTypes.includes("user")) {
+          $scope.permFilterTypes.push("user"); // can only filter perms by user if we have a role to user mapping
+        }
+      } else {
+        $scope.userRoles = {};
+        $scope.roles = [];
+        $scope.filteredRoles = [];
+        $scope.roleNames = [];
+      }
+
+      $scope.permissions = data.authorization["permissions"];
+      $scope.permissionsTable = [];
+      for (p in $scope.permissions) {
+        $scope.permissionsTable.push(permRow($scope.permissions[p], parseInt(p)+1));
+      }
+      $scope.filteredPerms = $scope.permissionsTable;
+
+      $scope.hasSecurityEditPerm = $scope.hasPermission("security-edit");
+      $scope.hasSecurityReadPerm = $scope.hasSecurityEditPerm || $scope.hasPermission("security-read");
+
+      if ($scope.authenticationPlugin === "org.apache.solr.security.BasicAuthPlugin") {
+        $scope.manageUsersEnabled = true;
+
+        Security.get({path: "authentication"}, function (data) {
+          if (!data.authentication) {
+            // TODO: error msg
+            $scope.manageUsersEnabled = false;
+          }
+
+          $scope.blockUnknown = data.authentication["blockUnknown"] === true ? "true" : "false";
+          $scope.forwardCredentials = data.authentication["forwardCredentials"] === true ? "true" : "false";
+
+          if ("realm" in data.authentication) {
+            $scope.realmName = data.authentication["realm"];
+          }
+
+          var users = [];
+          if (data.authentication.credentials) {
+            for (var u in data.authentication.credentials) {
+              var roles = $scope.userRoles[u];
+              if (!roles) roles = [];
+              users.push({"username":u, "roles":roles});
+            }
+          }
+          $scope.users = users.sort((a, b) => (a.username > b.username) ? 1 : -1);
+          $scope.filteredUsers = $scope.users.slice(0,100); // only display first 100
+        }, $scope.errorHandler);
+      } else {
+        $scope.users = [];
+        $scope.filteredUsers = $scope.users;
+        $scope.manageUsersEnabled = false;
+      }
+    }, $scope.errorHandler);
+  };
+
+  $scope.validatePassword = function() {
+    var password = $scope.upsertUser.password.trim();
+    var password2 = $scope.upsertUser.password2 ? $scope.upsertUser.password2.trim() : "";
+    if (password !== password2) {
+      $scope.validationError = "Passwords do not match!";
+      return false;
+    }
+
+    if (!password.match(strongPasswordRegex)) {
+      $scope.validationError = "Password not strong enough! Must contain at least one lowercase letter, one uppercase letter, one digit, and one of these special characters: !@#$%^&*_-[]()";
+      return false;
+    }
+
+    return true;
+  };
+
+  $scope.updateUserRoles = function() {
+    var setUserRoles = {};
+    var roles = [];
+    if ($scope.upsertUser.selectedRoles) {
+      roles = roles.concat($scope.upsertUser.selectedRoles);
+    }
+    if ($scope.upsertUser.newRole && $scope.upsertUser.newRole.trim() !== "") {
+      var newRole = $scope.upsertUser.newRole.trim();
+      if (newRole !== "null" && newRole !== "*" && newRole.length <= 30) {
+        roles.push(newRole);
+      } // else, no new role for you!
+    }
+    var userRoles = Array.from(new Set(roles));
+    setUserRoles[$scope.upsertUser.username] = userRoles.length > 0 ? userRoles : null;
+    Security.post({path: "authorization"}, { "set-user-role": setUserRoles }, function (data) {
+      $scope.toggleUserDialog();
+      $scope.refreshSecurityPanel();
+    });
+  };
+
+  $scope.doUpsertUser = function() {
+    if (!$scope.upsertUser) {
+      delete $scope.validationError;
+      $scope.showUserDialog = false;
+      return;
+    }
+
+    if (!$scope.upsertUser.username || $scope.upsertUser.username.trim() === "") {
+      $scope.validationError = "Username is required!";
+      return;
+    }
+
+    // keep username to a reasonable length? but allow for email addresses
+    var username = $scope.upsertUser.username.trim();
+    if (username.length > 30) {
+      $scope.validationError = "Username must be 30 characters or less!";
+      return;
+    }
+
+    var doSetUser = false;
+    if ($scope.userDialogMode === 'add') {
+      if ($scope.users) {
+        var existing = $scope.users.find(u => u.username === username);
+        if (existing) {
+          $scope.validationError = "User '"+username+"' already exists!";
+          return;
+        }
+      }
+
+      if (!$scope.upsertUser.password) {
+        $scope.validationError = "Password is required!";
+        return;
+      }
+
+      if (!$scope.validatePassword()) {
+        return;
+      }
+      doSetUser = true;
+    } else {
+      if ($scope.upsertUser.password) {
+        if ($scope.validatePassword()) {
+          doSetUser = true;
+        } else {
+          return; // update to password is invalid
+        }
+      } // else no update to password
+    }
+
+    if ($scope.upsertUser.newRole && $scope.upsertUser.newRole.trim() !== "") {
+      var newRole = $scope.upsertUser.newRole.trim();
+      if (newRole === "null" || newRole === "*" || newRole.length > 30) {
+        $scope.validationError = "Invalid new role: "+newRole;
+        return;
+      }
+    }
+
+    delete $scope.validationError;
+
+    if (doSetUser) {
+      var setUserJson = {};
+      setUserJson[username] = $scope.upsertUser.password.trim();
+      Security.post({path: "authentication"}, { "set-user": setUserJson }, function (data) {
+
+        var errorCause = checkError(data);
+        if (errorCause != null) {
+          $scope.securityAPIError = "create user "+username+" failed due to: "+errorCause;
+          $scope.securityAPIErrorDetails = JSON.stringify(data);
+          return;
+        }
+
+        $scope.updateUserRoles();
+      });
+    } else {
+      $scope.updateUserRoles();
+    }
+  };
+
+  $scope.confirmDeleteUser = function() {
+    if (window.confirm("Confirm delete the '"+$scope.upsertUser.username+"' user?")) {
+      // remove all roles for the user and the delete the user
+      var removeRoles = {};
+      removeRoles[$scope.upsertUser.username] = null;
+      Security.post({path: "authorization"}, { "set-user-role": removeRoles }, function (data) {
+        Security.post({path: "authentication"}, {"delete-user": [$scope.upsertUser.username]}, function (data2) {
+          $scope.toggleUserDialog();
+          $scope.refreshSecurityPanel();
+        });
+      });
+    }
+  };
+
+  $scope.showAddUserDialog = function() {
+    $scope.userDialogMode = "add";
+    $scope.userDialogHeader = "Add New User";
+    $scope.userDialogAction = "Add User";
+    $scope.upsertUser = {};
+    $scope.toggleUserDialog();
+  };
+
+  $scope.toggleUserDialog = function() {
+    if ($scope.showUserDialog) {
+      delete $scope.upsertUser;
+      delete $scope.validationError;
+      $scope.showUserDialog = false;
+      return;
+    }
+
+    $scope.hideAll();
+    $('#user-dialog').css({left: 132, top: 132});
+    $scope.showUserDialog = true;
+  };
+
+  $scope.onPredefinedChanged = function() {
+    if (!$scope.upsertPerm) {
+      return;
+    }
+
+    if ($scope.upsertPerm.name && $scope.upsertPerm.name.trim() !== "") {
+      delete $scope.selectedPredefinedPermission;
+    } else {
+      $scope.upsertPerm.name = "";
+    }
+
+    if ($scope.selectedPredefinedPermission && $scope.selectedPredefinedPermission in $scope.predefinedPermissionCollection) {
+      $scope.upsertPerm.collection = $scope.predefinedPermissionCollection[$scope.selectedPredefinedPermission];
+    }
+
+    $scope.isPermFieldDisabled = ($scope.upsertPerm.name === "" && $scope.selectedPredefinedPermission);
+  };
+
+  $scope.showAddPermDialog = function() {
+    $scope.permDialogMode = "add";
+    $scope.permDialogHeader = "Add New Permission";
+    $scope.permDialogAction = "Add Permission";
+    $scope.upsertPerm = {};
+    $scope.upsertPerm.name = "";
+    $scope.upsertPerm.index = "";
+    $scope.upsertPerm["method"] = {"get":"true", "post":"true", "put":"true", "delete":"true"};
+    $scope.isPermFieldDisabled = false;
+    delete $scope.selectedPredefinedPermission;
+
+    $scope.params = [{"name":"", "value":""}];
+
+    var permissionNames = $scope.permissions.map(p => p.name);
+    $scope.filteredPredefinedPermissions = $scope.predefinedPermissions.filter(p => !permissionNames.includes(p));
+
+    $scope.togglePermDialog();
+  };
+
+  $scope.togglePermDialog = function() {
+    if ($scope.showPermDialog) {
+      delete $scope.upsertPerm;
+      delete $scope.validationError;
+      $scope.showPermDialog = false;
+      $scope.isPermFieldDisabled = false;
+      delete $scope.selectedPredefinedPermission;
+      return;
+    }
+
+    $scope.hideAll();
+
+    var leftPos = $scope.permDialogMode === "add" ? 500 : 100;
+    var topPos = $('#permissions').offset().top - 320;
+    if (topPos < 0) topPos = 0;
+    $('#add-permission-dialog').css({left: leftPos, top: topPos});
+
+    $scope.showPermDialog = true;
+  };
+
+  $scope.getMethods = function() {
+    var methods = [];
+    if ($scope.upsertPerm.method.get === "true") {
+      methods.push("GET");
+    }
+    if ($scope.upsertPerm.method.put === "true") {
+      methods.push("PUT");
+    }
+    if ($scope.upsertPerm.method.post === "true") {
+      methods.push("POST");
+    }
+    if ($scope.upsertPerm.method.delete === "true") {
+      methods.push("DELETE");
+    }
+    return methods;
+  };
+
+  $scope.confirmDeletePerm = function() {
+    var permName = $scope.selectedPredefinedPermission ? $scope.selectedPredefinedPermission : $scope.upsertPerm.name.trim();
+    if (window.confirm("Confirm delete the '"+permName+"' permission?")) {
+      var index = parseInt($scope.upsertPerm.index);
+      Security.post({path: "authorization"}, { "delete-permission": index }, function (data) {
+        $scope.togglePermDialog();
+        $scope.refreshSecurityPanel();
+      });
+    }
+  };
+
+  $scope.doUpsertPermission = function() {
+    if (!$scope.upsertPerm) {
+      $scope.upsertPerm = {};
+    }
+
+    var isAdd = $scope.permDialogMode === "add";
+    var name = $scope.selectedPredefinedPermission ? $scope.selectedPredefinedPermission : $scope.upsertPerm.name.trim();
+
+    if (isAdd) {
+      if (!name) {
+        $scope.validationError = "Either select a predefined permission or provide a name for a custom permission";
+        return;
+      }
+      var permissionNames = $scope.permissions.map(p => p.name);
+      if (permissionNames.includes(name)) {
+        $scope.validationError = "Permission '"+name+"' already exists!";
+        return;
+      }
+
+      if (name === "*") {
+        $scope.validationError = "Invalid permission name!";
+        return;
+      }
+    }
+
+    var role = null;
+    if ($scope.manageUserRolesEnabled) {
+      role = $scope.upsertPerm.selectedRoles;
+      if (!role || role.length === 0) {
+        role = null;
+      } else if (role.includes("*")) {
+        role = ["*"];
+      }
+    } else if ($scope.upsertPerm.manualRoles && $scope.upsertPerm.manualRoles.trim() !== "") {
+      var manualRoles = $scope.upsertPerm.manualRoles.trim();
+      role = (manualRoles === "null") ? null : toList(manualRoles);
+    }
+
+    var setPermJson = {"name": name, "role": role };
+
+    if ($scope.selectedPredefinedPermission) {
+      $scope.params = [{"name":"","value":""}];
+    } else {
+      // collection
+      var coll = null;
+      if ($scope.upsertPerm.collection != null && $scope.upsertPerm.collection !== "null") {
+        if ($scope.upsertPerm.collection === "*") {
+          coll = "*";
+        } else {
+          coll = $scope.upsertPerm.collection && $scope.upsertPerm.collection.trim() !== "" ? toList($scope.upsertPerm.collection) : "";
+        }
+      }
+      setPermJson["collection"] = coll;
+
+      // path
+      if (!$scope.upsertPerm.path || (Array.isArray($scope.upsertPerm.path) && $scope.upsertPerm.path.length === 0)) {
+        $scope.validationError = "Path is required for custom permissions!";
+        return;
+      }
+
+      setPermJson["path"] = toList($scope.upsertPerm.path);
+
+      if ($scope.upsertPerm.method) {
+        var methods = $scope.getMethods();
+        if (methods.length === 0) {
+          $scope.validationError = "Must specify at least one request method for a custom permission!";
+          return;
+        }
+
+        if (methods.length < 4) {
+          setPermJson["method"] = methods;
+        } // else no need to specify, rule applies to all methods
+      }
+
+      // params
+      var params = {};
+      if ($scope.params && $scope.params.length > 0) {
+        for (i in $scope.params) {
+          var p = $scope.params[i];
+          var name = p.name.trim();
+          if (name !== "" && p.value) {
+            if (name in params) {
+              params[name].push(p.value);
+            } else {
+              params[name] = [p.value];
+            }
+          }
+        }
+      }
+      setPermJson["params"] = params;
+    }
+
+    var indexUpdated = false;
+    if ($scope.upsertPerm.index) {
+      var indexOrBefore = isAdd ? "before" : "index";
+      var indexInt = parseInt($scope.upsertPerm.index);
+      if (indexInt < 1) indexInt = 1;
+      if (indexInt >= $scope.permissions.length) indexInt = null;
+      if (indexInt != null) {
+        setPermJson[indexOrBefore] = indexInt;
+      }
+      indexUpdated = (!isAdd && indexInt !== parseInt($scope.upsertPerm.originalIndex));
+    }
+
+    if (indexUpdated) {
+      // changing position is a delete + re-add in new position
+      Security.post({path: "authorization"}, { "delete-permission": parseInt($scope.upsertPerm.originalIndex) }, function (remData) {
+        if (setPermJson.index) {
+          var before = setPermJson.index;
+          delete setPermJson.index;
+          setPermJson["before"] = before;
+        }
+
+        // add perm back in new position
+        Security.post({path: "authorization"}, { "set-permission": setPermJson }, function (data) {
+          var errorCause = checkError(data);
+          if (errorCause != null) {
+            $scope.securityAPIError = "set-permission "+name+" failed due to: "+errorCause;
+            $scope.securityAPIErrorDetails = JSON.stringify(data);
+            return;
+          }
+          $scope.togglePermDialog();
+          $scope.refreshSecurityPanel();
+        });
+      });
+    } else {
+      var action = isAdd ? "set-permission" : "update-permission";
+      var postBody = {};
+      postBody[action] = setPermJson;
+      Security.post({path: "authorization"}, postBody, function (data) {
+        var errorCause = checkError(data);
+        if (errorCause != null) {
+          $scope.securityAPIError = action+" "+name+" failed due to: "+errorCause;
+          $scope.securityAPIErrorDetails = JSON.stringify(data);
+          return;
+        }
+
+        $scope.togglePermDialog();
+        $scope.refreshSecurityPanel();
+      });
+    }
+  };
+
+  $scope.applyUserFilter = function() {
+    $scope.userFilterText = "";
+    $scope.userFilterOption = "";
+    $scope.userFilterOptions = [];
+    $scope.filteredUsers = $scope.users; // reset the filtered when the filter type changes
+
+    if ($scope.userFilter === "name" || $scope.userFilter === "path") {
+      // no-op: filter is text input
+    } else if ($scope.userFilter === "role") {
+      $scope.userFilterOptions = $scope.roleNames;
+    } else if ($scope.userFilter === "perm") {
+      $scope.userFilterOptions = $scope.permissions.map(p => p.name).sort();
+    } else {
+      $scope.userFilter = "";
+    }
+  };
+
+  $scope.onUserFilterTextChanged = function() {
+    // don't fire until we have at least 2 chars ...
+    if ($scope.userFilterText && $scope.userFilterText.trim().length >= 2) {
+      $scope.userFilterOption = $scope.userFilterText.toLowerCase();
+      $scope.onUserFilterOptionChanged();
+    } else {
+      $scope.filteredUsers = $scope.users;
+    }
+  };
+
+  function pathMatch(paths, filter) {
+    for (p in paths) {
+      if (paths[p].includes(filter)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  $scope.onUserFilterOptionChanged = function() {
+    var filter = $scope.userFilterOption ? $scope.userFilterOption.trim() : "";
+    if (filter.length === 0) {
+      $scope.filteredUsers = $scope.users;
+      return;
+    }
+
+    if ($scope.userFilter === "name") {
+      $scope.filteredUsers = $scope.users.filter(u => u.username.toLowerCase().includes(filter));
+    } else if ($scope.userFilter === "role") {
+      $scope.filteredUsers = $scope.users.filter(u => u.roles.includes(filter));
+    } else if ($scope.userFilter === "path") {
+      var rolesForPath = Array.from(new Set($scope.permissionsTable.filter(p => p.roles && pathMatch(p.paths, filter)).flatMap(p => p.roles)));
+      var usersForPath = Array.from(new Set($scope.roles.filter(r => r.users && r.users.length > 0 && rolesForPath.includes(r.name)).flatMap(r => r.users)));
+      $scope.filteredUsers = $scope.users.filter(u => usersForPath.includes(u.username));
+    } else if ($scope.userFilter === "perm") {
+      var rolesForPerm = Array.from(new Set($scope.permissionsTable.filter(p => p.name === filter).flatMap(p => p.roles)));
+      var usersForPerm = Array.from(new Set($scope.roles.filter(r => r.users && r.users.length > 0 && rolesForPerm.includes(r.name)).flatMap(r => r.users)));
+      $scope.filteredUsers = $scope.users.filter(u => usersForPerm.includes(u.username));
+    } else {
+      // reset
+      $scope.userFilter = "";
+      $scope.userFilterOption = "";
+      $scope.userFilterText = "";
+      $scope.filteredUsers = $scope.users;
+    }
+  };
+
+  $scope.applyPermFilter = function() {
+    $scope.permFilterText = "";
+    $scope.permFilterOption = "";
+    $scope.permFilterOptions = [];
+    $scope.filteredPerms = $scope.permissionsTable;
+
+    if ($scope.permFilter === "name" || $scope.permFilter === "path") {
+      // no-op: filter is text input
+    } else if ($scope.permFilter === "role") {
+      var roles = $scope.manageUserRolesEnabled ? $scope.roleNames : Array.from(new Set($scope.permissionsTable.flatMap(p => p.roles))).sort();
+      $scope.permFilterOptions = ["*", "null"].concat(roles);
+    } else if ($scope.permFilter === "user") {
+      $scope.permFilterOptions = Array.from(new Set($scope.roles.flatMap(r => r.users))).sort();
+    } else if ($scope.permFilter === "collection") {
+      $scope.permFilterOptions = Array.from(new Set($scope.permissionsTable.flatMap(p => p.collections))).sort();
+      $scope.permFilterOptions.push("null");
+    } else {
+      // no perm filtering
+      $scope.permFilter = "";
+    }
+  };
+
+  $scope.onPermFilterTextChanged = function() {
+    // don't fire until we have at least 2 chars ...
+    if ($scope.permFilterText && $scope.permFilterText.trim().length >= 2) {
+      $scope.permFilterOption = $scope.permFilterText.trim().toLowerCase();
+      $scope.onPermFilterOptionChanged();
+    } else {
+      $scope.filteredPerms = $scope.permissionsTable;
+    }
+  };
+
+  $scope.onPermFilterOptionChanged = function() {
+    var filterCriteria = $scope.permFilterOption ? $scope.permFilterOption.trim() : "";
+    if (filterCriteria.length === 0) {
+      $scope.filteredPerms = $scope.permissionsTable;
+      return;
+    }
+
+    if ($scope.permFilter === "name") {
+      $scope.filteredPerms = $scope.permissionsTable.filter(p => p.name.toLowerCase().includes(filterCriteria));
+    } else if ($scope.permFilter === "role") {
+      if (filterCriteria === "null") {
+        $scope.filteredPerms = $scope.permissionsTable.filter(p => p.roles.length === 0);
+      } else {
+        $scope.filteredPerms = $scope.permissionsTable.filter(p => p.roles.includes(filterCriteria));
+      }
+    } else if ($scope.permFilter === "path") {
+      $scope.filteredPerms = $scope.permissionsTable.filter(p => pathMatch(p.paths, filterCriteria));
+    } else if ($scope.permFilter === "user") {
+      // get the user's roles and then find all the permissions mapped to each role
+      var rolesForUser = $scope.roles.filter(r => r.users.includes(filterCriteria)).map(r => r.name);
+      $scope.filteredPerms = $scope.permissionsTable.filter(p => p.roles.length > 0 && roleMatch(p.roles, rolesForUser));
+    } else if ($scope.permFilter === "collection") {
+      function collectionMatch(collNames, colls, filter) {
+        return (filter === "null") ?collNames === "null" : colls.includes(filter);
+      }
+      $scope.filteredPerms = $scope.permissionsTable.filter(p => collectionMatch(p.collectionNames, p.collections, filterCriteria));
+    } else {
+      // reset
+      $scope.permFilter = "";
+      $scope.permFilterOption = "";
+      $scope.permFilterText = "";
+      $scope.filteredPerms = $scope.permissionsTable;
+    }
+  };
+
+  $scope.editUser = function(row) {
+    if (!row || !$scope.hasSecurityEditPerm) {
+      return;
+    }
+
+    var userId = row.username;
+    $scope.userDialogMode = "edit";
+    $scope.userDialogHeader = "Edit User: "+userId;
+    $scope.userDialogAction = "Update";
+    var userRoles = userId in $scope.userRoles ? $scope.userRoles[userId] : [];
+    if (!Array.isArray(userRoles)) {
+      userRoles = [userRoles];
+    }
+
+    $scope.upsertUser = { username: userId, selectedRoles: userRoles };
+    $scope.toggleUserDialog();
+  };
+
+  function buildMethods(m) {
+    return {"get":""+m.includes("GET"), "post":""+m.includes("POST"), "put":""+m.includes("PUT"), "delete":""+m.includes("DELETE")};
+  }
+
+  $scope.editPerm = function(row) {
+    if (!$scope.managePermissionsEnabled || !$scope.hasSecurityEditPerm || !row) {
+      return;
+    }
+
+    var name = row.name;
+    $scope.permDialogMode = "edit";
+    $scope.permDialogHeader = "Edit Permission: "+name;
+    $scope.permDialogAction = "Update";
+
+    var perm = $scope.permissionsTable.find(p => p.name === name);
+    var isPredefined = $scope.predefinedPermissions.includes(name);
+    if (isPredefined) {
+      $scope.selectedPredefinedPermission = name;
+      $scope.upsertPerm = { };
+      $scope.filteredPredefinedPermissions = [];
+      $scope.filteredPredefinedPermissions.push(name);
+      if ($scope.selectedPredefinedPermission && $scope.selectedPredefinedPermission in $scope.predefinedPermissionCollection) {
+        $scope.upsertPerm.collection = $scope.predefinedPermissionCollection[$scope.selectedPredefinedPermission];
+      }
+      $scope.isPermFieldDisabled = true;
+    } else {
+      $scope.upsertPerm = { name: name, collection: perm.collectionNames, path: perm.paths };
+      $scope.params = [];
+      if (perm.params) {
+        for (const [key, value] of Object.entries(perm.params)) {
+          if (Array.isArray(value)) {
+            for (i in value) {
+              $scope.params.push({"name":key, "value":value[i]});
+            }
+          } else {
+            $scope.params.push({"name":key, "value":value});
+          }
+        }
+      }
+      if ($scope.params.length === 0) {
+        $scope.params = [{"name":"","value":""}];
+      }
+
+      $scope.upsertPerm["method"] = perm.method.length === 0 ? {"get":"true", "post":"true", "put":"true", "delete":"true"} : buildMethods(perm.method);
+      $scope.isPermFieldDisabled = false;
+      delete $scope.selectedPredefinedPermission;
+    }
+
+    $scope.upsertPerm.index = perm["index"];
+    $scope.upsertPerm.originalIndex = perm["index"];

Review comment:
       *W069:*  ['index'] is better written in dot notation.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/security.js
##########
@@ -0,0 +1,1124 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cookies, $window, Constants, System, Security) {
+  $scope.resetMenu("security", Constants.IS_ROOT_PAGE);
+
+  $scope.params = [];
+
+  var strongPasswordRegex = /^(?=.*[0-9])(?=.*[!@#$%^&*\-_()[\]])[a-zA-Z0-9!@#$%^&*\-_()[\]]{8,30}$/;
+
+  function toList(str) {
+    if (Array.isArray(str)) {
+      return str; // already a list
+    }
+    return str.trim().split(",").map(s => s.trim()).filter(s => s !== "");
+  }
+
+  function asList(listOrStr) {
+    return Array.isArray(listOrStr) ? listOrStr : (listOrStr ? [listOrStr] : []);
+  }
+
+  function transposeUserRoles(userRoles) {
+    var roleUsers = {};
+    for (var u in userRoles) {
+      var roleList = asList(userRoles[u]);
+      for (var i in roleList) {
+        var role = roleList[i];
+        if (!roleUsers[role]) roleUsers[role] = []
+        roleUsers[role].push(u);
+      }
+    }
+
+    var roles = [];
+    for (var r in roleUsers) {
+      roles.push({"name":r, "users":Array.from(new Set(roleUsers[r]))});
+    }
+    return roles.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  function roleMatch(roles, rolesForUser) {
+    for (r in rolesForUser) {
+      if (roles.includes(rolesForUser[r]))
+        return true;
+    }
+    return false;
+  }
+
+  function permRow(perm, i) {
+    var roles = asList(perm.role);
+    var paths = asList(perm.path);
+
+    var collectionNames = "";
+    var collections = [];
+    if ("collection" in perm) {
+      if (perm["collection"] == null) {
+        collectionNames = "null";
+      } else {
+        collections = asList(perm.collection);
+        collectionNames = collections.sort().join(", ");
+      }
+    } else {
+      // no collection property on the perm, so the default "*" applies
+      collectionNames = "";
+      collections.push("*");
+    }
+
+    var method = asList(perm.method);
+
+    // perms don't always have an index ?!?
+    var index = "index" in perm ? perm["index"] : ""+i;
+
+    return { "index": index, "name": perm.name, "collectionNames": collectionNames, "collections": collections,
+      "roles": roles, "paths": paths, "method": method, "params": perm.params };
+  }
+
+  function checkError(data) {
+    var cause = null;
+    if ("errorMessages" in data && Array.isArray(data["errorMessages"]) && data["errorMessages"].length > 0) {
+      cause = "?";
+      if ("errorMessages" in data["errorMessages"][0] && Array.isArray(data["errorMessages"][0]["errorMessages"]) && data["errorMessages"][0]["errorMessages"].length > 0) {
+        cause = data["errorMessages"][0]["errorMessages"][0];
+      }
+    }
+    return cause;
+  }
+
+  function truncateTo(str, maxLen, delim) {
+    // allow for a little on either side of maxLen for better display
+    var varLen = Math.min(Math.round(maxLen * 0.1), 15);
+    if (str.length <= maxLen + varLen) {
+      return str;
+    }
+
+    var total = str.split(delim).length;
+    var at = str.indexOf(delim, maxLen - varLen);
+    str = (at !== -1 && at < maxLen + varLen) ? str.substring(0, at) : str.substring(0, maxLen);
+    var trimmed = str.split(delim).length;
+    var diff = total - trimmed;
+    str += " ... "+(diff > 1 ? "(+"+diff+" more)" : "");
+    return str;
+  }
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.securityAPIError;
+    delete $scope.securityAPIErrorDetails;
+  };
+
+  $scope.displayList = function(listOrStr) {
+    if (!listOrStr) return "";
+    var str = Array.isArray(listOrStr) ? listOrStr.sort().join(", ") : (""+listOrStr).trim();
+    return truncateTo(str, 160, ", ");
+  };
+
+  $scope.displayParams = function(obj) {
+    if (!obj) return "";
+    if (Array.isArray(obj)) return obj.sort().join(", ");
+
+    var display = "";
+    for (const [key, value] of Object.entries(obj)) {
+      if (display.length > 0) display += "; ";
+      display += (key + "=" + (Array.isArray(value)?value.sort().join(","):value+""));
+    }
+    return truncateTo(display, 160, "; ");
+  };
+
+  $scope.displayRoles = function(obj) {
+    return (!obj || (Array.isArray(obj) && obj.length === 0)) ? "null" : $scope.displayList(obj);
+  };
+
+  $scope.predefinedPermissions = ["collection-admin-edit", "collection-admin-read", "core-admin-read", "core-admin-edit", "zk-read",
+    "read", "update", "all", "config-edit", "config-read", "schema-read", "schema-edit", "security-edit", "security-read",
+    "metrics-read", "filestore-read", "filestore-write", "package-edit", "package-read"].sort();
+
+  $scope.predefinedPermissionCollection = {"read":"*", "update":"*", "config-edit":"*", "config-read":"*", "schema-edit":"*", "schema-read":"*"};
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error && error.msg) {
+      $scope.securityAPIError = error.msg;
+      $scope.securityAPIErrorDetails = e.data.errorDetails;
+    } else if (e.data && e.data.message) {
+      $scope.securityAPIError = e.data.message;
+      $scope.securityAPIErrorDetails = JSON.stringify(e.data);
+    }
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.hideAll();
+
+    $scope.tls = false;
+    $scope.blockUnknown = "false"; // default setting
+    $scope.realmName = "solr";
+    $scope.forwardCredentials = "false";
+
+    $scope.currentUser = sessionStorage.getItem("auth.username");
+
+    $scope.userFilter = "";
+    $scope.userFilterOption = "";
+    $scope.userFilterText = "";
+    $scope.userFilterOptions = [];
+
+    $scope.permFilter = "";
+    $scope.permFilterOption = "";
+    $scope.permFilterOptions = [];
+    $scope.permFilterTypes = ["", "name", "role", "path", "collection"];
+
+    System.get(function(data) {
+      $scope.tls = data.security ? data.security["tls"] : false;
+      $scope.authenticationPlugin = data.security ? data.security["authenticationPlugin"] : null;
+      $scope.authorizationPlugin = data.security ? data.security["authorizationPlugin"] : null;
+      $scope.myRoles = data.security ? data.security["roles"] : [];
+      $scope.isSecurityAdminEnabled = $scope.authenticationPlugin != null;
+      $scope.isCloudMode = data.mode.match( /solrcloud/i ) != null;
+      $scope.zkHost = $scope.isCloudMode ? data["zkHost"] : "";
+      $scope.solrHome = data["solr_home"];
+      $scope.refreshSecurityPanel();
+    }, function(e) {
+      if (e.status === 401 || e.status === 403) {
+        $scope.isSecurityAdminEnabled = true;
+        $scope.hasSecurityEditPerm = false;
+        $scope.hideAll();
+      }
+    });
+  };
+
+  $scope.hideAll = function () {
+    // add more dialogs here
+    delete $scope.validationError;
+    $scope.showUserDialog = false;
+    $scope.showPermDialog = false;
+    delete $scope.helpId;
+  };
+
+  $scope.getCurrentUserRoles = function() {
+    if ($scope.manageUserRolesEnabled) {
+      return Array.isArray($scope.userRoles[$scope.currentUser]) ? $scope.userRoles[$scope.currentUser] : [$scope.userRoles[$scope.currentUser]];
+    } else {
+      return $scope.myRoles;
+    }
+  };
+
+  $scope.hasPermission = function(permissionName) {
+    var rolesForPermission = $scope.permissionsTable.filter(p => permissionName === p.name).flatMap(p => p.roles);
+    return (rolesForPermission.length > 0 && roleMatch(rolesForPermission, $scope.getCurrentUserRoles()));
+  };
+
+  $scope.refreshSecurityPanel = function() {
+
+    // determine if the authorization plugin supports CRUD permissions
+    $scope.managePermissionsEnabled =
+        ($scope.authorizationPlugin === "org.apache.solr.security.RuleBasedAuthorizationPlugin" ||
+            $scope.authorizationPlugin === "org.apache.solr.security.ExternalRoleRuleBasedAuthorizationPlugin");
+
+    // don't allow CRUD on roles if using external
+    $scope.manageUserRolesEnabled = $scope.authorizationPlugin === "org.apache.solr.security.RuleBasedAuthorizationPlugin";
+
+    Security.get({path: "authorization"}, function (data) {
+      if (!data.authorization) {
+        $scope.isSecurityAdminEnabled = false;
+        $scope.hasSecurityEditPerm = false;
+        return;
+      }
+
+      if ($scope.manageUserRolesEnabled) {
+        $scope.userRoles = data.authorization["user-role"];
+        $scope.roles = transposeUserRoles($scope.userRoles);
+        $scope.filteredRoles = $scope.roles;
+        $scope.roleNames = $scope.roles.map(r => r.name).sort();
+        $scope.roleNamesWithWildcard = ["*"].concat($scope.roleNames);
+        if (!$scope.permFilterTypes.includes("user")) {
+          $scope.permFilterTypes.push("user"); // can only filter perms by user if we have a role to user mapping
+        }
+      } else {
+        $scope.userRoles = {};
+        $scope.roles = [];
+        $scope.filteredRoles = [];
+        $scope.roleNames = [];
+      }
+
+      $scope.permissions = data.authorization["permissions"];
+      $scope.permissionsTable = [];
+      for (p in $scope.permissions) {
+        $scope.permissionsTable.push(permRow($scope.permissions[p], parseInt(p)+1));
+      }
+      $scope.filteredPerms = $scope.permissionsTable;
+
+      $scope.hasSecurityEditPerm = $scope.hasPermission("security-edit");
+      $scope.hasSecurityReadPerm = $scope.hasSecurityEditPerm || $scope.hasPermission("security-read");
+
+      if ($scope.authenticationPlugin === "org.apache.solr.security.BasicAuthPlugin") {
+        $scope.manageUsersEnabled = true;
+
+        Security.get({path: "authentication"}, function (data) {
+          if (!data.authentication) {
+            // TODO: error msg
+            $scope.manageUsersEnabled = false;
+          }
+
+          $scope.blockUnknown = data.authentication["blockUnknown"] === true ? "true" : "false";
+          $scope.forwardCredentials = data.authentication["forwardCredentials"] === true ? "true" : "false";
+
+          if ("realm" in data.authentication) {
+            $scope.realmName = data.authentication["realm"];
+          }
+
+          var users = [];
+          if (data.authentication.credentials) {
+            for (var u in data.authentication.credentials) {
+              var roles = $scope.userRoles[u];
+              if (!roles) roles = [];
+              users.push({"username":u, "roles":roles});
+            }
+          }
+          $scope.users = users.sort((a, b) => (a.username > b.username) ? 1 : -1);
+          $scope.filteredUsers = $scope.users.slice(0,100); // only display first 100
+        }, $scope.errorHandler);
+      } else {
+        $scope.users = [];
+        $scope.filteredUsers = $scope.users;
+        $scope.manageUsersEnabled = false;
+      }
+    }, $scope.errorHandler);
+  };
+
+  $scope.validatePassword = function() {
+    var password = $scope.upsertUser.password.trim();
+    var password2 = $scope.upsertUser.password2 ? $scope.upsertUser.password2.trim() : "";
+    if (password !== password2) {
+      $scope.validationError = "Passwords do not match!";
+      return false;
+    }
+
+    if (!password.match(strongPasswordRegex)) {
+      $scope.validationError = "Password not strong enough! Must contain at least one lowercase letter, one uppercase letter, one digit, and one of these special characters: !@#$%^&*_-[]()";
+      return false;
+    }
+
+    return true;
+  };
+
+  $scope.updateUserRoles = function() {
+    var setUserRoles = {};
+    var roles = [];
+    if ($scope.upsertUser.selectedRoles) {
+      roles = roles.concat($scope.upsertUser.selectedRoles);
+    }
+    if ($scope.upsertUser.newRole && $scope.upsertUser.newRole.trim() !== "") {
+      var newRole = $scope.upsertUser.newRole.trim();
+      if (newRole !== "null" && newRole !== "*" && newRole.length <= 30) {
+        roles.push(newRole);
+      } // else, no new role for you!
+    }
+    var userRoles = Array.from(new Set(roles));
+    setUserRoles[$scope.upsertUser.username] = userRoles.length > 0 ? userRoles : null;
+    Security.post({path: "authorization"}, { "set-user-role": setUserRoles }, function (data) {
+      $scope.toggleUserDialog();
+      $scope.refreshSecurityPanel();
+    });
+  };
+
+  $scope.doUpsertUser = function() {
+    if (!$scope.upsertUser) {
+      delete $scope.validationError;
+      $scope.showUserDialog = false;
+      return;
+    }
+
+    if (!$scope.upsertUser.username || $scope.upsertUser.username.trim() === "") {
+      $scope.validationError = "Username is required!";
+      return;
+    }
+
+    // keep username to a reasonable length? but allow for email addresses
+    var username = $scope.upsertUser.username.trim();
+    if (username.length > 30) {
+      $scope.validationError = "Username must be 30 characters or less!";
+      return;
+    }
+
+    var doSetUser = false;
+    if ($scope.userDialogMode === 'add') {
+      if ($scope.users) {
+        var existing = $scope.users.find(u => u.username === username);
+        if (existing) {
+          $scope.validationError = "User '"+username+"' already exists!";
+          return;
+        }
+      }
+
+      if (!$scope.upsertUser.password) {
+        $scope.validationError = "Password is required!";
+        return;
+      }
+
+      if (!$scope.validatePassword()) {
+        return;
+      }
+      doSetUser = true;
+    } else {
+      if ($scope.upsertUser.password) {
+        if ($scope.validatePassword()) {
+          doSetUser = true;
+        } else {
+          return; // update to password is invalid
+        }
+      } // else no update to password
+    }
+
+    if ($scope.upsertUser.newRole && $scope.upsertUser.newRole.trim() !== "") {
+      var newRole = $scope.upsertUser.newRole.trim();
+      if (newRole === "null" || newRole === "*" || newRole.length > 30) {
+        $scope.validationError = "Invalid new role: "+newRole;
+        return;
+      }
+    }
+
+    delete $scope.validationError;
+
+    if (doSetUser) {
+      var setUserJson = {};
+      setUserJson[username] = $scope.upsertUser.password.trim();
+      Security.post({path: "authentication"}, { "set-user": setUserJson }, function (data) {
+
+        var errorCause = checkError(data);
+        if (errorCause != null) {
+          $scope.securityAPIError = "create user "+username+" failed due to: "+errorCause;
+          $scope.securityAPIErrorDetails = JSON.stringify(data);
+          return;
+        }
+
+        $scope.updateUserRoles();
+      });
+    } else {
+      $scope.updateUserRoles();
+    }
+  };
+
+  $scope.confirmDeleteUser = function() {
+    if (window.confirm("Confirm delete the '"+$scope.upsertUser.username+"' user?")) {
+      // remove all roles for the user and the delete the user
+      var removeRoles = {};
+      removeRoles[$scope.upsertUser.username] = null;
+      Security.post({path: "authorization"}, { "set-user-role": removeRoles }, function (data) {
+        Security.post({path: "authentication"}, {"delete-user": [$scope.upsertUser.username]}, function (data2) {
+          $scope.toggleUserDialog();
+          $scope.refreshSecurityPanel();
+        });
+      });
+    }
+  };
+
+  $scope.showAddUserDialog = function() {
+    $scope.userDialogMode = "add";
+    $scope.userDialogHeader = "Add New User";
+    $scope.userDialogAction = "Add User";
+    $scope.upsertUser = {};
+    $scope.toggleUserDialog();
+  };
+
+  $scope.toggleUserDialog = function() {
+    if ($scope.showUserDialog) {
+      delete $scope.upsertUser;
+      delete $scope.validationError;
+      $scope.showUserDialog = false;
+      return;
+    }
+
+    $scope.hideAll();
+    $('#user-dialog').css({left: 132, top: 132});
+    $scope.showUserDialog = true;
+  };
+
+  $scope.onPredefinedChanged = function() {
+    if (!$scope.upsertPerm) {
+      return;
+    }
+
+    if ($scope.upsertPerm.name && $scope.upsertPerm.name.trim() !== "") {
+      delete $scope.selectedPredefinedPermission;
+    } else {
+      $scope.upsertPerm.name = "";
+    }
+
+    if ($scope.selectedPredefinedPermission && $scope.selectedPredefinedPermission in $scope.predefinedPermissionCollection) {
+      $scope.upsertPerm.collection = $scope.predefinedPermissionCollection[$scope.selectedPredefinedPermission];
+    }
+
+    $scope.isPermFieldDisabled = ($scope.upsertPerm.name === "" && $scope.selectedPredefinedPermission);
+  };
+
+  $scope.showAddPermDialog = function() {
+    $scope.permDialogMode = "add";
+    $scope.permDialogHeader = "Add New Permission";
+    $scope.permDialogAction = "Add Permission";
+    $scope.upsertPerm = {};
+    $scope.upsertPerm.name = "";
+    $scope.upsertPerm.index = "";
+    $scope.upsertPerm["method"] = {"get":"true", "post":"true", "put":"true", "delete":"true"};
+    $scope.isPermFieldDisabled = false;
+    delete $scope.selectedPredefinedPermission;
+
+    $scope.params = [{"name":"", "value":""}];
+
+    var permissionNames = $scope.permissions.map(p => p.name);
+    $scope.filteredPredefinedPermissions = $scope.predefinedPermissions.filter(p => !permissionNames.includes(p));
+
+    $scope.togglePermDialog();
+  };
+
+  $scope.togglePermDialog = function() {
+    if ($scope.showPermDialog) {
+      delete $scope.upsertPerm;
+      delete $scope.validationError;
+      $scope.showPermDialog = false;
+      $scope.isPermFieldDisabled = false;
+      delete $scope.selectedPredefinedPermission;
+      return;
+    }
+
+    $scope.hideAll();
+
+    var leftPos = $scope.permDialogMode === "add" ? 500 : 100;
+    var topPos = $('#permissions').offset().top - 320;
+    if (topPos < 0) topPos = 0;
+    $('#add-permission-dialog').css({left: leftPos, top: topPos});
+
+    $scope.showPermDialog = true;
+  };
+
+  $scope.getMethods = function() {
+    var methods = [];
+    if ($scope.upsertPerm.method.get === "true") {
+      methods.push("GET");
+    }
+    if ($scope.upsertPerm.method.put === "true") {
+      methods.push("PUT");
+    }
+    if ($scope.upsertPerm.method.post === "true") {
+      methods.push("POST");
+    }
+    if ($scope.upsertPerm.method.delete === "true") {
+      methods.push("DELETE");
+    }
+    return methods;
+  };
+
+  $scope.confirmDeletePerm = function() {
+    var permName = $scope.selectedPredefinedPermission ? $scope.selectedPredefinedPermission : $scope.upsertPerm.name.trim();
+    if (window.confirm("Confirm delete the '"+permName+"' permission?")) {
+      var index = parseInt($scope.upsertPerm.index);
+      Security.post({path: "authorization"}, { "delete-permission": index }, function (data) {
+        $scope.togglePermDialog();
+        $scope.refreshSecurityPanel();
+      });
+    }
+  };
+
+  $scope.doUpsertPermission = function() {
+    if (!$scope.upsertPerm) {
+      $scope.upsertPerm = {};
+    }
+
+    var isAdd = $scope.permDialogMode === "add";
+    var name = $scope.selectedPredefinedPermission ? $scope.selectedPredefinedPermission : $scope.upsertPerm.name.trim();
+
+    if (isAdd) {
+      if (!name) {
+        $scope.validationError = "Either select a predefined permission or provide a name for a custom permission";
+        return;
+      }
+      var permissionNames = $scope.permissions.map(p => p.name);
+      if (permissionNames.includes(name)) {
+        $scope.validationError = "Permission '"+name+"' already exists!";
+        return;
+      }
+
+      if (name === "*") {
+        $scope.validationError = "Invalid permission name!";
+        return;
+      }
+    }
+
+    var role = null;
+    if ($scope.manageUserRolesEnabled) {
+      role = $scope.upsertPerm.selectedRoles;
+      if (!role || role.length === 0) {
+        role = null;
+      } else if (role.includes("*")) {
+        role = ["*"];
+      }
+    } else if ($scope.upsertPerm.manualRoles && $scope.upsertPerm.manualRoles.trim() !== "") {
+      var manualRoles = $scope.upsertPerm.manualRoles.trim();
+      role = (manualRoles === "null") ? null : toList(manualRoles);
+    }
+
+    var setPermJson = {"name": name, "role": role };
+
+    if ($scope.selectedPredefinedPermission) {
+      $scope.params = [{"name":"","value":""}];
+    } else {
+      // collection
+      var coll = null;
+      if ($scope.upsertPerm.collection != null && $scope.upsertPerm.collection !== "null") {
+        if ($scope.upsertPerm.collection === "*") {
+          coll = "*";
+        } else {
+          coll = $scope.upsertPerm.collection && $scope.upsertPerm.collection.trim() !== "" ? toList($scope.upsertPerm.collection) : "";
+        }
+      }
+      setPermJson["collection"] = coll;
+
+      // path
+      if (!$scope.upsertPerm.path || (Array.isArray($scope.upsertPerm.path) && $scope.upsertPerm.path.length === 0)) {
+        $scope.validationError = "Path is required for custom permissions!";
+        return;
+      }
+
+      setPermJson["path"] = toList($scope.upsertPerm.path);
+
+      if ($scope.upsertPerm.method) {
+        var methods = $scope.getMethods();
+        if (methods.length === 0) {
+          $scope.validationError = "Must specify at least one request method for a custom permission!";
+          return;
+        }
+
+        if (methods.length < 4) {
+          setPermJson["method"] = methods;
+        } // else no need to specify, rule applies to all methods
+      }
+
+      // params
+      var params = {};
+      if ($scope.params && $scope.params.length > 0) {
+        for (i in $scope.params) {
+          var p = $scope.params[i];
+          var name = p.name.trim();
+          if (name !== "" && p.value) {
+            if (name in params) {
+              params[name].push(p.value);
+            } else {
+              params[name] = [p.value];
+            }
+          }
+        }
+      }
+      setPermJson["params"] = params;
+    }
+
+    var indexUpdated = false;
+    if ($scope.upsertPerm.index) {
+      var indexOrBefore = isAdd ? "before" : "index";
+      var indexInt = parseInt($scope.upsertPerm.index);
+      if (indexInt < 1) indexInt = 1;
+      if (indexInt >= $scope.permissions.length) indexInt = null;
+      if (indexInt != null) {
+        setPermJson[indexOrBefore] = indexInt;
+      }
+      indexUpdated = (!isAdd && indexInt !== parseInt($scope.upsertPerm.originalIndex));
+    }
+
+    if (indexUpdated) {
+      // changing position is a delete + re-add in new position
+      Security.post({path: "authorization"}, { "delete-permission": parseInt($scope.upsertPerm.originalIndex) }, function (remData) {
+        if (setPermJson.index) {
+          var before = setPermJson.index;
+          delete setPermJson.index;
+          setPermJson["before"] = before;
+        }
+
+        // add perm back in new position
+        Security.post({path: "authorization"}, { "set-permission": setPermJson }, function (data) {
+          var errorCause = checkError(data);
+          if (errorCause != null) {
+            $scope.securityAPIError = "set-permission "+name+" failed due to: "+errorCause;
+            $scope.securityAPIErrorDetails = JSON.stringify(data);
+            return;
+          }
+          $scope.togglePermDialog();
+          $scope.refreshSecurityPanel();
+        });
+      });
+    } else {
+      var action = isAdd ? "set-permission" : "update-permission";
+      var postBody = {};
+      postBody[action] = setPermJson;
+      Security.post({path: "authorization"}, postBody, function (data) {
+        var errorCause = checkError(data);
+        if (errorCause != null) {
+          $scope.securityAPIError = action+" "+name+" failed due to: "+errorCause;
+          $scope.securityAPIErrorDetails = JSON.stringify(data);
+          return;
+        }
+
+        $scope.togglePermDialog();
+        $scope.refreshSecurityPanel();
+      });
+    }
+  };
+
+  $scope.applyUserFilter = function() {
+    $scope.userFilterText = "";
+    $scope.userFilterOption = "";
+    $scope.userFilterOptions = [];
+    $scope.filteredUsers = $scope.users; // reset the filtered when the filter type changes
+
+    if ($scope.userFilter === "name" || $scope.userFilter === "path") {
+      // no-op: filter is text input
+    } else if ($scope.userFilter === "role") {
+      $scope.userFilterOptions = $scope.roleNames;
+    } else if ($scope.userFilter === "perm") {
+      $scope.userFilterOptions = $scope.permissions.map(p => p.name).sort();
+    } else {
+      $scope.userFilter = "";
+    }
+  };
+
+  $scope.onUserFilterTextChanged = function() {
+    // don't fire until we have at least 2 chars ...
+    if ($scope.userFilterText && $scope.userFilterText.trim().length >= 2) {
+      $scope.userFilterOption = $scope.userFilterText.toLowerCase();
+      $scope.onUserFilterOptionChanged();
+    } else {
+      $scope.filteredUsers = $scope.users;
+    }
+  };
+
+  function pathMatch(paths, filter) {
+    for (p in paths) {
+      if (paths[p].includes(filter)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  $scope.onUserFilterOptionChanged = function() {
+    var filter = $scope.userFilterOption ? $scope.userFilterOption.trim() : "";
+    if (filter.length === 0) {
+      $scope.filteredUsers = $scope.users;
+      return;
+    }
+
+    if ($scope.userFilter === "name") {
+      $scope.filteredUsers = $scope.users.filter(u => u.username.toLowerCase().includes(filter));
+    } else if ($scope.userFilter === "role") {
+      $scope.filteredUsers = $scope.users.filter(u => u.roles.includes(filter));
+    } else if ($scope.userFilter === "path") {
+      var rolesForPath = Array.from(new Set($scope.permissionsTable.filter(p => p.roles && pathMatch(p.paths, filter)).flatMap(p => p.roles)));
+      var usersForPath = Array.from(new Set($scope.roles.filter(r => r.users && r.users.length > 0 && rolesForPath.includes(r.name)).flatMap(r => r.users)));
+      $scope.filteredUsers = $scope.users.filter(u => usersForPath.includes(u.username));
+    } else if ($scope.userFilter === "perm") {
+      var rolesForPerm = Array.from(new Set($scope.permissionsTable.filter(p => p.name === filter).flatMap(p => p.roles)));
+      var usersForPerm = Array.from(new Set($scope.roles.filter(r => r.users && r.users.length > 0 && rolesForPerm.includes(r.name)).flatMap(r => r.users)));
+      $scope.filteredUsers = $scope.users.filter(u => usersForPerm.includes(u.username));
+    } else {
+      // reset
+      $scope.userFilter = "";
+      $scope.userFilterOption = "";
+      $scope.userFilterText = "";
+      $scope.filteredUsers = $scope.users;
+    }
+  };
+
+  $scope.applyPermFilter = function() {
+    $scope.permFilterText = "";
+    $scope.permFilterOption = "";
+    $scope.permFilterOptions = [];
+    $scope.filteredPerms = $scope.permissionsTable;
+
+    if ($scope.permFilter === "name" || $scope.permFilter === "path") {
+      // no-op: filter is text input
+    } else if ($scope.permFilter === "role") {
+      var roles = $scope.manageUserRolesEnabled ? $scope.roleNames : Array.from(new Set($scope.permissionsTable.flatMap(p => p.roles))).sort();
+      $scope.permFilterOptions = ["*", "null"].concat(roles);
+    } else if ($scope.permFilter === "user") {
+      $scope.permFilterOptions = Array.from(new Set($scope.roles.flatMap(r => r.users))).sort();
+    } else if ($scope.permFilter === "collection") {
+      $scope.permFilterOptions = Array.from(new Set($scope.permissionsTable.flatMap(p => p.collections))).sort();
+      $scope.permFilterOptions.push("null");
+    } else {
+      // no perm filtering
+      $scope.permFilter = "";
+    }
+  };
+
+  $scope.onPermFilterTextChanged = function() {
+    // don't fire until we have at least 2 chars ...
+    if ($scope.permFilterText && $scope.permFilterText.trim().length >= 2) {
+      $scope.permFilterOption = $scope.permFilterText.trim().toLowerCase();
+      $scope.onPermFilterOptionChanged();
+    } else {
+      $scope.filteredPerms = $scope.permissionsTable;
+    }
+  };
+
+  $scope.onPermFilterOptionChanged = function() {
+    var filterCriteria = $scope.permFilterOption ? $scope.permFilterOption.trim() : "";
+    if (filterCriteria.length === 0) {
+      $scope.filteredPerms = $scope.permissionsTable;
+      return;
+    }
+
+    if ($scope.permFilter === "name") {
+      $scope.filteredPerms = $scope.permissionsTable.filter(p => p.name.toLowerCase().includes(filterCriteria));
+    } else if ($scope.permFilter === "role") {
+      if (filterCriteria === "null") {
+        $scope.filteredPerms = $scope.permissionsTable.filter(p => p.roles.length === 0);
+      } else {
+        $scope.filteredPerms = $scope.permissionsTable.filter(p => p.roles.includes(filterCriteria));
+      }
+    } else if ($scope.permFilter === "path") {
+      $scope.filteredPerms = $scope.permissionsTable.filter(p => pathMatch(p.paths, filterCriteria));
+    } else if ($scope.permFilter === "user") {
+      // get the user's roles and then find all the permissions mapped to each role
+      var rolesForUser = $scope.roles.filter(r => r.users.includes(filterCriteria)).map(r => r.name);
+      $scope.filteredPerms = $scope.permissionsTable.filter(p => p.roles.length > 0 && roleMatch(p.roles, rolesForUser));
+    } else if ($scope.permFilter === "collection") {
+      function collectionMatch(collNames, colls, filter) {
+        return (filter === "null") ?collNames === "null" : colls.includes(filter);
+      }
+      $scope.filteredPerms = $scope.permissionsTable.filter(p => collectionMatch(p.collectionNames, p.collections, filterCriteria));
+    } else {
+      // reset
+      $scope.permFilter = "";
+      $scope.permFilterOption = "";
+      $scope.permFilterText = "";
+      $scope.filteredPerms = $scope.permissionsTable;
+    }
+  };
+
+  $scope.editUser = function(row) {
+    if (!row || !$scope.hasSecurityEditPerm) {
+      return;
+    }
+
+    var userId = row.username;
+    $scope.userDialogMode = "edit";
+    $scope.userDialogHeader = "Edit User: "+userId;
+    $scope.userDialogAction = "Update";
+    var userRoles = userId in $scope.userRoles ? $scope.userRoles[userId] : [];
+    if (!Array.isArray(userRoles)) {
+      userRoles = [userRoles];
+    }
+
+    $scope.upsertUser = { username: userId, selectedRoles: userRoles };
+    $scope.toggleUserDialog();
+  };
+
+  function buildMethods(m) {
+    return {"get":""+m.includes("GET"), "post":""+m.includes("POST"), "put":""+m.includes("PUT"), "delete":""+m.includes("DELETE")};
+  }
+
+  $scope.editPerm = function(row) {
+    if (!$scope.managePermissionsEnabled || !$scope.hasSecurityEditPerm || !row) {
+      return;
+    }
+
+    var name = row.name;
+    $scope.permDialogMode = "edit";
+    $scope.permDialogHeader = "Edit Permission: "+name;
+    $scope.permDialogAction = "Update";
+
+    var perm = $scope.permissionsTable.find(p => p.name === name);
+    var isPredefined = $scope.predefinedPermissions.includes(name);
+    if (isPredefined) {
+      $scope.selectedPredefinedPermission = name;
+      $scope.upsertPerm = { };
+      $scope.filteredPredefinedPermissions = [];
+      $scope.filteredPredefinedPermissions.push(name);
+      if ($scope.selectedPredefinedPermission && $scope.selectedPredefinedPermission in $scope.predefinedPermissionCollection) {
+        $scope.upsertPerm.collection = $scope.predefinedPermissionCollection[$scope.selectedPredefinedPermission];
+      }
+      $scope.isPermFieldDisabled = true;
+    } else {
+      $scope.upsertPerm = { name: name, collection: perm.collectionNames, path: perm.paths };
+      $scope.params = [];
+      if (perm.params) {
+        for (const [key, value] of Object.entries(perm.params)) {
+          if (Array.isArray(value)) {
+            for (i in value) {
+              $scope.params.push({"name":key, "value":value[i]});
+            }
+          } else {
+            $scope.params.push({"name":key, "value":value});
+          }
+        }
+      }
+      if ($scope.params.length === 0) {
+        $scope.params = [{"name":"","value":""}];
+      }
+
+      $scope.upsertPerm["method"] = perm.method.length === 0 ? {"get":"true", "post":"true", "put":"true", "delete":"true"} : buildMethods(perm.method);
+      $scope.isPermFieldDisabled = false;
+      delete $scope.selectedPredefinedPermission;
+    }
+
+    $scope.upsertPerm.index = perm["index"];
+    $scope.upsertPerm.originalIndex = perm["index"];
+
+    // roles depending on authz plugin support
+    if ($scope.manageUserRolesEnabled) {
+      $scope.upsertPerm["selectedRoles"] = asList(perm.roles);
+    } else {
+      $scope.upsertPerm["manualRoles"] = asList(perm.roles).sort().join(", ");

Review comment:
       *W069:*  ['manualRoles'] is better written in dot notation.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/security.js
##########
@@ -0,0 +1,1124 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cookies, $window, Constants, System, Security) {
+  $scope.resetMenu("security", Constants.IS_ROOT_PAGE);
+
+  $scope.params = [];
+
+  var strongPasswordRegex = /^(?=.*[0-9])(?=.*[!@#$%^&*\-_()[\]])[a-zA-Z0-9!@#$%^&*\-_()[\]]{8,30}$/;
+
+  function toList(str) {
+    if (Array.isArray(str)) {
+      return str; // already a list
+    }
+    return str.trim().split(",").map(s => s.trim()).filter(s => s !== "");
+  }
+
+  function asList(listOrStr) {
+    return Array.isArray(listOrStr) ? listOrStr : (listOrStr ? [listOrStr] : []);
+  }
+
+  function transposeUserRoles(userRoles) {
+    var roleUsers = {};
+    for (var u in userRoles) {
+      var roleList = asList(userRoles[u]);
+      for (var i in roleList) {
+        var role = roleList[i];
+        if (!roleUsers[role]) roleUsers[role] = []
+        roleUsers[role].push(u);
+      }
+    }
+
+    var roles = [];
+    for (var r in roleUsers) {
+      roles.push({"name":r, "users":Array.from(new Set(roleUsers[r]))});
+    }
+    return roles.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  function roleMatch(roles, rolesForUser) {
+    for (r in rolesForUser) {
+      if (roles.includes(rolesForUser[r]))
+        return true;
+    }
+    return false;
+  }
+
+  function permRow(perm, i) {
+    var roles = asList(perm.role);
+    var paths = asList(perm.path);
+
+    var collectionNames = "";
+    var collections = [];
+    if ("collection" in perm) {
+      if (perm["collection"] == null) {
+        collectionNames = "null";
+      } else {
+        collections = asList(perm.collection);
+        collectionNames = collections.sort().join(", ");
+      }
+    } else {
+      // no collection property on the perm, so the default "*" applies
+      collectionNames = "";
+      collections.push("*");
+    }
+
+    var method = asList(perm.method);
+
+    // perms don't always have an index ?!?
+    var index = "index" in perm ? perm["index"] : ""+i;
+
+    return { "index": index, "name": perm.name, "collectionNames": collectionNames, "collections": collections,
+      "roles": roles, "paths": paths, "method": method, "params": perm.params };
+  }
+
+  function checkError(data) {
+    var cause = null;
+    if ("errorMessages" in data && Array.isArray(data["errorMessages"]) && data["errorMessages"].length > 0) {
+      cause = "?";
+      if ("errorMessages" in data["errorMessages"][0] && Array.isArray(data["errorMessages"][0]["errorMessages"]) && data["errorMessages"][0]["errorMessages"].length > 0) {
+        cause = data["errorMessages"][0]["errorMessages"][0];
+      }
+    }
+    return cause;
+  }
+
+  function truncateTo(str, maxLen, delim) {
+    // allow for a little on either side of maxLen for better display
+    var varLen = Math.min(Math.round(maxLen * 0.1), 15);
+    if (str.length <= maxLen + varLen) {
+      return str;
+    }
+
+    var total = str.split(delim).length;
+    var at = str.indexOf(delim, maxLen - varLen);
+    str = (at !== -1 && at < maxLen + varLen) ? str.substring(0, at) : str.substring(0, maxLen);
+    var trimmed = str.split(delim).length;
+    var diff = total - trimmed;
+    str += " ... "+(diff > 1 ? "(+"+diff+" more)" : "");
+    return str;
+  }
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.securityAPIError;
+    delete $scope.securityAPIErrorDetails;
+  };
+
+  $scope.displayList = function(listOrStr) {
+    if (!listOrStr) return "";
+    var str = Array.isArray(listOrStr) ? listOrStr.sort().join(", ") : (""+listOrStr).trim();
+    return truncateTo(str, 160, ", ");
+  };
+
+  $scope.displayParams = function(obj) {
+    if (!obj) return "";
+    if (Array.isArray(obj)) return obj.sort().join(", ");
+
+    var display = "";
+    for (const [key, value] of Object.entries(obj)) {
+      if (display.length > 0) display += "; ";
+      display += (key + "=" + (Array.isArray(value)?value.sort().join(","):value+""));
+    }
+    return truncateTo(display, 160, "; ");
+  };
+
+  $scope.displayRoles = function(obj) {
+    return (!obj || (Array.isArray(obj) && obj.length === 0)) ? "null" : $scope.displayList(obj);
+  };
+
+  $scope.predefinedPermissions = ["collection-admin-edit", "collection-admin-read", "core-admin-read", "core-admin-edit", "zk-read",
+    "read", "update", "all", "config-edit", "config-read", "schema-read", "schema-edit", "security-edit", "security-read",
+    "metrics-read", "filestore-read", "filestore-write", "package-edit", "package-read"].sort();
+
+  $scope.predefinedPermissionCollection = {"read":"*", "update":"*", "config-edit":"*", "config-read":"*", "schema-edit":"*", "schema-read":"*"};
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error && error.msg) {
+      $scope.securityAPIError = error.msg;
+      $scope.securityAPIErrorDetails = e.data.errorDetails;
+    } else if (e.data && e.data.message) {
+      $scope.securityAPIError = e.data.message;
+      $scope.securityAPIErrorDetails = JSON.stringify(e.data);
+    }
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.hideAll();
+
+    $scope.tls = false;
+    $scope.blockUnknown = "false"; // default setting
+    $scope.realmName = "solr";
+    $scope.forwardCredentials = "false";
+
+    $scope.currentUser = sessionStorage.getItem("auth.username");
+
+    $scope.userFilter = "";
+    $scope.userFilterOption = "";
+    $scope.userFilterText = "";
+    $scope.userFilterOptions = [];
+
+    $scope.permFilter = "";
+    $scope.permFilterOption = "";
+    $scope.permFilterOptions = [];
+    $scope.permFilterTypes = ["", "name", "role", "path", "collection"];
+
+    System.get(function(data) {
+      $scope.tls = data.security ? data.security["tls"] : false;
+      $scope.authenticationPlugin = data.security ? data.security["authenticationPlugin"] : null;
+      $scope.authorizationPlugin = data.security ? data.security["authorizationPlugin"] : null;
+      $scope.myRoles = data.security ? data.security["roles"] : [];
+      $scope.isSecurityAdminEnabled = $scope.authenticationPlugin != null;
+      $scope.isCloudMode = data.mode.match( /solrcloud/i ) != null;
+      $scope.zkHost = $scope.isCloudMode ? data["zkHost"] : "";
+      $scope.solrHome = data["solr_home"];
+      $scope.refreshSecurityPanel();
+    }, function(e) {
+      if (e.status === 401 || e.status === 403) {
+        $scope.isSecurityAdminEnabled = true;
+        $scope.hasSecurityEditPerm = false;
+        $scope.hideAll();
+      }
+    });
+  };
+
+  $scope.hideAll = function () {
+    // add more dialogs here
+    delete $scope.validationError;
+    $scope.showUserDialog = false;
+    $scope.showPermDialog = false;
+    delete $scope.helpId;
+  };
+
+  $scope.getCurrentUserRoles = function() {
+    if ($scope.manageUserRolesEnabled) {
+      return Array.isArray($scope.userRoles[$scope.currentUser]) ? $scope.userRoles[$scope.currentUser] : [$scope.userRoles[$scope.currentUser]];
+    } else {
+      return $scope.myRoles;
+    }
+  };
+
+  $scope.hasPermission = function(permissionName) {
+    var rolesForPermission = $scope.permissionsTable.filter(p => permissionName === p.name).flatMap(p => p.roles);
+    return (rolesForPermission.length > 0 && roleMatch(rolesForPermission, $scope.getCurrentUserRoles()));
+  };
+
+  $scope.refreshSecurityPanel = function() {
+
+    // determine if the authorization plugin supports CRUD permissions
+    $scope.managePermissionsEnabled =
+        ($scope.authorizationPlugin === "org.apache.solr.security.RuleBasedAuthorizationPlugin" ||
+            $scope.authorizationPlugin === "org.apache.solr.security.ExternalRoleRuleBasedAuthorizationPlugin");
+
+    // don't allow CRUD on roles if using external
+    $scope.manageUserRolesEnabled = $scope.authorizationPlugin === "org.apache.solr.security.RuleBasedAuthorizationPlugin";
+
+    Security.get({path: "authorization"}, function (data) {
+      if (!data.authorization) {
+        $scope.isSecurityAdminEnabled = false;
+        $scope.hasSecurityEditPerm = false;
+        return;
+      }
+
+      if ($scope.manageUserRolesEnabled) {
+        $scope.userRoles = data.authorization["user-role"];
+        $scope.roles = transposeUserRoles($scope.userRoles);
+        $scope.filteredRoles = $scope.roles;
+        $scope.roleNames = $scope.roles.map(r => r.name).sort();
+        $scope.roleNamesWithWildcard = ["*"].concat($scope.roleNames);
+        if (!$scope.permFilterTypes.includes("user")) {
+          $scope.permFilterTypes.push("user"); // can only filter perms by user if we have a role to user mapping
+        }
+      } else {
+        $scope.userRoles = {};
+        $scope.roles = [];
+        $scope.filteredRoles = [];
+        $scope.roleNames = [];
+      }
+
+      $scope.permissions = data.authorization["permissions"];
+      $scope.permissionsTable = [];
+      for (p in $scope.permissions) {
+        $scope.permissionsTable.push(permRow($scope.permissions[p], parseInt(p)+1));
+      }
+      $scope.filteredPerms = $scope.permissionsTable;
+
+      $scope.hasSecurityEditPerm = $scope.hasPermission("security-edit");
+      $scope.hasSecurityReadPerm = $scope.hasSecurityEditPerm || $scope.hasPermission("security-read");
+
+      if ($scope.authenticationPlugin === "org.apache.solr.security.BasicAuthPlugin") {
+        $scope.manageUsersEnabled = true;
+
+        Security.get({path: "authentication"}, function (data) {
+          if (!data.authentication) {
+            // TODO: error msg
+            $scope.manageUsersEnabled = false;
+          }
+
+          $scope.blockUnknown = data.authentication["blockUnknown"] === true ? "true" : "false";
+          $scope.forwardCredentials = data.authentication["forwardCredentials"] === true ? "true" : "false";
+
+          if ("realm" in data.authentication) {
+            $scope.realmName = data.authentication["realm"];
+          }
+
+          var users = [];
+          if (data.authentication.credentials) {
+            for (var u in data.authentication.credentials) {
+              var roles = $scope.userRoles[u];
+              if (!roles) roles = [];
+              users.push({"username":u, "roles":roles});
+            }
+          }
+          $scope.users = users.sort((a, b) => (a.username > b.username) ? 1 : -1);
+          $scope.filteredUsers = $scope.users.slice(0,100); // only display first 100
+        }, $scope.errorHandler);
+      } else {
+        $scope.users = [];
+        $scope.filteredUsers = $scope.users;
+        $scope.manageUsersEnabled = false;
+      }
+    }, $scope.errorHandler);
+  };
+
+  $scope.validatePassword = function() {
+    var password = $scope.upsertUser.password.trim();
+    var password2 = $scope.upsertUser.password2 ? $scope.upsertUser.password2.trim() : "";
+    if (password !== password2) {
+      $scope.validationError = "Passwords do not match!";
+      return false;
+    }
+
+    if (!password.match(strongPasswordRegex)) {
+      $scope.validationError = "Password not strong enough! Must contain at least one lowercase letter, one uppercase letter, one digit, and one of these special characters: !@#$%^&*_-[]()";
+      return false;
+    }
+
+    return true;
+  };
+
+  $scope.updateUserRoles = function() {
+    var setUserRoles = {};
+    var roles = [];
+    if ($scope.upsertUser.selectedRoles) {
+      roles = roles.concat($scope.upsertUser.selectedRoles);
+    }
+    if ($scope.upsertUser.newRole && $scope.upsertUser.newRole.trim() !== "") {
+      var newRole = $scope.upsertUser.newRole.trim();
+      if (newRole !== "null" && newRole !== "*" && newRole.length <= 30) {
+        roles.push(newRole);
+      } // else, no new role for you!
+    }
+    var userRoles = Array.from(new Set(roles));
+    setUserRoles[$scope.upsertUser.username] = userRoles.length > 0 ? userRoles : null;
+    Security.post({path: "authorization"}, { "set-user-role": setUserRoles }, function (data) {
+      $scope.toggleUserDialog();
+      $scope.refreshSecurityPanel();
+    });
+  };
+
+  $scope.doUpsertUser = function() {
+    if (!$scope.upsertUser) {
+      delete $scope.validationError;
+      $scope.showUserDialog = false;
+      return;
+    }
+
+    if (!$scope.upsertUser.username || $scope.upsertUser.username.trim() === "") {
+      $scope.validationError = "Username is required!";
+      return;
+    }
+
+    // keep username to a reasonable length? but allow for email addresses
+    var username = $scope.upsertUser.username.trim();
+    if (username.length > 30) {
+      $scope.validationError = "Username must be 30 characters or less!";
+      return;
+    }
+
+    var doSetUser = false;
+    if ($scope.userDialogMode === 'add') {
+      if ($scope.users) {
+        var existing = $scope.users.find(u => u.username === username);
+        if (existing) {
+          $scope.validationError = "User '"+username+"' already exists!";
+          return;
+        }
+      }
+
+      if (!$scope.upsertUser.password) {
+        $scope.validationError = "Password is required!";
+        return;
+      }
+
+      if (!$scope.validatePassword()) {
+        return;
+      }
+      doSetUser = true;
+    } else {
+      if ($scope.upsertUser.password) {
+        if ($scope.validatePassword()) {
+          doSetUser = true;
+        } else {
+          return; // update to password is invalid
+        }
+      } // else no update to password
+    }
+
+    if ($scope.upsertUser.newRole && $scope.upsertUser.newRole.trim() !== "") {
+      var newRole = $scope.upsertUser.newRole.trim();
+      if (newRole === "null" || newRole === "*" || newRole.length > 30) {
+        $scope.validationError = "Invalid new role: "+newRole;
+        return;
+      }
+    }
+
+    delete $scope.validationError;
+
+    if (doSetUser) {
+      var setUserJson = {};
+      setUserJson[username] = $scope.upsertUser.password.trim();
+      Security.post({path: "authentication"}, { "set-user": setUserJson }, function (data) {
+
+        var errorCause = checkError(data);
+        if (errorCause != null) {
+          $scope.securityAPIError = "create user "+username+" failed due to: "+errorCause;
+          $scope.securityAPIErrorDetails = JSON.stringify(data);
+          return;
+        }
+
+        $scope.updateUserRoles();
+      });
+    } else {
+      $scope.updateUserRoles();
+    }
+  };
+
+  $scope.confirmDeleteUser = function() {
+    if (window.confirm("Confirm delete the '"+$scope.upsertUser.username+"' user?")) {
+      // remove all roles for the user and the delete the user
+      var removeRoles = {};
+      removeRoles[$scope.upsertUser.username] = null;
+      Security.post({path: "authorization"}, { "set-user-role": removeRoles }, function (data) {
+        Security.post({path: "authentication"}, {"delete-user": [$scope.upsertUser.username]}, function (data2) {
+          $scope.toggleUserDialog();
+          $scope.refreshSecurityPanel();
+        });
+      });
+    }
+  };
+
+  $scope.showAddUserDialog = function() {
+    $scope.userDialogMode = "add";
+    $scope.userDialogHeader = "Add New User";
+    $scope.userDialogAction = "Add User";
+    $scope.upsertUser = {};
+    $scope.toggleUserDialog();
+  };
+
+  $scope.toggleUserDialog = function() {
+    if ($scope.showUserDialog) {
+      delete $scope.upsertUser;
+      delete $scope.validationError;
+      $scope.showUserDialog = false;
+      return;
+    }
+
+    $scope.hideAll();
+    $('#user-dialog').css({left: 132, top: 132});
+    $scope.showUserDialog = true;
+  };
+
+  $scope.onPredefinedChanged = function() {
+    if (!$scope.upsertPerm) {
+      return;
+    }
+
+    if ($scope.upsertPerm.name && $scope.upsertPerm.name.trim() !== "") {
+      delete $scope.selectedPredefinedPermission;
+    } else {
+      $scope.upsertPerm.name = "";
+    }
+
+    if ($scope.selectedPredefinedPermission && $scope.selectedPredefinedPermission in $scope.predefinedPermissionCollection) {
+      $scope.upsertPerm.collection = $scope.predefinedPermissionCollection[$scope.selectedPredefinedPermission];
+    }
+
+    $scope.isPermFieldDisabled = ($scope.upsertPerm.name === "" && $scope.selectedPredefinedPermission);
+  };
+
+  $scope.showAddPermDialog = function() {
+    $scope.permDialogMode = "add";
+    $scope.permDialogHeader = "Add New Permission";
+    $scope.permDialogAction = "Add Permission";
+    $scope.upsertPerm = {};
+    $scope.upsertPerm.name = "";
+    $scope.upsertPerm.index = "";
+    $scope.upsertPerm["method"] = {"get":"true", "post":"true", "put":"true", "delete":"true"};
+    $scope.isPermFieldDisabled = false;
+    delete $scope.selectedPredefinedPermission;
+
+    $scope.params = [{"name":"", "value":""}];
+
+    var permissionNames = $scope.permissions.map(p => p.name);
+    $scope.filteredPredefinedPermissions = $scope.predefinedPermissions.filter(p => !permissionNames.includes(p));
+
+    $scope.togglePermDialog();
+  };
+
+  $scope.togglePermDialog = function() {
+    if ($scope.showPermDialog) {
+      delete $scope.upsertPerm;
+      delete $scope.validationError;
+      $scope.showPermDialog = false;
+      $scope.isPermFieldDisabled = false;
+      delete $scope.selectedPredefinedPermission;
+      return;
+    }
+
+    $scope.hideAll();
+
+    var leftPos = $scope.permDialogMode === "add" ? 500 : 100;
+    var topPos = $('#permissions').offset().top - 320;
+    if (topPos < 0) topPos = 0;
+    $('#add-permission-dialog').css({left: leftPos, top: topPos});
+
+    $scope.showPermDialog = true;
+  };
+
+  $scope.getMethods = function() {
+    var methods = [];
+    if ($scope.upsertPerm.method.get === "true") {
+      methods.push("GET");
+    }
+    if ($scope.upsertPerm.method.put === "true") {
+      methods.push("PUT");
+    }
+    if ($scope.upsertPerm.method.post === "true") {
+      methods.push("POST");
+    }
+    if ($scope.upsertPerm.method.delete === "true") {
+      methods.push("DELETE");
+    }
+    return methods;
+  };
+
+  $scope.confirmDeletePerm = function() {
+    var permName = $scope.selectedPredefinedPermission ? $scope.selectedPredefinedPermission : $scope.upsertPerm.name.trim();
+    if (window.confirm("Confirm delete the '"+permName+"' permission?")) {
+      var index = parseInt($scope.upsertPerm.index);
+      Security.post({path: "authorization"}, { "delete-permission": index }, function (data) {
+        $scope.togglePermDialog();
+        $scope.refreshSecurityPanel();
+      });
+    }
+  };
+
+  $scope.doUpsertPermission = function() {
+    if (!$scope.upsertPerm) {
+      $scope.upsertPerm = {};
+    }
+
+    var isAdd = $scope.permDialogMode === "add";
+    var name = $scope.selectedPredefinedPermission ? $scope.selectedPredefinedPermission : $scope.upsertPerm.name.trim();
+
+    if (isAdd) {
+      if (!name) {
+        $scope.validationError = "Either select a predefined permission or provide a name for a custom permission";
+        return;
+      }
+      var permissionNames = $scope.permissions.map(p => p.name);
+      if (permissionNames.includes(name)) {
+        $scope.validationError = "Permission '"+name+"' already exists!";
+        return;
+      }
+
+      if (name === "*") {
+        $scope.validationError = "Invalid permission name!";
+        return;
+      }
+    }
+
+    var role = null;
+    if ($scope.manageUserRolesEnabled) {
+      role = $scope.upsertPerm.selectedRoles;
+      if (!role || role.length === 0) {
+        role = null;
+      } else if (role.includes("*")) {
+        role = ["*"];
+      }
+    } else if ($scope.upsertPerm.manualRoles && $scope.upsertPerm.manualRoles.trim() !== "") {
+      var manualRoles = $scope.upsertPerm.manualRoles.trim();
+      role = (manualRoles === "null") ? null : toList(manualRoles);
+    }
+
+    var setPermJson = {"name": name, "role": role };
+
+    if ($scope.selectedPredefinedPermission) {
+      $scope.params = [{"name":"","value":""}];
+    } else {
+      // collection
+      var coll = null;
+      if ($scope.upsertPerm.collection != null && $scope.upsertPerm.collection !== "null") {
+        if ($scope.upsertPerm.collection === "*") {
+          coll = "*";
+        } else {
+          coll = $scope.upsertPerm.collection && $scope.upsertPerm.collection.trim() !== "" ? toList($scope.upsertPerm.collection) : "";
+        }
+      }
+      setPermJson["collection"] = coll;
+
+      // path
+      if (!$scope.upsertPerm.path || (Array.isArray($scope.upsertPerm.path) && $scope.upsertPerm.path.length === 0)) {
+        $scope.validationError = "Path is required for custom permissions!";
+        return;
+      }
+
+      setPermJson["path"] = toList($scope.upsertPerm.path);
+
+      if ($scope.upsertPerm.method) {
+        var methods = $scope.getMethods();
+        if (methods.length === 0) {
+          $scope.validationError = "Must specify at least one request method for a custom permission!";
+          return;
+        }
+
+        if (methods.length < 4) {
+          setPermJson["method"] = methods;
+        } // else no need to specify, rule applies to all methods
+      }
+
+      // params
+      var params = {};
+      if ($scope.params && $scope.params.length > 0) {
+        for (i in $scope.params) {
+          var p = $scope.params[i];
+          var name = p.name.trim();
+          if (name !== "" && p.value) {
+            if (name in params) {
+              params[name].push(p.value);
+            } else {
+              params[name] = [p.value];
+            }
+          }
+        }
+      }
+      setPermJson["params"] = params;
+    }
+
+    var indexUpdated = false;
+    if ($scope.upsertPerm.index) {
+      var indexOrBefore = isAdd ? "before" : "index";
+      var indexInt = parseInt($scope.upsertPerm.index);
+      if (indexInt < 1) indexInt = 1;
+      if (indexInt >= $scope.permissions.length) indexInt = null;
+      if (indexInt != null) {
+        setPermJson[indexOrBefore] = indexInt;
+      }
+      indexUpdated = (!isAdd && indexInt !== parseInt($scope.upsertPerm.originalIndex));
+    }
+
+    if (indexUpdated) {
+      // changing position is a delete + re-add in new position
+      Security.post({path: "authorization"}, { "delete-permission": parseInt($scope.upsertPerm.originalIndex) }, function (remData) {
+        if (setPermJson.index) {
+          var before = setPermJson.index;
+          delete setPermJson.index;
+          setPermJson["before"] = before;
+        }
+
+        // add perm back in new position
+        Security.post({path: "authorization"}, { "set-permission": setPermJson }, function (data) {
+          var errorCause = checkError(data);
+          if (errorCause != null) {
+            $scope.securityAPIError = "set-permission "+name+" failed due to: "+errorCause;
+            $scope.securityAPIErrorDetails = JSON.stringify(data);
+            return;
+          }
+          $scope.togglePermDialog();
+          $scope.refreshSecurityPanel();
+        });
+      });
+    } else {
+      var action = isAdd ? "set-permission" : "update-permission";
+      var postBody = {};
+      postBody[action] = setPermJson;
+      Security.post({path: "authorization"}, postBody, function (data) {
+        var errorCause = checkError(data);
+        if (errorCause != null) {
+          $scope.securityAPIError = action+" "+name+" failed due to: "+errorCause;
+          $scope.securityAPIErrorDetails = JSON.stringify(data);
+          return;
+        }
+
+        $scope.togglePermDialog();
+        $scope.refreshSecurityPanel();
+      });
+    }
+  };
+
+  $scope.applyUserFilter = function() {
+    $scope.userFilterText = "";
+    $scope.userFilterOption = "";
+    $scope.userFilterOptions = [];
+    $scope.filteredUsers = $scope.users; // reset the filtered when the filter type changes
+
+    if ($scope.userFilter === "name" || $scope.userFilter === "path") {
+      // no-op: filter is text input
+    } else if ($scope.userFilter === "role") {
+      $scope.userFilterOptions = $scope.roleNames;
+    } else if ($scope.userFilter === "perm") {
+      $scope.userFilterOptions = $scope.permissions.map(p => p.name).sort();
+    } else {
+      $scope.userFilter = "";
+    }
+  };
+
+  $scope.onUserFilterTextChanged = function() {
+    // don't fire until we have at least 2 chars ...
+    if ($scope.userFilterText && $scope.userFilterText.trim().length >= 2) {
+      $scope.userFilterOption = $scope.userFilterText.toLowerCase();
+      $scope.onUserFilterOptionChanged();
+    } else {
+      $scope.filteredUsers = $scope.users;
+    }
+  };
+
+  function pathMatch(paths, filter) {
+    for (p in paths) {
+      if (paths[p].includes(filter)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  $scope.onUserFilterOptionChanged = function() {
+    var filter = $scope.userFilterOption ? $scope.userFilterOption.trim() : "";
+    if (filter.length === 0) {
+      $scope.filteredUsers = $scope.users;
+      return;
+    }
+
+    if ($scope.userFilter === "name") {
+      $scope.filteredUsers = $scope.users.filter(u => u.username.toLowerCase().includes(filter));
+    } else if ($scope.userFilter === "role") {
+      $scope.filteredUsers = $scope.users.filter(u => u.roles.includes(filter));
+    } else if ($scope.userFilter === "path") {
+      var rolesForPath = Array.from(new Set($scope.permissionsTable.filter(p => p.roles && pathMatch(p.paths, filter)).flatMap(p => p.roles)));
+      var usersForPath = Array.from(new Set($scope.roles.filter(r => r.users && r.users.length > 0 && rolesForPath.includes(r.name)).flatMap(r => r.users)));
+      $scope.filteredUsers = $scope.users.filter(u => usersForPath.includes(u.username));
+    } else if ($scope.userFilter === "perm") {
+      var rolesForPerm = Array.from(new Set($scope.permissionsTable.filter(p => p.name === filter).flatMap(p => p.roles)));
+      var usersForPerm = Array.from(new Set($scope.roles.filter(r => r.users && r.users.length > 0 && rolesForPerm.includes(r.name)).flatMap(r => r.users)));
+      $scope.filteredUsers = $scope.users.filter(u => usersForPerm.includes(u.username));
+    } else {
+      // reset
+      $scope.userFilter = "";
+      $scope.userFilterOption = "";
+      $scope.userFilterText = "";
+      $scope.filteredUsers = $scope.users;
+    }
+  };
+
+  $scope.applyPermFilter = function() {
+    $scope.permFilterText = "";
+    $scope.permFilterOption = "";
+    $scope.permFilterOptions = [];
+    $scope.filteredPerms = $scope.permissionsTable;
+
+    if ($scope.permFilter === "name" || $scope.permFilter === "path") {
+      // no-op: filter is text input
+    } else if ($scope.permFilter === "role") {
+      var roles = $scope.manageUserRolesEnabled ? $scope.roleNames : Array.from(new Set($scope.permissionsTable.flatMap(p => p.roles))).sort();
+      $scope.permFilterOptions = ["*", "null"].concat(roles);
+    } else if ($scope.permFilter === "user") {
+      $scope.permFilterOptions = Array.from(new Set($scope.roles.flatMap(r => r.users))).sort();
+    } else if ($scope.permFilter === "collection") {
+      $scope.permFilterOptions = Array.from(new Set($scope.permissionsTable.flatMap(p => p.collections))).sort();
+      $scope.permFilterOptions.push("null");
+    } else {
+      // no perm filtering
+      $scope.permFilter = "";
+    }
+  };
+
+  $scope.onPermFilterTextChanged = function() {
+    // don't fire until we have at least 2 chars ...
+    if ($scope.permFilterText && $scope.permFilterText.trim().length >= 2) {
+      $scope.permFilterOption = $scope.permFilterText.trim().toLowerCase();
+      $scope.onPermFilterOptionChanged();
+    } else {
+      $scope.filteredPerms = $scope.permissionsTable;
+    }
+  };
+
+  $scope.onPermFilterOptionChanged = function() {
+    var filterCriteria = $scope.permFilterOption ? $scope.permFilterOption.trim() : "";
+    if (filterCriteria.length === 0) {
+      $scope.filteredPerms = $scope.permissionsTable;
+      return;
+    }
+
+    if ($scope.permFilter === "name") {
+      $scope.filteredPerms = $scope.permissionsTable.filter(p => p.name.toLowerCase().includes(filterCriteria));
+    } else if ($scope.permFilter === "role") {
+      if (filterCriteria === "null") {
+        $scope.filteredPerms = $scope.permissionsTable.filter(p => p.roles.length === 0);
+      } else {
+        $scope.filteredPerms = $scope.permissionsTable.filter(p => p.roles.includes(filterCriteria));
+      }
+    } else if ($scope.permFilter === "path") {
+      $scope.filteredPerms = $scope.permissionsTable.filter(p => pathMatch(p.paths, filterCriteria));
+    } else if ($scope.permFilter === "user") {
+      // get the user's roles and then find all the permissions mapped to each role
+      var rolesForUser = $scope.roles.filter(r => r.users.includes(filterCriteria)).map(r => r.name);
+      $scope.filteredPerms = $scope.permissionsTable.filter(p => p.roles.length > 0 && roleMatch(p.roles, rolesForUser));
+    } else if ($scope.permFilter === "collection") {
+      function collectionMatch(collNames, colls, filter) {
+        return (filter === "null") ?collNames === "null" : colls.includes(filter);
+      }
+      $scope.filteredPerms = $scope.permissionsTable.filter(p => collectionMatch(p.collectionNames, p.collections, filterCriteria));
+    } else {
+      // reset
+      $scope.permFilter = "";
+      $scope.permFilterOption = "";
+      $scope.permFilterText = "";
+      $scope.filteredPerms = $scope.permissionsTable;
+    }
+  };
+
+  $scope.editUser = function(row) {
+    if (!row || !$scope.hasSecurityEditPerm) {
+      return;
+    }
+
+    var userId = row.username;
+    $scope.userDialogMode = "edit";
+    $scope.userDialogHeader = "Edit User: "+userId;
+    $scope.userDialogAction = "Update";
+    var userRoles = userId in $scope.userRoles ? $scope.userRoles[userId] : [];
+    if (!Array.isArray(userRoles)) {
+      userRoles = [userRoles];
+    }
+
+    $scope.upsertUser = { username: userId, selectedRoles: userRoles };
+    $scope.toggleUserDialog();
+  };
+
+  function buildMethods(m) {
+    return {"get":""+m.includes("GET"), "post":""+m.includes("POST"), "put":""+m.includes("PUT"), "delete":""+m.includes("DELETE")};
+  }
+
+  $scope.editPerm = function(row) {
+    if (!$scope.managePermissionsEnabled || !$scope.hasSecurityEditPerm || !row) {
+      return;
+    }
+
+    var name = row.name;
+    $scope.permDialogMode = "edit";
+    $scope.permDialogHeader = "Edit Permission: "+name;
+    $scope.permDialogAction = "Update";
+
+    var perm = $scope.permissionsTable.find(p => p.name === name);
+    var isPredefined = $scope.predefinedPermissions.includes(name);
+    if (isPredefined) {
+      $scope.selectedPredefinedPermission = name;
+      $scope.upsertPerm = { };
+      $scope.filteredPredefinedPermissions = [];
+      $scope.filteredPredefinedPermissions.push(name);
+      if ($scope.selectedPredefinedPermission && $scope.selectedPredefinedPermission in $scope.predefinedPermissionCollection) {
+        $scope.upsertPerm.collection = $scope.predefinedPermissionCollection[$scope.selectedPredefinedPermission];
+      }
+      $scope.isPermFieldDisabled = true;
+    } else {
+      $scope.upsertPerm = { name: name, collection: perm.collectionNames, path: perm.paths };
+      $scope.params = [];
+      if (perm.params) {
+        for (const [key, value] of Object.entries(perm.params)) {
+          if (Array.isArray(value)) {
+            for (i in value) {
+              $scope.params.push({"name":key, "value":value[i]});
+            }
+          } else {
+            $scope.params.push({"name":key, "value":value});
+          }
+        }
+      }
+      if ($scope.params.length === 0) {
+        $scope.params = [{"name":"","value":""}];
+      }
+
+      $scope.upsertPerm["method"] = perm.method.length === 0 ? {"get":"true", "post":"true", "put":"true", "delete":"true"} : buildMethods(perm.method);

Review comment:
       *W069:*  ['method'] is better written in dot notation.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/security.js
##########
@@ -0,0 +1,1124 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cookies, $window, Constants, System, Security) {
+  $scope.resetMenu("security", Constants.IS_ROOT_PAGE);
+
+  $scope.params = [];
+
+  var strongPasswordRegex = /^(?=.*[0-9])(?=.*[!@#$%^&*\-_()[\]])[a-zA-Z0-9!@#$%^&*\-_()[\]]{8,30}$/;
+
+  function toList(str) {
+    if (Array.isArray(str)) {
+      return str; // already a list
+    }
+    return str.trim().split(",").map(s => s.trim()).filter(s => s !== "");
+  }
+
+  function asList(listOrStr) {
+    return Array.isArray(listOrStr) ? listOrStr : (listOrStr ? [listOrStr] : []);
+  }
+
+  function transposeUserRoles(userRoles) {
+    var roleUsers = {};
+    for (var u in userRoles) {
+      var roleList = asList(userRoles[u]);
+      for (var i in roleList) {
+        var role = roleList[i];
+        if (!roleUsers[role]) roleUsers[role] = []
+        roleUsers[role].push(u);
+      }
+    }
+
+    var roles = [];
+    for (var r in roleUsers) {
+      roles.push({"name":r, "users":Array.from(new Set(roleUsers[r]))});
+    }
+    return roles.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  function roleMatch(roles, rolesForUser) {
+    for (r in rolesForUser) {
+      if (roles.includes(rolesForUser[r]))
+        return true;
+    }
+    return false;
+  }
+
+  function permRow(perm, i) {
+    var roles = asList(perm.role);
+    var paths = asList(perm.path);
+
+    var collectionNames = "";
+    var collections = [];
+    if ("collection" in perm) {
+      if (perm["collection"] == null) {
+        collectionNames = "null";
+      } else {
+        collections = asList(perm.collection);
+        collectionNames = collections.sort().join(", ");
+      }
+    } else {
+      // no collection property on the perm, so the default "*" applies
+      collectionNames = "";
+      collections.push("*");
+    }
+
+    var method = asList(perm.method);
+
+    // perms don't always have an index ?!?
+    var index = "index" in perm ? perm["index"] : ""+i;
+
+    return { "index": index, "name": perm.name, "collectionNames": collectionNames, "collections": collections,
+      "roles": roles, "paths": paths, "method": method, "params": perm.params };
+  }
+
+  function checkError(data) {
+    var cause = null;
+    if ("errorMessages" in data && Array.isArray(data["errorMessages"]) && data["errorMessages"].length > 0) {
+      cause = "?";
+      if ("errorMessages" in data["errorMessages"][0] && Array.isArray(data["errorMessages"][0]["errorMessages"]) && data["errorMessages"][0]["errorMessages"].length > 0) {
+        cause = data["errorMessages"][0]["errorMessages"][0];
+      }
+    }
+    return cause;
+  }
+
+  function truncateTo(str, maxLen, delim) {
+    // allow for a little on either side of maxLen for better display
+    var varLen = Math.min(Math.round(maxLen * 0.1), 15);
+    if (str.length <= maxLen + varLen) {
+      return str;
+    }
+
+    var total = str.split(delim).length;
+    var at = str.indexOf(delim, maxLen - varLen);
+    str = (at !== -1 && at < maxLen + varLen) ? str.substring(0, at) : str.substring(0, maxLen);
+    var trimmed = str.split(delim).length;
+    var diff = total - trimmed;
+    str += " ... "+(diff > 1 ? "(+"+diff+" more)" : "");
+    return str;
+  }
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.securityAPIError;
+    delete $scope.securityAPIErrorDetails;
+  };
+
+  $scope.displayList = function(listOrStr) {
+    if (!listOrStr) return "";
+    var str = Array.isArray(listOrStr) ? listOrStr.sort().join(", ") : (""+listOrStr).trim();
+    return truncateTo(str, 160, ", ");
+  };
+
+  $scope.displayParams = function(obj) {
+    if (!obj) return "";
+    if (Array.isArray(obj)) return obj.sort().join(", ");
+
+    var display = "";
+    for (const [key, value] of Object.entries(obj)) {
+      if (display.length > 0) display += "; ";
+      display += (key + "=" + (Array.isArray(value)?value.sort().join(","):value+""));
+    }
+    return truncateTo(display, 160, "; ");
+  };
+
+  $scope.displayRoles = function(obj) {
+    return (!obj || (Array.isArray(obj) && obj.length === 0)) ? "null" : $scope.displayList(obj);
+  };
+
+  $scope.predefinedPermissions = ["collection-admin-edit", "collection-admin-read", "core-admin-read", "core-admin-edit", "zk-read",
+    "read", "update", "all", "config-edit", "config-read", "schema-read", "schema-edit", "security-edit", "security-read",
+    "metrics-read", "filestore-read", "filestore-write", "package-edit", "package-read"].sort();
+
+  $scope.predefinedPermissionCollection = {"read":"*", "update":"*", "config-edit":"*", "config-read":"*", "schema-edit":"*", "schema-read":"*"};
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error && error.msg) {
+      $scope.securityAPIError = error.msg;
+      $scope.securityAPIErrorDetails = e.data.errorDetails;
+    } else if (e.data && e.data.message) {
+      $scope.securityAPIError = e.data.message;
+      $scope.securityAPIErrorDetails = JSON.stringify(e.data);
+    }
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.hideAll();
+
+    $scope.tls = false;
+    $scope.blockUnknown = "false"; // default setting
+    $scope.realmName = "solr";
+    $scope.forwardCredentials = "false";
+
+    $scope.currentUser = sessionStorage.getItem("auth.username");
+
+    $scope.userFilter = "";
+    $scope.userFilterOption = "";
+    $scope.userFilterText = "";
+    $scope.userFilterOptions = [];
+
+    $scope.permFilter = "";
+    $scope.permFilterOption = "";
+    $scope.permFilterOptions = [];
+    $scope.permFilterTypes = ["", "name", "role", "path", "collection"];
+
+    System.get(function(data) {
+      $scope.tls = data.security ? data.security["tls"] : false;
+      $scope.authenticationPlugin = data.security ? data.security["authenticationPlugin"] : null;
+      $scope.authorizationPlugin = data.security ? data.security["authorizationPlugin"] : null;
+      $scope.myRoles = data.security ? data.security["roles"] : [];
+      $scope.isSecurityAdminEnabled = $scope.authenticationPlugin != null;
+      $scope.isCloudMode = data.mode.match( /solrcloud/i ) != null;
+      $scope.zkHost = $scope.isCloudMode ? data["zkHost"] : "";
+      $scope.solrHome = data["solr_home"];
+      $scope.refreshSecurityPanel();
+    }, function(e) {
+      if (e.status === 401 || e.status === 403) {
+        $scope.isSecurityAdminEnabled = true;
+        $scope.hasSecurityEditPerm = false;
+        $scope.hideAll();
+      }
+    });
+  };
+
+  $scope.hideAll = function () {
+    // add more dialogs here
+    delete $scope.validationError;
+    $scope.showUserDialog = false;
+    $scope.showPermDialog = false;
+    delete $scope.helpId;
+  };
+
+  $scope.getCurrentUserRoles = function() {
+    if ($scope.manageUserRolesEnabled) {
+      return Array.isArray($scope.userRoles[$scope.currentUser]) ? $scope.userRoles[$scope.currentUser] : [$scope.userRoles[$scope.currentUser]];
+    } else {
+      return $scope.myRoles;
+    }
+  };
+
+  $scope.hasPermission = function(permissionName) {
+    var rolesForPermission = $scope.permissionsTable.filter(p => permissionName === p.name).flatMap(p => p.roles);
+    return (rolesForPermission.length > 0 && roleMatch(rolesForPermission, $scope.getCurrentUserRoles()));
+  };
+
+  $scope.refreshSecurityPanel = function() {
+
+    // determine if the authorization plugin supports CRUD permissions
+    $scope.managePermissionsEnabled =
+        ($scope.authorizationPlugin === "org.apache.solr.security.RuleBasedAuthorizationPlugin" ||
+            $scope.authorizationPlugin === "org.apache.solr.security.ExternalRoleRuleBasedAuthorizationPlugin");
+
+    // don't allow CRUD on roles if using external
+    $scope.manageUserRolesEnabled = $scope.authorizationPlugin === "org.apache.solr.security.RuleBasedAuthorizationPlugin";
+
+    Security.get({path: "authorization"}, function (data) {
+      if (!data.authorization) {
+        $scope.isSecurityAdminEnabled = false;
+        $scope.hasSecurityEditPerm = false;
+        return;
+      }
+
+      if ($scope.manageUserRolesEnabled) {
+        $scope.userRoles = data.authorization["user-role"];
+        $scope.roles = transposeUserRoles($scope.userRoles);
+        $scope.filteredRoles = $scope.roles;
+        $scope.roleNames = $scope.roles.map(r => r.name).sort();
+        $scope.roleNamesWithWildcard = ["*"].concat($scope.roleNames);
+        if (!$scope.permFilterTypes.includes("user")) {
+          $scope.permFilterTypes.push("user"); // can only filter perms by user if we have a role to user mapping
+        }
+      } else {
+        $scope.userRoles = {};
+        $scope.roles = [];
+        $scope.filteredRoles = [];
+        $scope.roleNames = [];
+      }
+
+      $scope.permissions = data.authorization["permissions"];
+      $scope.permissionsTable = [];
+      for (p in $scope.permissions) {
+        $scope.permissionsTable.push(permRow($scope.permissions[p], parseInt(p)+1));
+      }
+      $scope.filteredPerms = $scope.permissionsTable;
+
+      $scope.hasSecurityEditPerm = $scope.hasPermission("security-edit");
+      $scope.hasSecurityReadPerm = $scope.hasSecurityEditPerm || $scope.hasPermission("security-read");
+
+      if ($scope.authenticationPlugin === "org.apache.solr.security.BasicAuthPlugin") {
+        $scope.manageUsersEnabled = true;
+
+        Security.get({path: "authentication"}, function (data) {
+          if (!data.authentication) {
+            // TODO: error msg
+            $scope.manageUsersEnabled = false;
+          }
+
+          $scope.blockUnknown = data.authentication["blockUnknown"] === true ? "true" : "false";
+          $scope.forwardCredentials = data.authentication["forwardCredentials"] === true ? "true" : "false";
+
+          if ("realm" in data.authentication) {
+            $scope.realmName = data.authentication["realm"];
+          }
+
+          var users = [];
+          if (data.authentication.credentials) {
+            for (var u in data.authentication.credentials) {
+              var roles = $scope.userRoles[u];
+              if (!roles) roles = [];
+              users.push({"username":u, "roles":roles});
+            }
+          }
+          $scope.users = users.sort((a, b) => (a.username > b.username) ? 1 : -1);
+          $scope.filteredUsers = $scope.users.slice(0,100); // only display first 100
+        }, $scope.errorHandler);
+      } else {
+        $scope.users = [];
+        $scope.filteredUsers = $scope.users;
+        $scope.manageUsersEnabled = false;
+      }
+    }, $scope.errorHandler);
+  };
+
+  $scope.validatePassword = function() {
+    var password = $scope.upsertUser.password.trim();
+    var password2 = $scope.upsertUser.password2 ? $scope.upsertUser.password2.trim() : "";
+    if (password !== password2) {
+      $scope.validationError = "Passwords do not match!";
+      return false;
+    }
+
+    if (!password.match(strongPasswordRegex)) {
+      $scope.validationError = "Password not strong enough! Must contain at least one lowercase letter, one uppercase letter, one digit, and one of these special characters: !@#$%^&*_-[]()";
+      return false;
+    }
+
+    return true;
+  };
+
+  $scope.updateUserRoles = function() {
+    var setUserRoles = {};
+    var roles = [];
+    if ($scope.upsertUser.selectedRoles) {
+      roles = roles.concat($scope.upsertUser.selectedRoles);
+    }
+    if ($scope.upsertUser.newRole && $scope.upsertUser.newRole.trim() !== "") {
+      var newRole = $scope.upsertUser.newRole.trim();
+      if (newRole !== "null" && newRole !== "*" && newRole.length <= 30) {
+        roles.push(newRole);
+      } // else, no new role for you!
+    }
+    var userRoles = Array.from(new Set(roles));
+    setUserRoles[$scope.upsertUser.username] = userRoles.length > 0 ? userRoles : null;
+    Security.post({path: "authorization"}, { "set-user-role": setUserRoles }, function (data) {
+      $scope.toggleUserDialog();
+      $scope.refreshSecurityPanel();
+    });
+  };
+
+  $scope.doUpsertUser = function() {
+    if (!$scope.upsertUser) {
+      delete $scope.validationError;
+      $scope.showUserDialog = false;
+      return;
+    }
+
+    if (!$scope.upsertUser.username || $scope.upsertUser.username.trim() === "") {
+      $scope.validationError = "Username is required!";
+      return;
+    }
+
+    // keep username to a reasonable length? but allow for email addresses
+    var username = $scope.upsertUser.username.trim();
+    if (username.length > 30) {
+      $scope.validationError = "Username must be 30 characters or less!";
+      return;
+    }
+
+    var doSetUser = false;
+    if ($scope.userDialogMode === 'add') {
+      if ($scope.users) {
+        var existing = $scope.users.find(u => u.username === username);
+        if (existing) {
+          $scope.validationError = "User '"+username+"' already exists!";
+          return;
+        }
+      }
+
+      if (!$scope.upsertUser.password) {
+        $scope.validationError = "Password is required!";
+        return;
+      }
+
+      if (!$scope.validatePassword()) {
+        return;
+      }
+      doSetUser = true;
+    } else {
+      if ($scope.upsertUser.password) {
+        if ($scope.validatePassword()) {
+          doSetUser = true;
+        } else {
+          return; // update to password is invalid
+        }
+      } // else no update to password
+    }
+
+    if ($scope.upsertUser.newRole && $scope.upsertUser.newRole.trim() !== "") {
+      var newRole = $scope.upsertUser.newRole.trim();
+      if (newRole === "null" || newRole === "*" || newRole.length > 30) {
+        $scope.validationError = "Invalid new role: "+newRole;
+        return;
+      }
+    }
+
+    delete $scope.validationError;
+
+    if (doSetUser) {
+      var setUserJson = {};
+      setUserJson[username] = $scope.upsertUser.password.trim();
+      Security.post({path: "authentication"}, { "set-user": setUserJson }, function (data) {
+
+        var errorCause = checkError(data);
+        if (errorCause != null) {
+          $scope.securityAPIError = "create user "+username+" failed due to: "+errorCause;
+          $scope.securityAPIErrorDetails = JSON.stringify(data);
+          return;
+        }
+
+        $scope.updateUserRoles();
+      });
+    } else {
+      $scope.updateUserRoles();
+    }
+  };
+
+  $scope.confirmDeleteUser = function() {
+    if (window.confirm("Confirm delete the '"+$scope.upsertUser.username+"' user?")) {
+      // remove all roles for the user and the delete the user
+      var removeRoles = {};
+      removeRoles[$scope.upsertUser.username] = null;
+      Security.post({path: "authorization"}, { "set-user-role": removeRoles }, function (data) {
+        Security.post({path: "authentication"}, {"delete-user": [$scope.upsertUser.username]}, function (data2) {
+          $scope.toggleUserDialog();
+          $scope.refreshSecurityPanel();
+        });
+      });
+    }
+  };
+
+  $scope.showAddUserDialog = function() {
+    $scope.userDialogMode = "add";
+    $scope.userDialogHeader = "Add New User";
+    $scope.userDialogAction = "Add User";
+    $scope.upsertUser = {};
+    $scope.toggleUserDialog();
+  };
+
+  $scope.toggleUserDialog = function() {
+    if ($scope.showUserDialog) {
+      delete $scope.upsertUser;
+      delete $scope.validationError;
+      $scope.showUserDialog = false;
+      return;
+    }
+
+    $scope.hideAll();
+    $('#user-dialog').css({left: 132, top: 132});
+    $scope.showUserDialog = true;
+  };
+
+  $scope.onPredefinedChanged = function() {
+    if (!$scope.upsertPerm) {
+      return;
+    }
+
+    if ($scope.upsertPerm.name && $scope.upsertPerm.name.trim() !== "") {
+      delete $scope.selectedPredefinedPermission;
+    } else {
+      $scope.upsertPerm.name = "";
+    }
+
+    if ($scope.selectedPredefinedPermission && $scope.selectedPredefinedPermission in $scope.predefinedPermissionCollection) {
+      $scope.upsertPerm.collection = $scope.predefinedPermissionCollection[$scope.selectedPredefinedPermission];
+    }
+
+    $scope.isPermFieldDisabled = ($scope.upsertPerm.name === "" && $scope.selectedPredefinedPermission);
+  };
+
+  $scope.showAddPermDialog = function() {
+    $scope.permDialogMode = "add";
+    $scope.permDialogHeader = "Add New Permission";
+    $scope.permDialogAction = "Add Permission";
+    $scope.upsertPerm = {};
+    $scope.upsertPerm.name = "";
+    $scope.upsertPerm.index = "";
+    $scope.upsertPerm["method"] = {"get":"true", "post":"true", "put":"true", "delete":"true"};
+    $scope.isPermFieldDisabled = false;
+    delete $scope.selectedPredefinedPermission;
+
+    $scope.params = [{"name":"", "value":""}];
+
+    var permissionNames = $scope.permissions.map(p => p.name);
+    $scope.filteredPredefinedPermissions = $scope.predefinedPermissions.filter(p => !permissionNames.includes(p));
+
+    $scope.togglePermDialog();
+  };
+
+  $scope.togglePermDialog = function() {
+    if ($scope.showPermDialog) {
+      delete $scope.upsertPerm;
+      delete $scope.validationError;
+      $scope.showPermDialog = false;
+      $scope.isPermFieldDisabled = false;
+      delete $scope.selectedPredefinedPermission;
+      return;
+    }
+
+    $scope.hideAll();
+
+    var leftPos = $scope.permDialogMode === "add" ? 500 : 100;
+    var topPos = $('#permissions').offset().top - 320;
+    if (topPos < 0) topPos = 0;
+    $('#add-permission-dialog').css({left: leftPos, top: topPos});
+
+    $scope.showPermDialog = true;
+  };
+
+  $scope.getMethods = function() {
+    var methods = [];
+    if ($scope.upsertPerm.method.get === "true") {
+      methods.push("GET");
+    }
+    if ($scope.upsertPerm.method.put === "true") {
+      methods.push("PUT");
+    }
+    if ($scope.upsertPerm.method.post === "true") {
+      methods.push("POST");
+    }
+    if ($scope.upsertPerm.method.delete === "true") {
+      methods.push("DELETE");
+    }
+    return methods;
+  };
+
+  $scope.confirmDeletePerm = function() {
+    var permName = $scope.selectedPredefinedPermission ? $scope.selectedPredefinedPermission : $scope.upsertPerm.name.trim();
+    if (window.confirm("Confirm delete the '"+permName+"' permission?")) {
+      var index = parseInt($scope.upsertPerm.index);
+      Security.post({path: "authorization"}, { "delete-permission": index }, function (data) {
+        $scope.togglePermDialog();
+        $scope.refreshSecurityPanel();
+      });
+    }
+  };
+
+  $scope.doUpsertPermission = function() {
+    if (!$scope.upsertPerm) {
+      $scope.upsertPerm = {};
+    }
+
+    var isAdd = $scope.permDialogMode === "add";
+    var name = $scope.selectedPredefinedPermission ? $scope.selectedPredefinedPermission : $scope.upsertPerm.name.trim();
+
+    if (isAdd) {
+      if (!name) {
+        $scope.validationError = "Either select a predefined permission or provide a name for a custom permission";
+        return;
+      }
+      var permissionNames = $scope.permissions.map(p => p.name);
+      if (permissionNames.includes(name)) {
+        $scope.validationError = "Permission '"+name+"' already exists!";
+        return;
+      }
+
+      if (name === "*") {
+        $scope.validationError = "Invalid permission name!";
+        return;
+      }
+    }
+
+    var role = null;
+    if ($scope.manageUserRolesEnabled) {
+      role = $scope.upsertPerm.selectedRoles;
+      if (!role || role.length === 0) {
+        role = null;
+      } else if (role.includes("*")) {
+        role = ["*"];
+      }
+    } else if ($scope.upsertPerm.manualRoles && $scope.upsertPerm.manualRoles.trim() !== "") {
+      var manualRoles = $scope.upsertPerm.manualRoles.trim();
+      role = (manualRoles === "null") ? null : toList(manualRoles);
+    }
+
+    var setPermJson = {"name": name, "role": role };
+
+    if ($scope.selectedPredefinedPermission) {
+      $scope.params = [{"name":"","value":""}];
+    } else {
+      // collection
+      var coll = null;
+      if ($scope.upsertPerm.collection != null && $scope.upsertPerm.collection !== "null") {
+        if ($scope.upsertPerm.collection === "*") {
+          coll = "*";
+        } else {
+          coll = $scope.upsertPerm.collection && $scope.upsertPerm.collection.trim() !== "" ? toList($scope.upsertPerm.collection) : "";
+        }
+      }
+      setPermJson["collection"] = coll;
+
+      // path
+      if (!$scope.upsertPerm.path || (Array.isArray($scope.upsertPerm.path) && $scope.upsertPerm.path.length === 0)) {
+        $scope.validationError = "Path is required for custom permissions!";
+        return;
+      }
+
+      setPermJson["path"] = toList($scope.upsertPerm.path);
+
+      if ($scope.upsertPerm.method) {
+        var methods = $scope.getMethods();
+        if (methods.length === 0) {
+          $scope.validationError = "Must specify at least one request method for a custom permission!";
+          return;
+        }
+
+        if (methods.length < 4) {
+          setPermJson["method"] = methods;
+        } // else no need to specify, rule applies to all methods
+      }
+
+      // params
+      var params = {};
+      if ($scope.params && $scope.params.length > 0) {
+        for (i in $scope.params) {
+          var p = $scope.params[i];
+          var name = p.name.trim();
+          if (name !== "" && p.value) {
+            if (name in params) {
+              params[name].push(p.value);
+            } else {
+              params[name] = [p.value];
+            }
+          }
+        }
+      }
+      setPermJson["params"] = params;

Review comment:
       *W069:*  ['params'] is better written in dot notation.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/security.js
##########
@@ -0,0 +1,1124 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cookies, $window, Constants, System, Security) {
+  $scope.resetMenu("security", Constants.IS_ROOT_PAGE);
+
+  $scope.params = [];
+
+  var strongPasswordRegex = /^(?=.*[0-9])(?=.*[!@#$%^&*\-_()[\]])[a-zA-Z0-9!@#$%^&*\-_()[\]]{8,30}$/;
+
+  function toList(str) {
+    if (Array.isArray(str)) {
+      return str; // already a list
+    }
+    return str.trim().split(",").map(s => s.trim()).filter(s => s !== "");
+  }
+
+  function asList(listOrStr) {
+    return Array.isArray(listOrStr) ? listOrStr : (listOrStr ? [listOrStr] : []);
+  }
+
+  function transposeUserRoles(userRoles) {
+    var roleUsers = {};
+    for (var u in userRoles) {
+      var roleList = asList(userRoles[u]);
+      for (var i in roleList) {
+        var role = roleList[i];
+        if (!roleUsers[role]) roleUsers[role] = []
+        roleUsers[role].push(u);
+      }
+    }
+
+    var roles = [];
+    for (var r in roleUsers) {
+      roles.push({"name":r, "users":Array.from(new Set(roleUsers[r]))});
+    }
+    return roles.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  function roleMatch(roles, rolesForUser) {
+    for (r in rolesForUser) {
+      if (roles.includes(rolesForUser[r]))
+        return true;
+    }
+    return false;
+  }
+
+  function permRow(perm, i) {
+    var roles = asList(perm.role);
+    var paths = asList(perm.path);
+
+    var collectionNames = "";
+    var collections = [];
+    if ("collection" in perm) {
+      if (perm["collection"] == null) {
+        collectionNames = "null";
+      } else {
+        collections = asList(perm.collection);
+        collectionNames = collections.sort().join(", ");
+      }
+    } else {
+      // no collection property on the perm, so the default "*" applies
+      collectionNames = "";
+      collections.push("*");
+    }
+
+    var method = asList(perm.method);
+
+    // perms don't always have an index ?!?
+    var index = "index" in perm ? perm["index"] : ""+i;
+
+    return { "index": index, "name": perm.name, "collectionNames": collectionNames, "collections": collections,
+      "roles": roles, "paths": paths, "method": method, "params": perm.params };
+  }
+
+  function checkError(data) {
+    var cause = null;
+    if ("errorMessages" in data && Array.isArray(data["errorMessages"]) && data["errorMessages"].length > 0) {
+      cause = "?";
+      if ("errorMessages" in data["errorMessages"][0] && Array.isArray(data["errorMessages"][0]["errorMessages"]) && data["errorMessages"][0]["errorMessages"].length > 0) {
+        cause = data["errorMessages"][0]["errorMessages"][0];
+      }
+    }
+    return cause;
+  }
+
+  function truncateTo(str, maxLen, delim) {
+    // allow for a little on either side of maxLen for better display
+    var varLen = Math.min(Math.round(maxLen * 0.1), 15);
+    if (str.length <= maxLen + varLen) {
+      return str;
+    }
+
+    var total = str.split(delim).length;
+    var at = str.indexOf(delim, maxLen - varLen);
+    str = (at !== -1 && at < maxLen + varLen) ? str.substring(0, at) : str.substring(0, maxLen);
+    var trimmed = str.split(delim).length;
+    var diff = total - trimmed;
+    str += " ... "+(diff > 1 ? "(+"+diff+" more)" : "");
+    return str;
+  }
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.securityAPIError;
+    delete $scope.securityAPIErrorDetails;
+  };
+
+  $scope.displayList = function(listOrStr) {
+    if (!listOrStr) return "";
+    var str = Array.isArray(listOrStr) ? listOrStr.sort().join(", ") : (""+listOrStr).trim();
+    return truncateTo(str, 160, ", ");
+  };
+
+  $scope.displayParams = function(obj) {
+    if (!obj) return "";
+    if (Array.isArray(obj)) return obj.sort().join(", ");
+
+    var display = "";
+    for (const [key, value] of Object.entries(obj)) {
+      if (display.length > 0) display += "; ";
+      display += (key + "=" + (Array.isArray(value)?value.sort().join(","):value+""));
+    }
+    return truncateTo(display, 160, "; ");
+  };
+
+  $scope.displayRoles = function(obj) {
+    return (!obj || (Array.isArray(obj) && obj.length === 0)) ? "null" : $scope.displayList(obj);
+  };
+
+  $scope.predefinedPermissions = ["collection-admin-edit", "collection-admin-read", "core-admin-read", "core-admin-edit", "zk-read",
+    "read", "update", "all", "config-edit", "config-read", "schema-read", "schema-edit", "security-edit", "security-read",
+    "metrics-read", "filestore-read", "filestore-write", "package-edit", "package-read"].sort();
+
+  $scope.predefinedPermissionCollection = {"read":"*", "update":"*", "config-edit":"*", "config-read":"*", "schema-edit":"*", "schema-read":"*"};
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error && error.msg) {
+      $scope.securityAPIError = error.msg;
+      $scope.securityAPIErrorDetails = e.data.errorDetails;
+    } else if (e.data && e.data.message) {
+      $scope.securityAPIError = e.data.message;
+      $scope.securityAPIErrorDetails = JSON.stringify(e.data);
+    }
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.hideAll();
+
+    $scope.tls = false;
+    $scope.blockUnknown = "false"; // default setting
+    $scope.realmName = "solr";
+    $scope.forwardCredentials = "false";
+
+    $scope.currentUser = sessionStorage.getItem("auth.username");
+
+    $scope.userFilter = "";
+    $scope.userFilterOption = "";
+    $scope.userFilterText = "";
+    $scope.userFilterOptions = [];
+
+    $scope.permFilter = "";
+    $scope.permFilterOption = "";
+    $scope.permFilterOptions = [];
+    $scope.permFilterTypes = ["", "name", "role", "path", "collection"];
+
+    System.get(function(data) {
+      $scope.tls = data.security ? data.security["tls"] : false;
+      $scope.authenticationPlugin = data.security ? data.security["authenticationPlugin"] : null;
+      $scope.authorizationPlugin = data.security ? data.security["authorizationPlugin"] : null;
+      $scope.myRoles = data.security ? data.security["roles"] : [];
+      $scope.isSecurityAdminEnabled = $scope.authenticationPlugin != null;
+      $scope.isCloudMode = data.mode.match( /solrcloud/i ) != null;
+      $scope.zkHost = $scope.isCloudMode ? data["zkHost"] : "";
+      $scope.solrHome = data["solr_home"];
+      $scope.refreshSecurityPanel();
+    }, function(e) {
+      if (e.status === 401 || e.status === 403) {
+        $scope.isSecurityAdminEnabled = true;
+        $scope.hasSecurityEditPerm = false;
+        $scope.hideAll();
+      }
+    });
+  };
+
+  $scope.hideAll = function () {
+    // add more dialogs here
+    delete $scope.validationError;
+    $scope.showUserDialog = false;
+    $scope.showPermDialog = false;
+    delete $scope.helpId;
+  };
+
+  $scope.getCurrentUserRoles = function() {
+    if ($scope.manageUserRolesEnabled) {
+      return Array.isArray($scope.userRoles[$scope.currentUser]) ? $scope.userRoles[$scope.currentUser] : [$scope.userRoles[$scope.currentUser]];
+    } else {
+      return $scope.myRoles;
+    }
+  };
+
+  $scope.hasPermission = function(permissionName) {
+    var rolesForPermission = $scope.permissionsTable.filter(p => permissionName === p.name).flatMap(p => p.roles);
+    return (rolesForPermission.length > 0 && roleMatch(rolesForPermission, $scope.getCurrentUserRoles()));
+  };
+
+  $scope.refreshSecurityPanel = function() {
+
+    // determine if the authorization plugin supports CRUD permissions
+    $scope.managePermissionsEnabled =
+        ($scope.authorizationPlugin === "org.apache.solr.security.RuleBasedAuthorizationPlugin" ||
+            $scope.authorizationPlugin === "org.apache.solr.security.ExternalRoleRuleBasedAuthorizationPlugin");
+
+    // don't allow CRUD on roles if using external
+    $scope.manageUserRolesEnabled = $scope.authorizationPlugin === "org.apache.solr.security.RuleBasedAuthorizationPlugin";
+
+    Security.get({path: "authorization"}, function (data) {
+      if (!data.authorization) {
+        $scope.isSecurityAdminEnabled = false;
+        $scope.hasSecurityEditPerm = false;
+        return;
+      }
+
+      if ($scope.manageUserRolesEnabled) {
+        $scope.userRoles = data.authorization["user-role"];
+        $scope.roles = transposeUserRoles($scope.userRoles);
+        $scope.filteredRoles = $scope.roles;
+        $scope.roleNames = $scope.roles.map(r => r.name).sort();
+        $scope.roleNamesWithWildcard = ["*"].concat($scope.roleNames);
+        if (!$scope.permFilterTypes.includes("user")) {
+          $scope.permFilterTypes.push("user"); // can only filter perms by user if we have a role to user mapping
+        }
+      } else {
+        $scope.userRoles = {};
+        $scope.roles = [];
+        $scope.filteredRoles = [];
+        $scope.roleNames = [];
+      }
+
+      $scope.permissions = data.authorization["permissions"];
+      $scope.permissionsTable = [];
+      for (p in $scope.permissions) {
+        $scope.permissionsTable.push(permRow($scope.permissions[p], parseInt(p)+1));
+      }
+      $scope.filteredPerms = $scope.permissionsTable;
+
+      $scope.hasSecurityEditPerm = $scope.hasPermission("security-edit");
+      $scope.hasSecurityReadPerm = $scope.hasSecurityEditPerm || $scope.hasPermission("security-read");
+
+      if ($scope.authenticationPlugin === "org.apache.solr.security.BasicAuthPlugin") {
+        $scope.manageUsersEnabled = true;
+
+        Security.get({path: "authentication"}, function (data) {
+          if (!data.authentication) {
+            // TODO: error msg
+            $scope.manageUsersEnabled = false;
+          }
+
+          $scope.blockUnknown = data.authentication["blockUnknown"] === true ? "true" : "false";
+          $scope.forwardCredentials = data.authentication["forwardCredentials"] === true ? "true" : "false";
+
+          if ("realm" in data.authentication) {
+            $scope.realmName = data.authentication["realm"];
+          }
+
+          var users = [];
+          if (data.authentication.credentials) {
+            for (var u in data.authentication.credentials) {
+              var roles = $scope.userRoles[u];
+              if (!roles) roles = [];
+              users.push({"username":u, "roles":roles});
+            }
+          }
+          $scope.users = users.sort((a, b) => (a.username > b.username) ? 1 : -1);
+          $scope.filteredUsers = $scope.users.slice(0,100); // only display first 100
+        }, $scope.errorHandler);
+      } else {
+        $scope.users = [];
+        $scope.filteredUsers = $scope.users;
+        $scope.manageUsersEnabled = false;
+      }
+    }, $scope.errorHandler);
+  };
+
+  $scope.validatePassword = function() {
+    var password = $scope.upsertUser.password.trim();
+    var password2 = $scope.upsertUser.password2 ? $scope.upsertUser.password2.trim() : "";
+    if (password !== password2) {
+      $scope.validationError = "Passwords do not match!";
+      return false;
+    }
+
+    if (!password.match(strongPasswordRegex)) {
+      $scope.validationError = "Password not strong enough! Must contain at least one lowercase letter, one uppercase letter, one digit, and one of these special characters: !@#$%^&*_-[]()";
+      return false;
+    }
+
+    return true;
+  };
+
+  $scope.updateUserRoles = function() {
+    var setUserRoles = {};
+    var roles = [];
+    if ($scope.upsertUser.selectedRoles) {
+      roles = roles.concat($scope.upsertUser.selectedRoles);
+    }
+    if ($scope.upsertUser.newRole && $scope.upsertUser.newRole.trim() !== "") {
+      var newRole = $scope.upsertUser.newRole.trim();
+      if (newRole !== "null" && newRole !== "*" && newRole.length <= 30) {
+        roles.push(newRole);
+      } // else, no new role for you!
+    }
+    var userRoles = Array.from(new Set(roles));
+    setUserRoles[$scope.upsertUser.username] = userRoles.length > 0 ? userRoles : null;
+    Security.post({path: "authorization"}, { "set-user-role": setUserRoles }, function (data) {
+      $scope.toggleUserDialog();
+      $scope.refreshSecurityPanel();
+    });
+  };
+
+  $scope.doUpsertUser = function() {
+    if (!$scope.upsertUser) {
+      delete $scope.validationError;
+      $scope.showUserDialog = false;
+      return;
+    }
+
+    if (!$scope.upsertUser.username || $scope.upsertUser.username.trim() === "") {
+      $scope.validationError = "Username is required!";
+      return;
+    }
+
+    // keep username to a reasonable length? but allow for email addresses
+    var username = $scope.upsertUser.username.trim();
+    if (username.length > 30) {
+      $scope.validationError = "Username must be 30 characters or less!";
+      return;
+    }
+
+    var doSetUser = false;
+    if ($scope.userDialogMode === 'add') {
+      if ($scope.users) {
+        var existing = $scope.users.find(u => u.username === username);
+        if (existing) {
+          $scope.validationError = "User '"+username+"' already exists!";
+          return;
+        }
+      }
+
+      if (!$scope.upsertUser.password) {
+        $scope.validationError = "Password is required!";
+        return;
+      }
+
+      if (!$scope.validatePassword()) {
+        return;
+      }
+      doSetUser = true;
+    } else {
+      if ($scope.upsertUser.password) {
+        if ($scope.validatePassword()) {
+          doSetUser = true;
+        } else {
+          return; // update to password is invalid
+        }
+      } // else no update to password
+    }
+
+    if ($scope.upsertUser.newRole && $scope.upsertUser.newRole.trim() !== "") {
+      var newRole = $scope.upsertUser.newRole.trim();
+      if (newRole === "null" || newRole === "*" || newRole.length > 30) {
+        $scope.validationError = "Invalid new role: "+newRole;
+        return;
+      }
+    }
+
+    delete $scope.validationError;
+
+    if (doSetUser) {
+      var setUserJson = {};
+      setUserJson[username] = $scope.upsertUser.password.trim();
+      Security.post({path: "authentication"}, { "set-user": setUserJson }, function (data) {
+
+        var errorCause = checkError(data);
+        if (errorCause != null) {
+          $scope.securityAPIError = "create user "+username+" failed due to: "+errorCause;
+          $scope.securityAPIErrorDetails = JSON.stringify(data);
+          return;
+        }
+
+        $scope.updateUserRoles();
+      });
+    } else {
+      $scope.updateUserRoles();
+    }
+  };
+
+  $scope.confirmDeleteUser = function() {
+    if (window.confirm("Confirm delete the '"+$scope.upsertUser.username+"' user?")) {
+      // remove all roles for the user and the delete the user
+      var removeRoles = {};
+      removeRoles[$scope.upsertUser.username] = null;
+      Security.post({path: "authorization"}, { "set-user-role": removeRoles }, function (data) {
+        Security.post({path: "authentication"}, {"delete-user": [$scope.upsertUser.username]}, function (data2) {
+          $scope.toggleUserDialog();
+          $scope.refreshSecurityPanel();
+        });
+      });
+    }
+  };
+
+  $scope.showAddUserDialog = function() {
+    $scope.userDialogMode = "add";
+    $scope.userDialogHeader = "Add New User";
+    $scope.userDialogAction = "Add User";
+    $scope.upsertUser = {};
+    $scope.toggleUserDialog();
+  };
+
+  $scope.toggleUserDialog = function() {
+    if ($scope.showUserDialog) {
+      delete $scope.upsertUser;
+      delete $scope.validationError;
+      $scope.showUserDialog = false;
+      return;
+    }
+
+    $scope.hideAll();
+    $('#user-dialog').css({left: 132, top: 132});
+    $scope.showUserDialog = true;
+  };
+
+  $scope.onPredefinedChanged = function() {
+    if (!$scope.upsertPerm) {
+      return;
+    }
+
+    if ($scope.upsertPerm.name && $scope.upsertPerm.name.trim() !== "") {
+      delete $scope.selectedPredefinedPermission;
+    } else {
+      $scope.upsertPerm.name = "";
+    }
+
+    if ($scope.selectedPredefinedPermission && $scope.selectedPredefinedPermission in $scope.predefinedPermissionCollection) {
+      $scope.upsertPerm.collection = $scope.predefinedPermissionCollection[$scope.selectedPredefinedPermission];
+    }
+
+    $scope.isPermFieldDisabled = ($scope.upsertPerm.name === "" && $scope.selectedPredefinedPermission);
+  };
+
+  $scope.showAddPermDialog = function() {
+    $scope.permDialogMode = "add";
+    $scope.permDialogHeader = "Add New Permission";
+    $scope.permDialogAction = "Add Permission";
+    $scope.upsertPerm = {};
+    $scope.upsertPerm.name = "";
+    $scope.upsertPerm.index = "";
+    $scope.upsertPerm["method"] = {"get":"true", "post":"true", "put":"true", "delete":"true"};
+    $scope.isPermFieldDisabled = false;
+    delete $scope.selectedPredefinedPermission;
+
+    $scope.params = [{"name":"", "value":""}];
+
+    var permissionNames = $scope.permissions.map(p => p.name);
+    $scope.filteredPredefinedPermissions = $scope.predefinedPermissions.filter(p => !permissionNames.includes(p));
+
+    $scope.togglePermDialog();
+  };
+
+  $scope.togglePermDialog = function() {
+    if ($scope.showPermDialog) {
+      delete $scope.upsertPerm;
+      delete $scope.validationError;
+      $scope.showPermDialog = false;
+      $scope.isPermFieldDisabled = false;
+      delete $scope.selectedPredefinedPermission;
+      return;
+    }
+
+    $scope.hideAll();
+
+    var leftPos = $scope.permDialogMode === "add" ? 500 : 100;
+    var topPos = $('#permissions').offset().top - 320;
+    if (topPos < 0) topPos = 0;
+    $('#add-permission-dialog').css({left: leftPos, top: topPos});
+
+    $scope.showPermDialog = true;
+  };
+
+  $scope.getMethods = function() {
+    var methods = [];
+    if ($scope.upsertPerm.method.get === "true") {
+      methods.push("GET");
+    }
+    if ($scope.upsertPerm.method.put === "true") {
+      methods.push("PUT");
+    }
+    if ($scope.upsertPerm.method.post === "true") {
+      methods.push("POST");
+    }
+    if ($scope.upsertPerm.method.delete === "true") {
+      methods.push("DELETE");
+    }
+    return methods;
+  };
+
+  $scope.confirmDeletePerm = function() {
+    var permName = $scope.selectedPredefinedPermission ? $scope.selectedPredefinedPermission : $scope.upsertPerm.name.trim();
+    if (window.confirm("Confirm delete the '"+permName+"' permission?")) {
+      var index = parseInt($scope.upsertPerm.index);
+      Security.post({path: "authorization"}, { "delete-permission": index }, function (data) {
+        $scope.togglePermDialog();
+        $scope.refreshSecurityPanel();
+      });
+    }
+  };
+
+  $scope.doUpsertPermission = function() {
+    if (!$scope.upsertPerm) {
+      $scope.upsertPerm = {};
+    }
+
+    var isAdd = $scope.permDialogMode === "add";
+    var name = $scope.selectedPredefinedPermission ? $scope.selectedPredefinedPermission : $scope.upsertPerm.name.trim();
+
+    if (isAdd) {
+      if (!name) {
+        $scope.validationError = "Either select a predefined permission or provide a name for a custom permission";
+        return;
+      }
+      var permissionNames = $scope.permissions.map(p => p.name);
+      if (permissionNames.includes(name)) {
+        $scope.validationError = "Permission '"+name+"' already exists!";
+        return;
+      }
+
+      if (name === "*") {
+        $scope.validationError = "Invalid permission name!";
+        return;
+      }
+    }
+
+    var role = null;
+    if ($scope.manageUserRolesEnabled) {
+      role = $scope.upsertPerm.selectedRoles;
+      if (!role || role.length === 0) {
+        role = null;
+      } else if (role.includes("*")) {
+        role = ["*"];
+      }
+    } else if ($scope.upsertPerm.manualRoles && $scope.upsertPerm.manualRoles.trim() !== "") {
+      var manualRoles = $scope.upsertPerm.manualRoles.trim();
+      role = (manualRoles === "null") ? null : toList(manualRoles);
+    }
+
+    var setPermJson = {"name": name, "role": role };
+
+    if ($scope.selectedPredefinedPermission) {
+      $scope.params = [{"name":"","value":""}];
+    } else {
+      // collection
+      var coll = null;
+      if ($scope.upsertPerm.collection != null && $scope.upsertPerm.collection !== "null") {
+        if ($scope.upsertPerm.collection === "*") {
+          coll = "*";
+        } else {
+          coll = $scope.upsertPerm.collection && $scope.upsertPerm.collection.trim() !== "" ? toList($scope.upsertPerm.collection) : "";
+        }
+      }
+      setPermJson["collection"] = coll;
+
+      // path
+      if (!$scope.upsertPerm.path || (Array.isArray($scope.upsertPerm.path) && $scope.upsertPerm.path.length === 0)) {
+        $scope.validationError = "Path is required for custom permissions!";
+        return;
+      }
+
+      setPermJson["path"] = toList($scope.upsertPerm.path);

Review comment:
       *W069:*  ['path'] is better written in dot notation.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/security.js
##########
@@ -0,0 +1,1124 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cookies, $window, Constants, System, Security) {
+  $scope.resetMenu("security", Constants.IS_ROOT_PAGE);
+
+  $scope.params = [];
+
+  var strongPasswordRegex = /^(?=.*[0-9])(?=.*[!@#$%^&*\-_()[\]])[a-zA-Z0-9!@#$%^&*\-_()[\]]{8,30}$/;
+
+  function toList(str) {
+    if (Array.isArray(str)) {
+      return str; // already a list
+    }
+    return str.trim().split(",").map(s => s.trim()).filter(s => s !== "");
+  }
+
+  function asList(listOrStr) {
+    return Array.isArray(listOrStr) ? listOrStr : (listOrStr ? [listOrStr] : []);
+  }
+
+  function transposeUserRoles(userRoles) {
+    var roleUsers = {};
+    for (var u in userRoles) {
+      var roleList = asList(userRoles[u]);
+      for (var i in roleList) {
+        var role = roleList[i];
+        if (!roleUsers[role]) roleUsers[role] = []
+        roleUsers[role].push(u);
+      }
+    }
+
+    var roles = [];
+    for (var r in roleUsers) {
+      roles.push({"name":r, "users":Array.from(new Set(roleUsers[r]))});
+    }
+    return roles.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  function roleMatch(roles, rolesForUser) {
+    for (r in rolesForUser) {
+      if (roles.includes(rolesForUser[r]))
+        return true;
+    }
+    return false;
+  }
+
+  function permRow(perm, i) {
+    var roles = asList(perm.role);
+    var paths = asList(perm.path);
+
+    var collectionNames = "";
+    var collections = [];
+    if ("collection" in perm) {
+      if (perm["collection"] == null) {
+        collectionNames = "null";
+      } else {
+        collections = asList(perm.collection);
+        collectionNames = collections.sort().join(", ");
+      }
+    } else {
+      // no collection property on the perm, so the default "*" applies
+      collectionNames = "";
+      collections.push("*");
+    }
+
+    var method = asList(perm.method);
+
+    // perms don't always have an index ?!?
+    var index = "index" in perm ? perm["index"] : ""+i;
+
+    return { "index": index, "name": perm.name, "collectionNames": collectionNames, "collections": collections,
+      "roles": roles, "paths": paths, "method": method, "params": perm.params };
+  }
+
+  function checkError(data) {
+    var cause = null;
+    if ("errorMessages" in data && Array.isArray(data["errorMessages"]) && data["errorMessages"].length > 0) {
+      cause = "?";
+      if ("errorMessages" in data["errorMessages"][0] && Array.isArray(data["errorMessages"][0]["errorMessages"]) && data["errorMessages"][0]["errorMessages"].length > 0) {
+        cause = data["errorMessages"][0]["errorMessages"][0];
+      }
+    }
+    return cause;
+  }
+
+  function truncateTo(str, maxLen, delim) {
+    // allow for a little on either side of maxLen for better display
+    var varLen = Math.min(Math.round(maxLen * 0.1), 15);
+    if (str.length <= maxLen + varLen) {
+      return str;
+    }
+
+    var total = str.split(delim).length;
+    var at = str.indexOf(delim, maxLen - varLen);
+    str = (at !== -1 && at < maxLen + varLen) ? str.substring(0, at) : str.substring(0, maxLen);
+    var trimmed = str.split(delim).length;
+    var diff = total - trimmed;
+    str += " ... "+(diff > 1 ? "(+"+diff+" more)" : "");
+    return str;
+  }
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.securityAPIError;
+    delete $scope.securityAPIErrorDetails;
+  };
+
+  $scope.displayList = function(listOrStr) {
+    if (!listOrStr) return "";
+    var str = Array.isArray(listOrStr) ? listOrStr.sort().join(", ") : (""+listOrStr).trim();
+    return truncateTo(str, 160, ", ");
+  };
+
+  $scope.displayParams = function(obj) {
+    if (!obj) return "";
+    if (Array.isArray(obj)) return obj.sort().join(", ");
+
+    var display = "";
+    for (const [key, value] of Object.entries(obj)) {
+      if (display.length > 0) display += "; ";
+      display += (key + "=" + (Array.isArray(value)?value.sort().join(","):value+""));
+    }
+    return truncateTo(display, 160, "; ");
+  };
+
+  $scope.displayRoles = function(obj) {
+    return (!obj || (Array.isArray(obj) && obj.length === 0)) ? "null" : $scope.displayList(obj);
+  };
+
+  $scope.predefinedPermissions = ["collection-admin-edit", "collection-admin-read", "core-admin-read", "core-admin-edit", "zk-read",
+    "read", "update", "all", "config-edit", "config-read", "schema-read", "schema-edit", "security-edit", "security-read",
+    "metrics-read", "filestore-read", "filestore-write", "package-edit", "package-read"].sort();
+
+  $scope.predefinedPermissionCollection = {"read":"*", "update":"*", "config-edit":"*", "config-read":"*", "schema-edit":"*", "schema-read":"*"};
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error && error.msg) {
+      $scope.securityAPIError = error.msg;
+      $scope.securityAPIErrorDetails = e.data.errorDetails;
+    } else if (e.data && e.data.message) {
+      $scope.securityAPIError = e.data.message;
+      $scope.securityAPIErrorDetails = JSON.stringify(e.data);
+    }
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.hideAll();
+
+    $scope.tls = false;
+    $scope.blockUnknown = "false"; // default setting
+    $scope.realmName = "solr";
+    $scope.forwardCredentials = "false";
+
+    $scope.currentUser = sessionStorage.getItem("auth.username");
+
+    $scope.userFilter = "";
+    $scope.userFilterOption = "";
+    $scope.userFilterText = "";
+    $scope.userFilterOptions = [];
+
+    $scope.permFilter = "";
+    $scope.permFilterOption = "";
+    $scope.permFilterOptions = [];
+    $scope.permFilterTypes = ["", "name", "role", "path", "collection"];
+
+    System.get(function(data) {
+      $scope.tls = data.security ? data.security["tls"] : false;
+      $scope.authenticationPlugin = data.security ? data.security["authenticationPlugin"] : null;
+      $scope.authorizationPlugin = data.security ? data.security["authorizationPlugin"] : null;
+      $scope.myRoles = data.security ? data.security["roles"] : [];
+      $scope.isSecurityAdminEnabled = $scope.authenticationPlugin != null;
+      $scope.isCloudMode = data.mode.match( /solrcloud/i ) != null;
+      $scope.zkHost = $scope.isCloudMode ? data["zkHost"] : "";
+      $scope.solrHome = data["solr_home"];
+      $scope.refreshSecurityPanel();
+    }, function(e) {
+      if (e.status === 401 || e.status === 403) {
+        $scope.isSecurityAdminEnabled = true;
+        $scope.hasSecurityEditPerm = false;
+        $scope.hideAll();
+      }
+    });
+  };
+
+  $scope.hideAll = function () {
+    // add more dialogs here
+    delete $scope.validationError;
+    $scope.showUserDialog = false;
+    $scope.showPermDialog = false;
+    delete $scope.helpId;
+  };
+
+  $scope.getCurrentUserRoles = function() {
+    if ($scope.manageUserRolesEnabled) {
+      return Array.isArray($scope.userRoles[$scope.currentUser]) ? $scope.userRoles[$scope.currentUser] : [$scope.userRoles[$scope.currentUser]];
+    } else {
+      return $scope.myRoles;
+    }
+  };
+
+  $scope.hasPermission = function(permissionName) {
+    var rolesForPermission = $scope.permissionsTable.filter(p => permissionName === p.name).flatMap(p => p.roles);
+    return (rolesForPermission.length > 0 && roleMatch(rolesForPermission, $scope.getCurrentUserRoles()));
+  };
+
+  $scope.refreshSecurityPanel = function() {
+
+    // determine if the authorization plugin supports CRUD permissions
+    $scope.managePermissionsEnabled =
+        ($scope.authorizationPlugin === "org.apache.solr.security.RuleBasedAuthorizationPlugin" ||
+            $scope.authorizationPlugin === "org.apache.solr.security.ExternalRoleRuleBasedAuthorizationPlugin");
+
+    // don't allow CRUD on roles if using external
+    $scope.manageUserRolesEnabled = $scope.authorizationPlugin === "org.apache.solr.security.RuleBasedAuthorizationPlugin";
+
+    Security.get({path: "authorization"}, function (data) {
+      if (!data.authorization) {
+        $scope.isSecurityAdminEnabled = false;
+        $scope.hasSecurityEditPerm = false;
+        return;
+      }
+
+      if ($scope.manageUserRolesEnabled) {
+        $scope.userRoles = data.authorization["user-role"];
+        $scope.roles = transposeUserRoles($scope.userRoles);
+        $scope.filteredRoles = $scope.roles;
+        $scope.roleNames = $scope.roles.map(r => r.name).sort();
+        $scope.roleNamesWithWildcard = ["*"].concat($scope.roleNames);
+        if (!$scope.permFilterTypes.includes("user")) {
+          $scope.permFilterTypes.push("user"); // can only filter perms by user if we have a role to user mapping
+        }
+      } else {
+        $scope.userRoles = {};
+        $scope.roles = [];
+        $scope.filteredRoles = [];
+        $scope.roleNames = [];
+      }
+
+      $scope.permissions = data.authorization["permissions"];
+      $scope.permissionsTable = [];
+      for (p in $scope.permissions) {
+        $scope.permissionsTable.push(permRow($scope.permissions[p], parseInt(p)+1));
+      }
+      $scope.filteredPerms = $scope.permissionsTable;
+
+      $scope.hasSecurityEditPerm = $scope.hasPermission("security-edit");
+      $scope.hasSecurityReadPerm = $scope.hasSecurityEditPerm || $scope.hasPermission("security-read");
+
+      if ($scope.authenticationPlugin === "org.apache.solr.security.BasicAuthPlugin") {
+        $scope.manageUsersEnabled = true;
+
+        Security.get({path: "authentication"}, function (data) {
+          if (!data.authentication) {
+            // TODO: error msg
+            $scope.manageUsersEnabled = false;
+          }
+
+          $scope.blockUnknown = data.authentication["blockUnknown"] === true ? "true" : "false";
+          $scope.forwardCredentials = data.authentication["forwardCredentials"] === true ? "true" : "false";
+
+          if ("realm" in data.authentication) {
+            $scope.realmName = data.authentication["realm"];
+          }
+
+          var users = [];
+          if (data.authentication.credentials) {
+            for (var u in data.authentication.credentials) {
+              var roles = $scope.userRoles[u];
+              if (!roles) roles = [];
+              users.push({"username":u, "roles":roles});
+            }
+          }
+          $scope.users = users.sort((a, b) => (a.username > b.username) ? 1 : -1);
+          $scope.filteredUsers = $scope.users.slice(0,100); // only display first 100
+        }, $scope.errorHandler);
+      } else {
+        $scope.users = [];
+        $scope.filteredUsers = $scope.users;
+        $scope.manageUsersEnabled = false;
+      }
+    }, $scope.errorHandler);
+  };
+
+  $scope.validatePassword = function() {
+    var password = $scope.upsertUser.password.trim();
+    var password2 = $scope.upsertUser.password2 ? $scope.upsertUser.password2.trim() : "";
+    if (password !== password2) {
+      $scope.validationError = "Passwords do not match!";
+      return false;
+    }
+
+    if (!password.match(strongPasswordRegex)) {
+      $scope.validationError = "Password not strong enough! Must contain at least one lowercase letter, one uppercase letter, one digit, and one of these special characters: !@#$%^&*_-[]()";
+      return false;
+    }
+
+    return true;
+  };
+
+  $scope.updateUserRoles = function() {
+    var setUserRoles = {};
+    var roles = [];
+    if ($scope.upsertUser.selectedRoles) {
+      roles = roles.concat($scope.upsertUser.selectedRoles);
+    }
+    if ($scope.upsertUser.newRole && $scope.upsertUser.newRole.trim() !== "") {
+      var newRole = $scope.upsertUser.newRole.trim();
+      if (newRole !== "null" && newRole !== "*" && newRole.length <= 30) {
+        roles.push(newRole);
+      } // else, no new role for you!
+    }
+    var userRoles = Array.from(new Set(roles));
+    setUserRoles[$scope.upsertUser.username] = userRoles.length > 0 ? userRoles : null;
+    Security.post({path: "authorization"}, { "set-user-role": setUserRoles }, function (data) {
+      $scope.toggleUserDialog();
+      $scope.refreshSecurityPanel();
+    });
+  };
+
+  $scope.doUpsertUser = function() {
+    if (!$scope.upsertUser) {
+      delete $scope.validationError;
+      $scope.showUserDialog = false;
+      return;
+    }
+
+    if (!$scope.upsertUser.username || $scope.upsertUser.username.trim() === "") {
+      $scope.validationError = "Username is required!";
+      return;
+    }
+
+    // keep username to a reasonable length? but allow for email addresses
+    var username = $scope.upsertUser.username.trim();
+    if (username.length > 30) {
+      $scope.validationError = "Username must be 30 characters or less!";
+      return;
+    }
+
+    var doSetUser = false;
+    if ($scope.userDialogMode === 'add') {
+      if ($scope.users) {
+        var existing = $scope.users.find(u => u.username === username);
+        if (existing) {
+          $scope.validationError = "User '"+username+"' already exists!";
+          return;
+        }
+      }
+
+      if (!$scope.upsertUser.password) {
+        $scope.validationError = "Password is required!";
+        return;
+      }
+
+      if (!$scope.validatePassword()) {
+        return;
+      }
+      doSetUser = true;
+    } else {
+      if ($scope.upsertUser.password) {
+        if ($scope.validatePassword()) {
+          doSetUser = true;
+        } else {
+          return; // update to password is invalid
+        }
+      } // else no update to password
+    }
+
+    if ($scope.upsertUser.newRole && $scope.upsertUser.newRole.trim() !== "") {
+      var newRole = $scope.upsertUser.newRole.trim();
+      if (newRole === "null" || newRole === "*" || newRole.length > 30) {
+        $scope.validationError = "Invalid new role: "+newRole;
+        return;
+      }
+    }
+
+    delete $scope.validationError;
+
+    if (doSetUser) {
+      var setUserJson = {};
+      setUserJson[username] = $scope.upsertUser.password.trim();
+      Security.post({path: "authentication"}, { "set-user": setUserJson }, function (data) {
+
+        var errorCause = checkError(data);
+        if (errorCause != null) {
+          $scope.securityAPIError = "create user "+username+" failed due to: "+errorCause;
+          $scope.securityAPIErrorDetails = JSON.stringify(data);
+          return;
+        }
+
+        $scope.updateUserRoles();
+      });
+    } else {
+      $scope.updateUserRoles();
+    }
+  };
+
+  $scope.confirmDeleteUser = function() {
+    if (window.confirm("Confirm delete the '"+$scope.upsertUser.username+"' user?")) {
+      // remove all roles for the user and the delete the user
+      var removeRoles = {};
+      removeRoles[$scope.upsertUser.username] = null;
+      Security.post({path: "authorization"}, { "set-user-role": removeRoles }, function (data) {
+        Security.post({path: "authentication"}, {"delete-user": [$scope.upsertUser.username]}, function (data2) {
+          $scope.toggleUserDialog();
+          $scope.refreshSecurityPanel();
+        });
+      });
+    }
+  };
+
+  $scope.showAddUserDialog = function() {
+    $scope.userDialogMode = "add";
+    $scope.userDialogHeader = "Add New User";
+    $scope.userDialogAction = "Add User";
+    $scope.upsertUser = {};
+    $scope.toggleUserDialog();
+  };
+
+  $scope.toggleUserDialog = function() {
+    if ($scope.showUserDialog) {
+      delete $scope.upsertUser;
+      delete $scope.validationError;
+      $scope.showUserDialog = false;
+      return;
+    }
+
+    $scope.hideAll();
+    $('#user-dialog').css({left: 132, top: 132});
+    $scope.showUserDialog = true;
+  };
+
+  $scope.onPredefinedChanged = function() {
+    if (!$scope.upsertPerm) {
+      return;
+    }
+
+    if ($scope.upsertPerm.name && $scope.upsertPerm.name.trim() !== "") {
+      delete $scope.selectedPredefinedPermission;
+    } else {
+      $scope.upsertPerm.name = "";
+    }
+
+    if ($scope.selectedPredefinedPermission && $scope.selectedPredefinedPermission in $scope.predefinedPermissionCollection) {
+      $scope.upsertPerm.collection = $scope.predefinedPermissionCollection[$scope.selectedPredefinedPermission];
+    }
+
+    $scope.isPermFieldDisabled = ($scope.upsertPerm.name === "" && $scope.selectedPredefinedPermission);
+  };
+
+  $scope.showAddPermDialog = function() {
+    $scope.permDialogMode = "add";
+    $scope.permDialogHeader = "Add New Permission";
+    $scope.permDialogAction = "Add Permission";
+    $scope.upsertPerm = {};
+    $scope.upsertPerm.name = "";
+    $scope.upsertPerm.index = "";
+    $scope.upsertPerm["method"] = {"get":"true", "post":"true", "put":"true", "delete":"true"};
+    $scope.isPermFieldDisabled = false;
+    delete $scope.selectedPredefinedPermission;
+
+    $scope.params = [{"name":"", "value":""}];
+
+    var permissionNames = $scope.permissions.map(p => p.name);
+    $scope.filteredPredefinedPermissions = $scope.predefinedPermissions.filter(p => !permissionNames.includes(p));
+
+    $scope.togglePermDialog();
+  };
+
+  $scope.togglePermDialog = function() {
+    if ($scope.showPermDialog) {
+      delete $scope.upsertPerm;
+      delete $scope.validationError;
+      $scope.showPermDialog = false;
+      $scope.isPermFieldDisabled = false;
+      delete $scope.selectedPredefinedPermission;
+      return;
+    }
+
+    $scope.hideAll();
+
+    var leftPos = $scope.permDialogMode === "add" ? 500 : 100;
+    var topPos = $('#permissions').offset().top - 320;
+    if (topPos < 0) topPos = 0;
+    $('#add-permission-dialog').css({left: leftPos, top: topPos});
+
+    $scope.showPermDialog = true;
+  };
+
+  $scope.getMethods = function() {
+    var methods = [];
+    if ($scope.upsertPerm.method.get === "true") {
+      methods.push("GET");
+    }
+    if ($scope.upsertPerm.method.put === "true") {
+      methods.push("PUT");
+    }
+    if ($scope.upsertPerm.method.post === "true") {
+      methods.push("POST");
+    }
+    if ($scope.upsertPerm.method.delete === "true") {
+      methods.push("DELETE");
+    }
+    return methods;
+  };
+
+  $scope.confirmDeletePerm = function() {
+    var permName = $scope.selectedPredefinedPermission ? $scope.selectedPredefinedPermission : $scope.upsertPerm.name.trim();
+    if (window.confirm("Confirm delete the '"+permName+"' permission?")) {
+      var index = parseInt($scope.upsertPerm.index);
+      Security.post({path: "authorization"}, { "delete-permission": index }, function (data) {
+        $scope.togglePermDialog();
+        $scope.refreshSecurityPanel();
+      });
+    }
+  };
+
+  $scope.doUpsertPermission = function() {
+    if (!$scope.upsertPerm) {
+      $scope.upsertPerm = {};
+    }
+
+    var isAdd = $scope.permDialogMode === "add";
+    var name = $scope.selectedPredefinedPermission ? $scope.selectedPredefinedPermission : $scope.upsertPerm.name.trim();
+
+    if (isAdd) {
+      if (!name) {
+        $scope.validationError = "Either select a predefined permission or provide a name for a custom permission";
+        return;
+      }
+      var permissionNames = $scope.permissions.map(p => p.name);
+      if (permissionNames.includes(name)) {
+        $scope.validationError = "Permission '"+name+"' already exists!";
+        return;
+      }
+
+      if (name === "*") {
+        $scope.validationError = "Invalid permission name!";
+        return;
+      }
+    }
+
+    var role = null;
+    if ($scope.manageUserRolesEnabled) {
+      role = $scope.upsertPerm.selectedRoles;
+      if (!role || role.length === 0) {
+        role = null;
+      } else if (role.includes("*")) {
+        role = ["*"];
+      }
+    } else if ($scope.upsertPerm.manualRoles && $scope.upsertPerm.manualRoles.trim() !== "") {
+      var manualRoles = $scope.upsertPerm.manualRoles.trim();
+      role = (manualRoles === "null") ? null : toList(manualRoles);
+    }
+
+    var setPermJson = {"name": name, "role": role };
+
+    if ($scope.selectedPredefinedPermission) {
+      $scope.params = [{"name":"","value":""}];
+    } else {
+      // collection
+      var coll = null;
+      if ($scope.upsertPerm.collection != null && $scope.upsertPerm.collection !== "null") {
+        if ($scope.upsertPerm.collection === "*") {
+          coll = "*";
+        } else {
+          coll = $scope.upsertPerm.collection && $scope.upsertPerm.collection.trim() !== "" ? toList($scope.upsertPerm.collection) : "";
+        }
+      }
+      setPermJson["collection"] = coll;
+
+      // path
+      if (!$scope.upsertPerm.path || (Array.isArray($scope.upsertPerm.path) && $scope.upsertPerm.path.length === 0)) {
+        $scope.validationError = "Path is required for custom permissions!";
+        return;
+      }
+
+      setPermJson["path"] = toList($scope.upsertPerm.path);
+
+      if ($scope.upsertPerm.method) {
+        var methods = $scope.getMethods();
+        if (methods.length === 0) {
+          $scope.validationError = "Must specify at least one request method for a custom permission!";
+          return;
+        }
+
+        if (methods.length < 4) {
+          setPermJson["method"] = methods;
+        } // else no need to specify, rule applies to all methods
+      }
+
+      // params
+      var params = {};
+      if ($scope.params && $scope.params.length > 0) {
+        for (i in $scope.params) {
+          var p = $scope.params[i];
+          var name = p.name.trim();
+          if (name !== "" && p.value) {
+            if (name in params) {
+              params[name].push(p.value);
+            } else {
+              params[name] = [p.value];
+            }
+          }
+        }
+      }
+      setPermJson["params"] = params;
+    }
+
+    var indexUpdated = false;
+    if ($scope.upsertPerm.index) {
+      var indexOrBefore = isAdd ? "before" : "index";
+      var indexInt = parseInt($scope.upsertPerm.index);
+      if (indexInt < 1) indexInt = 1;
+      if (indexInt >= $scope.permissions.length) indexInt = null;
+      if (indexInt != null) {
+        setPermJson[indexOrBefore] = indexInt;
+      }
+      indexUpdated = (!isAdd && indexInt !== parseInt($scope.upsertPerm.originalIndex));
+    }
+
+    if (indexUpdated) {
+      // changing position is a delete + re-add in new position
+      Security.post({path: "authorization"}, { "delete-permission": parseInt($scope.upsertPerm.originalIndex) }, function (remData) {
+        if (setPermJson.index) {
+          var before = setPermJson.index;
+          delete setPermJson.index;
+          setPermJson["before"] = before;
+        }
+
+        // add perm back in new position
+        Security.post({path: "authorization"}, { "set-permission": setPermJson }, function (data) {
+          var errorCause = checkError(data);
+          if (errorCause != null) {
+            $scope.securityAPIError = "set-permission "+name+" failed due to: "+errorCause;
+            $scope.securityAPIErrorDetails = JSON.stringify(data);
+            return;
+          }
+          $scope.togglePermDialog();
+          $scope.refreshSecurityPanel();
+        });
+      });
+    } else {
+      var action = isAdd ? "set-permission" : "update-permission";
+      var postBody = {};
+      postBody[action] = setPermJson;
+      Security.post({path: "authorization"}, postBody, function (data) {
+        var errorCause = checkError(data);
+        if (errorCause != null) {
+          $scope.securityAPIError = action+" "+name+" failed due to: "+errorCause;
+          $scope.securityAPIErrorDetails = JSON.stringify(data);
+          return;
+        }
+
+        $scope.togglePermDialog();
+        $scope.refreshSecurityPanel();
+      });
+    }
+  };
+
+  $scope.applyUserFilter = function() {
+    $scope.userFilterText = "";
+    $scope.userFilterOption = "";
+    $scope.userFilterOptions = [];
+    $scope.filteredUsers = $scope.users; // reset the filtered when the filter type changes
+
+    if ($scope.userFilter === "name" || $scope.userFilter === "path") {
+      // no-op: filter is text input
+    } else if ($scope.userFilter === "role") {
+      $scope.userFilterOptions = $scope.roleNames;
+    } else if ($scope.userFilter === "perm") {
+      $scope.userFilterOptions = $scope.permissions.map(p => p.name).sort();
+    } else {
+      $scope.userFilter = "";
+    }
+  };
+
+  $scope.onUserFilterTextChanged = function() {
+    // don't fire until we have at least 2 chars ...
+    if ($scope.userFilterText && $scope.userFilterText.trim().length >= 2) {
+      $scope.userFilterOption = $scope.userFilterText.toLowerCase();
+      $scope.onUserFilterOptionChanged();
+    } else {
+      $scope.filteredUsers = $scope.users;
+    }
+  };
+
+  function pathMatch(paths, filter) {
+    for (p in paths) {
+      if (paths[p].includes(filter)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  $scope.onUserFilterOptionChanged = function() {
+    var filter = $scope.userFilterOption ? $scope.userFilterOption.trim() : "";
+    if (filter.length === 0) {
+      $scope.filteredUsers = $scope.users;
+      return;
+    }
+
+    if ($scope.userFilter === "name") {
+      $scope.filteredUsers = $scope.users.filter(u => u.username.toLowerCase().includes(filter));
+    } else if ($scope.userFilter === "role") {
+      $scope.filteredUsers = $scope.users.filter(u => u.roles.includes(filter));
+    } else if ($scope.userFilter === "path") {
+      var rolesForPath = Array.from(new Set($scope.permissionsTable.filter(p => p.roles && pathMatch(p.paths, filter)).flatMap(p => p.roles)));
+      var usersForPath = Array.from(new Set($scope.roles.filter(r => r.users && r.users.length > 0 && rolesForPath.includes(r.name)).flatMap(r => r.users)));
+      $scope.filteredUsers = $scope.users.filter(u => usersForPath.includes(u.username));
+    } else if ($scope.userFilter === "perm") {
+      var rolesForPerm = Array.from(new Set($scope.permissionsTable.filter(p => p.name === filter).flatMap(p => p.roles)));
+      var usersForPerm = Array.from(new Set($scope.roles.filter(r => r.users && r.users.length > 0 && rolesForPerm.includes(r.name)).flatMap(r => r.users)));
+      $scope.filteredUsers = $scope.users.filter(u => usersForPerm.includes(u.username));
+    } else {
+      // reset
+      $scope.userFilter = "";
+      $scope.userFilterOption = "";
+      $scope.userFilterText = "";
+      $scope.filteredUsers = $scope.users;
+    }
+  };
+
+  $scope.applyPermFilter = function() {
+    $scope.permFilterText = "";
+    $scope.permFilterOption = "";
+    $scope.permFilterOptions = [];
+    $scope.filteredPerms = $scope.permissionsTable;
+
+    if ($scope.permFilter === "name" || $scope.permFilter === "path") {
+      // no-op: filter is text input
+    } else if ($scope.permFilter === "role") {
+      var roles = $scope.manageUserRolesEnabled ? $scope.roleNames : Array.from(new Set($scope.permissionsTable.flatMap(p => p.roles))).sort();
+      $scope.permFilterOptions = ["*", "null"].concat(roles);
+    } else if ($scope.permFilter === "user") {
+      $scope.permFilterOptions = Array.from(new Set($scope.roles.flatMap(r => r.users))).sort();
+    } else if ($scope.permFilter === "collection") {
+      $scope.permFilterOptions = Array.from(new Set($scope.permissionsTable.flatMap(p => p.collections))).sort();
+      $scope.permFilterOptions.push("null");
+    } else {
+      // no perm filtering
+      $scope.permFilter = "";
+    }
+  };
+
+  $scope.onPermFilterTextChanged = function() {
+    // don't fire until we have at least 2 chars ...
+    if ($scope.permFilterText && $scope.permFilterText.trim().length >= 2) {
+      $scope.permFilterOption = $scope.permFilterText.trim().toLowerCase();
+      $scope.onPermFilterOptionChanged();
+    } else {
+      $scope.filteredPerms = $scope.permissionsTable;
+    }
+  };
+
+  $scope.onPermFilterOptionChanged = function() {
+    var filterCriteria = $scope.permFilterOption ? $scope.permFilterOption.trim() : "";
+    if (filterCriteria.length === 0) {
+      $scope.filteredPerms = $scope.permissionsTable;
+      return;
+    }
+
+    if ($scope.permFilter === "name") {
+      $scope.filteredPerms = $scope.permissionsTable.filter(p => p.name.toLowerCase().includes(filterCriteria));
+    } else if ($scope.permFilter === "role") {
+      if (filterCriteria === "null") {
+        $scope.filteredPerms = $scope.permissionsTable.filter(p => p.roles.length === 0);
+      } else {
+        $scope.filteredPerms = $scope.permissionsTable.filter(p => p.roles.includes(filterCriteria));
+      }
+    } else if ($scope.permFilter === "path") {
+      $scope.filteredPerms = $scope.permissionsTable.filter(p => pathMatch(p.paths, filterCriteria));
+    } else if ($scope.permFilter === "user") {
+      // get the user's roles and then find all the permissions mapped to each role
+      var rolesForUser = $scope.roles.filter(r => r.users.includes(filterCriteria)).map(r => r.name);
+      $scope.filteredPerms = $scope.permissionsTable.filter(p => p.roles.length > 0 && roleMatch(p.roles, rolesForUser));
+    } else if ($scope.permFilter === "collection") {
+      function collectionMatch(collNames, colls, filter) {
+        return (filter === "null") ?collNames === "null" : colls.includes(filter);
+      }
+      $scope.filteredPerms = $scope.permissionsTable.filter(p => collectionMatch(p.collectionNames, p.collections, filterCriteria));
+    } else {
+      // reset
+      $scope.permFilter = "";
+      $scope.permFilterOption = "";
+      $scope.permFilterText = "";
+      $scope.filteredPerms = $scope.permissionsTable;
+    }
+  };
+
+  $scope.editUser = function(row) {
+    if (!row || !$scope.hasSecurityEditPerm) {
+      return;
+    }
+
+    var userId = row.username;
+    $scope.userDialogMode = "edit";
+    $scope.userDialogHeader = "Edit User: "+userId;
+    $scope.userDialogAction = "Update";
+    var userRoles = userId in $scope.userRoles ? $scope.userRoles[userId] : [];
+    if (!Array.isArray(userRoles)) {
+      userRoles = [userRoles];
+    }
+
+    $scope.upsertUser = { username: userId, selectedRoles: userRoles };
+    $scope.toggleUserDialog();
+  };
+
+  function buildMethods(m) {
+    return {"get":""+m.includes("GET"), "post":""+m.includes("POST"), "put":""+m.includes("PUT"), "delete":""+m.includes("DELETE")};
+  }
+
+  $scope.editPerm = function(row) {
+    if (!$scope.managePermissionsEnabled || !$scope.hasSecurityEditPerm || !row) {
+      return;
+    }
+
+    var name = row.name;
+    $scope.permDialogMode = "edit";
+    $scope.permDialogHeader = "Edit Permission: "+name;
+    $scope.permDialogAction = "Update";
+
+    var perm = $scope.permissionsTable.find(p => p.name === name);
+    var isPredefined = $scope.predefinedPermissions.includes(name);
+    if (isPredefined) {
+      $scope.selectedPredefinedPermission = name;
+      $scope.upsertPerm = { };
+      $scope.filteredPredefinedPermissions = [];
+      $scope.filteredPredefinedPermissions.push(name);
+      if ($scope.selectedPredefinedPermission && $scope.selectedPredefinedPermission in $scope.predefinedPermissionCollection) {
+        $scope.upsertPerm.collection = $scope.predefinedPermissionCollection[$scope.selectedPredefinedPermission];
+      }
+      $scope.isPermFieldDisabled = true;
+    } else {
+      $scope.upsertPerm = { name: name, collection: perm.collectionNames, path: perm.paths };
+      $scope.params = [];
+      if (perm.params) {
+        for (const [key, value] of Object.entries(perm.params)) {
+          if (Array.isArray(value)) {
+            for (i in value) {
+              $scope.params.push({"name":key, "value":value[i]});
+            }
+          } else {
+            $scope.params.push({"name":key, "value":value});
+          }
+        }
+      }
+      if ($scope.params.length === 0) {
+        $scope.params = [{"name":"","value":""}];
+      }
+
+      $scope.upsertPerm["method"] = perm.method.length === 0 ? {"get":"true", "post":"true", "put":"true", "delete":"true"} : buildMethods(perm.method);
+      $scope.isPermFieldDisabled = false;
+      delete $scope.selectedPredefinedPermission;
+    }
+
+    $scope.upsertPerm.index = perm["index"];
+    $scope.upsertPerm.originalIndex = perm["index"];
+
+    // roles depending on authz plugin support
+    if ($scope.manageUserRolesEnabled) {
+      $scope.upsertPerm["selectedRoles"] = asList(perm.roles);
+    } else {
+      $scope.upsertPerm["manualRoles"] = asList(perm.roles).sort().join(", ");
+    }
+
+    $scope.togglePermDialog();
+  };
+
+  $scope.applyRoleFilter = function() {
+    $scope.roleFilterText = "";
+    $scope.roleFilterOption = "";
+    $scope.roleFilterOptions = [];
+    $scope.filteredRoles = $scope.roles; // reset the filtered when the filter type changes
+
+    if ($scope.roleFilter === "name" || $scope.roleFilter === "path") {
+      // no-op: filter is text input
+    } else if ($scope.roleFilter === "user") {
+      $scope.roleFilterOptions = Array.from(new Set($scope.roles.flatMap(r => r.users))).sort();
+    } else if ($scope.roleFilter === "perm") {
+      $scope.roleFilterOptions = $scope.permissions.map(p => p.name).sort();
+    } else {
+      $scope.roleFilter = "";
+    }
+  };
+
+  $scope.onRoleFilterTextChanged = function() {
+    // don't fire until we have at least 2 chars ...
+    if ($scope.roleFilterText && $scope.roleFilterText.trim().length >= 2) {
+      $scope.roleFilterOption = $scope.roleFilterText.toLowerCase();
+      $scope.onRoleFilterOptionChanged();
+    } else {
+      $scope.filteredRoles = $scope.roles;
+    }
+  };
+
+  $scope.onRoleFilterOptionChanged = function() {
+    var filter = $scope.roleFilterOption ? $scope.roleFilterOption.trim() : "";
+    if (filter.length === 0) {
+      $scope.filteredRoles = $scope.roles;
+      return;
+    }
+
+    if ($scope.roleFilter === "name") {
+      $scope.filteredRoles = $scope.roles.filter(r => r.name.toLowerCase().includes(filter));
+    } else if ($scope.roleFilter === "user") {
+      $scope.filteredRoles = $scope.roles.filter(r => r.users.includes(filter));
+    } else if ($scope.roleFilter === "path") {
+      var rolesForPath = Array.from(new Set($scope.permissionsTable.filter(p => p.roles && pathMatch(p.paths, filter)).flatMap(p => p.roles)));
+      $scope.filteredRoles = $scope.roles.filter(r => rolesForPath.includes(r.name));
+    } else if ($scope.roleFilter === "perm") {
+      var rolesForPerm = Array.from(new Set($scope.permissionsTable.filter(p => p.name === filter).flatMap(p => p.roles)));
+      $scope.filteredRoles = $scope.roles.filter(r => rolesForPerm.includes(r.name));
+    } else {
+      // reset
+      $scope.roleFilter = "";
+      $scope.roleFilterOption = "";
+      $scope.roleFilterText = "";
+      $scope.filteredRoles = $scope.roles;
+    }
+  };
+
+  $scope.showAddRoleDialog = function() {
+    $scope.roleDialogMode = "add";
+    $scope.roleDialogHeader = "Add New Role";
+    $scope.roleDialogAction = "Add Role";
+    $scope.upsertRole = {};
+    $scope.userNames = $scope.users.map(u => u.username);
+    $scope.grantPermissionNames = Array.from(new Set($scope.predefinedPermissions.concat($scope.permissions.map(p => p.name)))).sort();
+    $scope.toggleRoleDialog();
+  };
+
+  $scope.toggleRoleDialog = function() {
+    if ($scope.showRoleDialog) {
+      delete $scope.upsertRole;
+      delete $scope.validationError;
+      delete $scope.userNames;
+      $scope.showRoleDialog = false;
+      return;
+    }
+    $scope.hideAll();
+    $('#role-dialog').css({left: 680, top: 139});
+    $scope.showRoleDialog = true;
+  };
+
+  $scope.doUpsertRole = function() {
+    if (!$scope.upsertRole) {
+      delete $scope.validationError;
+      $scope.showRoleDialog = false;
+      return;
+    }
+
+    if (!$scope.upsertRole.name || $scope.upsertRole.name.trim() === "") {
+      $scope.validationError = "Role name is required!";
+      return;
+    }
+
+    // keep role name to a reasonable length? but allow for email addresses
+    var name = $scope.upsertRole.name.trim();
+    if (name.length > 30) {
+      $scope.validationError = "Role name must be 30 characters or less!";
+      return;
+    }
+
+    if (name === "null" || name === "*") {
+      $scope.validationError = "Role name '"+name+"' is invalid!";
+      return;
+    }
+
+    if ($scope.roleDialogMode === "add") {
+      if ($scope.roleNames.includes(name)) {
+        $scope.validationError = "Role '"+name+"' already exists!";
+        return;
+      }
+    }
+
+    var usersForRole = [];
+    if ($scope.upsertRole.selectedUsers && $scope.upsertRole.selectedUsers.length > 0) {
+      usersForRole = usersForRole.concat($scope.upsertRole.selectedUsers);
+    }
+    usersForRole = Array.from(new Set(usersForRole));
+    if (usersForRole.length === 0) {
+      $scope.validationError = "Must assign new role '"+name+"' to at least one user.";
+      return;
+    }
+
+    var perms = [];
+    if ($scope.upsertRole.grantedPerms && Array.isArray($scope.upsertRole.grantedPerms) && $scope.upsertRole.grantedPerms.length > 0) {
+      perms = $scope.upsertRole.grantedPerms;
+    }
+
+    // go get the latest role mappings ...
+    Security.get({path: "authorization"}, function (data) {
+      var userRoles = data.authorization["user-role"];
+      var setUserRoles = {};
+      for (u in usersForRole) {
+        var user = usersForRole[u];
+        var currentRoles = user in userRoles ? asList(userRoles[user]) : [];
+        // add the new role for this user if needed
+        if (!currentRoles.includes(name)) {
+          currentRoles.push(name);
+        }
+        setUserRoles[user] = currentRoles;
+      }
+
+      Security.post({path: "authorization"}, { "set-user-role": setUserRoles }, function (data2) {
+
+        var errorCause = checkError(data2);
+        if (errorCause != null) {
+          $scope.securityAPIError = "set-user-role for "+username+" failed due to: "+errorCause;
+          $scope.securityAPIErrorDetails = JSON.stringify(data2);
+          return;
+        }
+
+        if (perms.length === 0) {
+          // close dialog and refresh the tables ...
+          $scope.toggleRoleDialog();
+          $scope.refreshSecurityPanel();
+          return;
+        }
+
+        var currentPerms = data.authorization["permissions"];

Review comment:
       *W069:*  ['permissions'] is better written in dot notation.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/security.js
##########
@@ -0,0 +1,1124 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cookies, $window, Constants, System, Security) {
+  $scope.resetMenu("security", Constants.IS_ROOT_PAGE);
+
+  $scope.params = [];
+
+  var strongPasswordRegex = /^(?=.*[0-9])(?=.*[!@#$%^&*\-_()[\]])[a-zA-Z0-9!@#$%^&*\-_()[\]]{8,30}$/;
+
+  function toList(str) {
+    if (Array.isArray(str)) {
+      return str; // already a list
+    }
+    return str.trim().split(",").map(s => s.trim()).filter(s => s !== "");
+  }
+
+  function asList(listOrStr) {
+    return Array.isArray(listOrStr) ? listOrStr : (listOrStr ? [listOrStr] : []);
+  }
+
+  function transposeUserRoles(userRoles) {
+    var roleUsers = {};
+    for (var u in userRoles) {
+      var roleList = asList(userRoles[u]);
+      for (var i in roleList) {
+        var role = roleList[i];
+        if (!roleUsers[role]) roleUsers[role] = []
+        roleUsers[role].push(u);
+      }
+    }
+
+    var roles = [];
+    for (var r in roleUsers) {
+      roles.push({"name":r, "users":Array.from(new Set(roleUsers[r]))});
+    }
+    return roles.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  function roleMatch(roles, rolesForUser) {
+    for (r in rolesForUser) {
+      if (roles.includes(rolesForUser[r]))
+        return true;
+    }
+    return false;
+  }
+
+  function permRow(perm, i) {
+    var roles = asList(perm.role);
+    var paths = asList(perm.path);
+
+    var collectionNames = "";
+    var collections = [];
+    if ("collection" in perm) {
+      if (perm["collection"] == null) {
+        collectionNames = "null";
+      } else {
+        collections = asList(perm.collection);
+        collectionNames = collections.sort().join(", ");
+      }
+    } else {
+      // no collection property on the perm, so the default "*" applies
+      collectionNames = "";
+      collections.push("*");
+    }
+
+    var method = asList(perm.method);
+
+    // perms don't always have an index ?!?
+    var index = "index" in perm ? perm["index"] : ""+i;
+
+    return { "index": index, "name": perm.name, "collectionNames": collectionNames, "collections": collections,
+      "roles": roles, "paths": paths, "method": method, "params": perm.params };
+  }
+
+  function checkError(data) {
+    var cause = null;
+    if ("errorMessages" in data && Array.isArray(data["errorMessages"]) && data["errorMessages"].length > 0) {
+      cause = "?";
+      if ("errorMessages" in data["errorMessages"][0] && Array.isArray(data["errorMessages"][0]["errorMessages"]) && data["errorMessages"][0]["errorMessages"].length > 0) {
+        cause = data["errorMessages"][0]["errorMessages"][0];
+      }
+    }
+    return cause;
+  }
+
+  function truncateTo(str, maxLen, delim) {
+    // allow for a little on either side of maxLen for better display
+    var varLen = Math.min(Math.round(maxLen * 0.1), 15);
+    if (str.length <= maxLen + varLen) {
+      return str;
+    }
+
+    var total = str.split(delim).length;
+    var at = str.indexOf(delim, maxLen - varLen);
+    str = (at !== -1 && at < maxLen + varLen) ? str.substring(0, at) : str.substring(0, maxLen);
+    var trimmed = str.split(delim).length;
+    var diff = total - trimmed;
+    str += " ... "+(diff > 1 ? "(+"+diff+" more)" : "");
+    return str;
+  }
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.securityAPIError;
+    delete $scope.securityAPIErrorDetails;
+  };
+
+  $scope.displayList = function(listOrStr) {
+    if (!listOrStr) return "";
+    var str = Array.isArray(listOrStr) ? listOrStr.sort().join(", ") : (""+listOrStr).trim();
+    return truncateTo(str, 160, ", ");
+  };
+
+  $scope.displayParams = function(obj) {
+    if (!obj) return "";
+    if (Array.isArray(obj)) return obj.sort().join(", ");
+
+    var display = "";
+    for (const [key, value] of Object.entries(obj)) {
+      if (display.length > 0) display += "; ";
+      display += (key + "=" + (Array.isArray(value)?value.sort().join(","):value+""));
+    }
+    return truncateTo(display, 160, "; ");
+  };
+
+  $scope.displayRoles = function(obj) {
+    return (!obj || (Array.isArray(obj) && obj.length === 0)) ? "null" : $scope.displayList(obj);
+  };
+
+  $scope.predefinedPermissions = ["collection-admin-edit", "collection-admin-read", "core-admin-read", "core-admin-edit", "zk-read",
+    "read", "update", "all", "config-edit", "config-read", "schema-read", "schema-edit", "security-edit", "security-read",
+    "metrics-read", "filestore-read", "filestore-write", "package-edit", "package-read"].sort();
+
+  $scope.predefinedPermissionCollection = {"read":"*", "update":"*", "config-edit":"*", "config-read":"*", "schema-edit":"*", "schema-read":"*"};
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error && error.msg) {
+      $scope.securityAPIError = error.msg;
+      $scope.securityAPIErrorDetails = e.data.errorDetails;
+    } else if (e.data && e.data.message) {
+      $scope.securityAPIError = e.data.message;
+      $scope.securityAPIErrorDetails = JSON.stringify(e.data);
+    }
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.hideAll();
+
+    $scope.tls = false;
+    $scope.blockUnknown = "false"; // default setting
+    $scope.realmName = "solr";
+    $scope.forwardCredentials = "false";
+
+    $scope.currentUser = sessionStorage.getItem("auth.username");
+
+    $scope.userFilter = "";
+    $scope.userFilterOption = "";
+    $scope.userFilterText = "";
+    $scope.userFilterOptions = [];
+
+    $scope.permFilter = "";
+    $scope.permFilterOption = "";
+    $scope.permFilterOptions = [];
+    $scope.permFilterTypes = ["", "name", "role", "path", "collection"];
+
+    System.get(function(data) {
+      $scope.tls = data.security ? data.security["tls"] : false;
+      $scope.authenticationPlugin = data.security ? data.security["authenticationPlugin"] : null;
+      $scope.authorizationPlugin = data.security ? data.security["authorizationPlugin"] : null;
+      $scope.myRoles = data.security ? data.security["roles"] : [];
+      $scope.isSecurityAdminEnabled = $scope.authenticationPlugin != null;
+      $scope.isCloudMode = data.mode.match( /solrcloud/i ) != null;
+      $scope.zkHost = $scope.isCloudMode ? data["zkHost"] : "";
+      $scope.solrHome = data["solr_home"];
+      $scope.refreshSecurityPanel();
+    }, function(e) {
+      if (e.status === 401 || e.status === 403) {
+        $scope.isSecurityAdminEnabled = true;
+        $scope.hasSecurityEditPerm = false;
+        $scope.hideAll();
+      }
+    });
+  };
+
+  $scope.hideAll = function () {
+    // add more dialogs here
+    delete $scope.validationError;
+    $scope.showUserDialog = false;
+    $scope.showPermDialog = false;
+    delete $scope.helpId;
+  };
+
+  $scope.getCurrentUserRoles = function() {
+    if ($scope.manageUserRolesEnabled) {
+      return Array.isArray($scope.userRoles[$scope.currentUser]) ? $scope.userRoles[$scope.currentUser] : [$scope.userRoles[$scope.currentUser]];
+    } else {
+      return $scope.myRoles;
+    }
+  };
+
+  $scope.hasPermission = function(permissionName) {
+    var rolesForPermission = $scope.permissionsTable.filter(p => permissionName === p.name).flatMap(p => p.roles);
+    return (rolesForPermission.length > 0 && roleMatch(rolesForPermission, $scope.getCurrentUserRoles()));
+  };
+
+  $scope.refreshSecurityPanel = function() {
+
+    // determine if the authorization plugin supports CRUD permissions
+    $scope.managePermissionsEnabled =
+        ($scope.authorizationPlugin === "org.apache.solr.security.RuleBasedAuthorizationPlugin" ||
+            $scope.authorizationPlugin === "org.apache.solr.security.ExternalRoleRuleBasedAuthorizationPlugin");
+
+    // don't allow CRUD on roles if using external
+    $scope.manageUserRolesEnabled = $scope.authorizationPlugin === "org.apache.solr.security.RuleBasedAuthorizationPlugin";
+
+    Security.get({path: "authorization"}, function (data) {
+      if (!data.authorization) {
+        $scope.isSecurityAdminEnabled = false;
+        $scope.hasSecurityEditPerm = false;
+        return;
+      }
+
+      if ($scope.manageUserRolesEnabled) {
+        $scope.userRoles = data.authorization["user-role"];
+        $scope.roles = transposeUserRoles($scope.userRoles);
+        $scope.filteredRoles = $scope.roles;
+        $scope.roleNames = $scope.roles.map(r => r.name).sort();
+        $scope.roleNamesWithWildcard = ["*"].concat($scope.roleNames);
+        if (!$scope.permFilterTypes.includes("user")) {
+          $scope.permFilterTypes.push("user"); // can only filter perms by user if we have a role to user mapping
+        }
+      } else {
+        $scope.userRoles = {};
+        $scope.roles = [];
+        $scope.filteredRoles = [];
+        $scope.roleNames = [];
+      }
+
+      $scope.permissions = data.authorization["permissions"];
+      $scope.permissionsTable = [];
+      for (p in $scope.permissions) {
+        $scope.permissionsTable.push(permRow($scope.permissions[p], parseInt(p)+1));
+      }
+      $scope.filteredPerms = $scope.permissionsTable;
+
+      $scope.hasSecurityEditPerm = $scope.hasPermission("security-edit");
+      $scope.hasSecurityReadPerm = $scope.hasSecurityEditPerm || $scope.hasPermission("security-read");
+
+      if ($scope.authenticationPlugin === "org.apache.solr.security.BasicAuthPlugin") {
+        $scope.manageUsersEnabled = true;
+
+        Security.get({path: "authentication"}, function (data) {
+          if (!data.authentication) {
+            // TODO: error msg
+            $scope.manageUsersEnabled = false;
+          }
+
+          $scope.blockUnknown = data.authentication["blockUnknown"] === true ? "true" : "false";
+          $scope.forwardCredentials = data.authentication["forwardCredentials"] === true ? "true" : "false";
+
+          if ("realm" in data.authentication) {
+            $scope.realmName = data.authentication["realm"];

Review comment:
       *W069:*  ['realm'] is better written in dot notation.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/security.js
##########
@@ -0,0 +1,1124 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cookies, $window, Constants, System, Security) {
+  $scope.resetMenu("security", Constants.IS_ROOT_PAGE);
+
+  $scope.params = [];
+
+  var strongPasswordRegex = /^(?=.*[0-9])(?=.*[!@#$%^&*\-_()[\]])[a-zA-Z0-9!@#$%^&*\-_()[\]]{8,30}$/;
+
+  function toList(str) {
+    if (Array.isArray(str)) {
+      return str; // already a list
+    }
+    return str.trim().split(",").map(s => s.trim()).filter(s => s !== "");
+  }
+
+  function asList(listOrStr) {
+    return Array.isArray(listOrStr) ? listOrStr : (listOrStr ? [listOrStr] : []);
+  }
+
+  function transposeUserRoles(userRoles) {
+    var roleUsers = {};
+    for (var u in userRoles) {
+      var roleList = asList(userRoles[u]);
+      for (var i in roleList) {
+        var role = roleList[i];
+        if (!roleUsers[role]) roleUsers[role] = []
+        roleUsers[role].push(u);
+      }
+    }
+
+    var roles = [];
+    for (var r in roleUsers) {
+      roles.push({"name":r, "users":Array.from(new Set(roleUsers[r]))});
+    }
+    return roles.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  function roleMatch(roles, rolesForUser) {
+    for (r in rolesForUser) {
+      if (roles.includes(rolesForUser[r]))
+        return true;
+    }
+    return false;
+  }
+
+  function permRow(perm, i) {
+    var roles = asList(perm.role);
+    var paths = asList(perm.path);
+
+    var collectionNames = "";
+    var collections = [];
+    if ("collection" in perm) {
+      if (perm["collection"] == null) {
+        collectionNames = "null";
+      } else {
+        collections = asList(perm.collection);
+        collectionNames = collections.sort().join(", ");
+      }
+    } else {
+      // no collection property on the perm, so the default "*" applies
+      collectionNames = "";
+      collections.push("*");
+    }
+
+    var method = asList(perm.method);
+
+    // perms don't always have an index ?!?
+    var index = "index" in perm ? perm["index"] : ""+i;
+
+    return { "index": index, "name": perm.name, "collectionNames": collectionNames, "collections": collections,
+      "roles": roles, "paths": paths, "method": method, "params": perm.params };
+  }
+
+  function checkError(data) {
+    var cause = null;
+    if ("errorMessages" in data && Array.isArray(data["errorMessages"]) && data["errorMessages"].length > 0) {
+      cause = "?";
+      if ("errorMessages" in data["errorMessages"][0] && Array.isArray(data["errorMessages"][0]["errorMessages"]) && data["errorMessages"][0]["errorMessages"].length > 0) {
+        cause = data["errorMessages"][0]["errorMessages"][0];
+      }
+    }
+    return cause;
+  }
+
+  function truncateTo(str, maxLen, delim) {
+    // allow for a little on either side of maxLen for better display
+    var varLen = Math.min(Math.round(maxLen * 0.1), 15);
+    if (str.length <= maxLen + varLen) {
+      return str;
+    }
+
+    var total = str.split(delim).length;
+    var at = str.indexOf(delim, maxLen - varLen);
+    str = (at !== -1 && at < maxLen + varLen) ? str.substring(0, at) : str.substring(0, maxLen);
+    var trimmed = str.split(delim).length;
+    var diff = total - trimmed;
+    str += " ... "+(diff > 1 ? "(+"+diff+" more)" : "");
+    return str;
+  }
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.securityAPIError;
+    delete $scope.securityAPIErrorDetails;
+  };
+
+  $scope.displayList = function(listOrStr) {
+    if (!listOrStr) return "";
+    var str = Array.isArray(listOrStr) ? listOrStr.sort().join(", ") : (""+listOrStr).trim();
+    return truncateTo(str, 160, ", ");
+  };
+
+  $scope.displayParams = function(obj) {
+    if (!obj) return "";
+    if (Array.isArray(obj)) return obj.sort().join(", ");
+
+    var display = "";
+    for (const [key, value] of Object.entries(obj)) {
+      if (display.length > 0) display += "; ";
+      display += (key + "=" + (Array.isArray(value)?value.sort().join(","):value+""));
+    }
+    return truncateTo(display, 160, "; ");
+  };
+
+  $scope.displayRoles = function(obj) {
+    return (!obj || (Array.isArray(obj) && obj.length === 0)) ? "null" : $scope.displayList(obj);
+  };
+
+  $scope.predefinedPermissions = ["collection-admin-edit", "collection-admin-read", "core-admin-read", "core-admin-edit", "zk-read",
+    "read", "update", "all", "config-edit", "config-read", "schema-read", "schema-edit", "security-edit", "security-read",
+    "metrics-read", "filestore-read", "filestore-write", "package-edit", "package-read"].sort();
+
+  $scope.predefinedPermissionCollection = {"read":"*", "update":"*", "config-edit":"*", "config-read":"*", "schema-edit":"*", "schema-read":"*"};
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error && error.msg) {
+      $scope.securityAPIError = error.msg;
+      $scope.securityAPIErrorDetails = e.data.errorDetails;
+    } else if (e.data && e.data.message) {
+      $scope.securityAPIError = e.data.message;
+      $scope.securityAPIErrorDetails = JSON.stringify(e.data);
+    }
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.hideAll();
+
+    $scope.tls = false;
+    $scope.blockUnknown = "false"; // default setting
+    $scope.realmName = "solr";
+    $scope.forwardCredentials = "false";
+
+    $scope.currentUser = sessionStorage.getItem("auth.username");
+
+    $scope.userFilter = "";
+    $scope.userFilterOption = "";
+    $scope.userFilterText = "";
+    $scope.userFilterOptions = [];
+
+    $scope.permFilter = "";
+    $scope.permFilterOption = "";
+    $scope.permFilterOptions = [];
+    $scope.permFilterTypes = ["", "name", "role", "path", "collection"];
+
+    System.get(function(data) {
+      $scope.tls = data.security ? data.security["tls"] : false;
+      $scope.authenticationPlugin = data.security ? data.security["authenticationPlugin"] : null;
+      $scope.authorizationPlugin = data.security ? data.security["authorizationPlugin"] : null;
+      $scope.myRoles = data.security ? data.security["roles"] : [];

Review comment:
       *W069:*  ['roles'] is better written in dot notation.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/security.js
##########
@@ -0,0 +1,1124 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cookies, $window, Constants, System, Security) {
+  $scope.resetMenu("security", Constants.IS_ROOT_PAGE);
+
+  $scope.params = [];
+
+  var strongPasswordRegex = /^(?=.*[0-9])(?=.*[!@#$%^&*\-_()[\]])[a-zA-Z0-9!@#$%^&*\-_()[\]]{8,30}$/;
+
+  function toList(str) {
+    if (Array.isArray(str)) {
+      return str; // already a list
+    }
+    return str.trim().split(",").map(s => s.trim()).filter(s => s !== "");
+  }
+
+  function asList(listOrStr) {
+    return Array.isArray(listOrStr) ? listOrStr : (listOrStr ? [listOrStr] : []);
+  }
+
+  function transposeUserRoles(userRoles) {
+    var roleUsers = {};
+    for (var u in userRoles) {
+      var roleList = asList(userRoles[u]);
+      for (var i in roleList) {
+        var role = roleList[i];
+        if (!roleUsers[role]) roleUsers[role] = []
+        roleUsers[role].push(u);
+      }
+    }
+
+    var roles = [];
+    for (var r in roleUsers) {
+      roles.push({"name":r, "users":Array.from(new Set(roleUsers[r]))});
+    }
+    return roles.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  function roleMatch(roles, rolesForUser) {
+    for (r in rolesForUser) {
+      if (roles.includes(rolesForUser[r]))
+        return true;
+    }
+    return false;
+  }
+
+  function permRow(perm, i) {
+    var roles = asList(perm.role);
+    var paths = asList(perm.path);
+
+    var collectionNames = "";
+    var collections = [];
+    if ("collection" in perm) {
+      if (perm["collection"] == null) {
+        collectionNames = "null";
+      } else {
+        collections = asList(perm.collection);
+        collectionNames = collections.sort().join(", ");
+      }
+    } else {
+      // no collection property on the perm, so the default "*" applies
+      collectionNames = "";
+      collections.push("*");
+    }
+
+    var method = asList(perm.method);
+
+    // perms don't always have an index ?!?
+    var index = "index" in perm ? perm["index"] : ""+i;
+
+    return { "index": index, "name": perm.name, "collectionNames": collectionNames, "collections": collections,
+      "roles": roles, "paths": paths, "method": method, "params": perm.params };
+  }
+
+  function checkError(data) {
+    var cause = null;
+    if ("errorMessages" in data && Array.isArray(data["errorMessages"]) && data["errorMessages"].length > 0) {
+      cause = "?";
+      if ("errorMessages" in data["errorMessages"][0] && Array.isArray(data["errorMessages"][0]["errorMessages"]) && data["errorMessages"][0]["errorMessages"].length > 0) {
+        cause = data["errorMessages"][0]["errorMessages"][0];
+      }
+    }
+    return cause;
+  }
+
+  function truncateTo(str, maxLen, delim) {
+    // allow for a little on either side of maxLen for better display
+    var varLen = Math.min(Math.round(maxLen * 0.1), 15);
+    if (str.length <= maxLen + varLen) {
+      return str;
+    }
+
+    var total = str.split(delim).length;
+    var at = str.indexOf(delim, maxLen - varLen);
+    str = (at !== -1 && at < maxLen + varLen) ? str.substring(0, at) : str.substring(0, maxLen);
+    var trimmed = str.split(delim).length;
+    var diff = total - trimmed;
+    str += " ... "+(diff > 1 ? "(+"+diff+" more)" : "");
+    return str;
+  }
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.securityAPIError;
+    delete $scope.securityAPIErrorDetails;
+  };
+
+  $scope.displayList = function(listOrStr) {
+    if (!listOrStr) return "";
+    var str = Array.isArray(listOrStr) ? listOrStr.sort().join(", ") : (""+listOrStr).trim();
+    return truncateTo(str, 160, ", ");
+  };
+
+  $scope.displayParams = function(obj) {
+    if (!obj) return "";
+    if (Array.isArray(obj)) return obj.sort().join(", ");
+
+    var display = "";
+    for (const [key, value] of Object.entries(obj)) {
+      if (display.length > 0) display += "; ";
+      display += (key + "=" + (Array.isArray(value)?value.sort().join(","):value+""));
+    }
+    return truncateTo(display, 160, "; ");
+  };
+
+  $scope.displayRoles = function(obj) {
+    return (!obj || (Array.isArray(obj) && obj.length === 0)) ? "null" : $scope.displayList(obj);
+  };
+
+  $scope.predefinedPermissions = ["collection-admin-edit", "collection-admin-read", "core-admin-read", "core-admin-edit", "zk-read",
+    "read", "update", "all", "config-edit", "config-read", "schema-read", "schema-edit", "security-edit", "security-read",
+    "metrics-read", "filestore-read", "filestore-write", "package-edit", "package-read"].sort();
+
+  $scope.predefinedPermissionCollection = {"read":"*", "update":"*", "config-edit":"*", "config-read":"*", "schema-edit":"*", "schema-read":"*"};
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error && error.msg) {
+      $scope.securityAPIError = error.msg;
+      $scope.securityAPIErrorDetails = e.data.errorDetails;
+    } else if (e.data && e.data.message) {
+      $scope.securityAPIError = e.data.message;
+      $scope.securityAPIErrorDetails = JSON.stringify(e.data);
+    }
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.hideAll();
+
+    $scope.tls = false;
+    $scope.blockUnknown = "false"; // default setting
+    $scope.realmName = "solr";
+    $scope.forwardCredentials = "false";
+
+    $scope.currentUser = sessionStorage.getItem("auth.username");
+
+    $scope.userFilter = "";
+    $scope.userFilterOption = "";
+    $scope.userFilterText = "";
+    $scope.userFilterOptions = [];
+
+    $scope.permFilter = "";
+    $scope.permFilterOption = "";
+    $scope.permFilterOptions = [];
+    $scope.permFilterTypes = ["", "name", "role", "path", "collection"];
+
+    System.get(function(data) {
+      $scope.tls = data.security ? data.security["tls"] : false;
+      $scope.authenticationPlugin = data.security ? data.security["authenticationPlugin"] : null;
+      $scope.authorizationPlugin = data.security ? data.security["authorizationPlugin"] : null;
+      $scope.myRoles = data.security ? data.security["roles"] : [];
+      $scope.isSecurityAdminEnabled = $scope.authenticationPlugin != null;
+      $scope.isCloudMode = data.mode.match( /solrcloud/i ) != null;
+      $scope.zkHost = $scope.isCloudMode ? data["zkHost"] : "";
+      $scope.solrHome = data["solr_home"];
+      $scope.refreshSecurityPanel();
+    }, function(e) {
+      if (e.status === 401 || e.status === 403) {
+        $scope.isSecurityAdminEnabled = true;
+        $scope.hasSecurityEditPerm = false;
+        $scope.hideAll();
+      }
+    });
+  };
+
+  $scope.hideAll = function () {
+    // add more dialogs here
+    delete $scope.validationError;
+    $scope.showUserDialog = false;
+    $scope.showPermDialog = false;
+    delete $scope.helpId;
+  };
+
+  $scope.getCurrentUserRoles = function() {
+    if ($scope.manageUserRolesEnabled) {
+      return Array.isArray($scope.userRoles[$scope.currentUser]) ? $scope.userRoles[$scope.currentUser] : [$scope.userRoles[$scope.currentUser]];
+    } else {
+      return $scope.myRoles;
+    }
+  };
+
+  $scope.hasPermission = function(permissionName) {
+    var rolesForPermission = $scope.permissionsTable.filter(p => permissionName === p.name).flatMap(p => p.roles);
+    return (rolesForPermission.length > 0 && roleMatch(rolesForPermission, $scope.getCurrentUserRoles()));
+  };
+
+  $scope.refreshSecurityPanel = function() {
+
+    // determine if the authorization plugin supports CRUD permissions
+    $scope.managePermissionsEnabled =
+        ($scope.authorizationPlugin === "org.apache.solr.security.RuleBasedAuthorizationPlugin" ||
+            $scope.authorizationPlugin === "org.apache.solr.security.ExternalRoleRuleBasedAuthorizationPlugin");
+
+    // don't allow CRUD on roles if using external
+    $scope.manageUserRolesEnabled = $scope.authorizationPlugin === "org.apache.solr.security.RuleBasedAuthorizationPlugin";
+
+    Security.get({path: "authorization"}, function (data) {
+      if (!data.authorization) {
+        $scope.isSecurityAdminEnabled = false;
+        $scope.hasSecurityEditPerm = false;
+        return;
+      }
+
+      if ($scope.manageUserRolesEnabled) {
+        $scope.userRoles = data.authorization["user-role"];
+        $scope.roles = transposeUserRoles($scope.userRoles);
+        $scope.filteredRoles = $scope.roles;
+        $scope.roleNames = $scope.roles.map(r => r.name).sort();
+        $scope.roleNamesWithWildcard = ["*"].concat($scope.roleNames);
+        if (!$scope.permFilterTypes.includes("user")) {
+          $scope.permFilterTypes.push("user"); // can only filter perms by user if we have a role to user mapping
+        }
+      } else {
+        $scope.userRoles = {};
+        $scope.roles = [];
+        $scope.filteredRoles = [];
+        $scope.roleNames = [];
+      }
+
+      $scope.permissions = data.authorization["permissions"];
+      $scope.permissionsTable = [];
+      for (p in $scope.permissions) {
+        $scope.permissionsTable.push(permRow($scope.permissions[p], parseInt(p)+1));
+      }
+      $scope.filteredPerms = $scope.permissionsTable;
+
+      $scope.hasSecurityEditPerm = $scope.hasPermission("security-edit");
+      $scope.hasSecurityReadPerm = $scope.hasSecurityEditPerm || $scope.hasPermission("security-read");
+
+      if ($scope.authenticationPlugin === "org.apache.solr.security.BasicAuthPlugin") {
+        $scope.manageUsersEnabled = true;
+
+        Security.get({path: "authentication"}, function (data) {
+          if (!data.authentication) {
+            // TODO: error msg
+            $scope.manageUsersEnabled = false;
+          }
+
+          $scope.blockUnknown = data.authentication["blockUnknown"] === true ? "true" : "false";
+          $scope.forwardCredentials = data.authentication["forwardCredentials"] === true ? "true" : "false";
+
+          if ("realm" in data.authentication) {
+            $scope.realmName = data.authentication["realm"];
+          }
+
+          var users = [];
+          if (data.authentication.credentials) {
+            for (var u in data.authentication.credentials) {
+              var roles = $scope.userRoles[u];
+              if (!roles) roles = [];
+              users.push({"username":u, "roles":roles});
+            }
+          }
+          $scope.users = users.sort((a, b) => (a.username > b.username) ? 1 : -1);
+          $scope.filteredUsers = $scope.users.slice(0,100); // only display first 100
+        }, $scope.errorHandler);
+      } else {
+        $scope.users = [];
+        $scope.filteredUsers = $scope.users;
+        $scope.manageUsersEnabled = false;
+      }
+    }, $scope.errorHandler);
+  };
+
+  $scope.validatePassword = function() {
+    var password = $scope.upsertUser.password.trim();
+    var password2 = $scope.upsertUser.password2 ? $scope.upsertUser.password2.trim() : "";
+    if (password !== password2) {
+      $scope.validationError = "Passwords do not match!";
+      return false;
+    }
+
+    if (!password.match(strongPasswordRegex)) {
+      $scope.validationError = "Password not strong enough! Must contain at least one lowercase letter, one uppercase letter, one digit, and one of these special characters: !@#$%^&*_-[]()";
+      return false;
+    }
+
+    return true;
+  };
+
+  $scope.updateUserRoles = function() {
+    var setUserRoles = {};
+    var roles = [];
+    if ($scope.upsertUser.selectedRoles) {
+      roles = roles.concat($scope.upsertUser.selectedRoles);
+    }
+    if ($scope.upsertUser.newRole && $scope.upsertUser.newRole.trim() !== "") {
+      var newRole = $scope.upsertUser.newRole.trim();
+      if (newRole !== "null" && newRole !== "*" && newRole.length <= 30) {
+        roles.push(newRole);
+      } // else, no new role for you!
+    }
+    var userRoles = Array.from(new Set(roles));
+    setUserRoles[$scope.upsertUser.username] = userRoles.length > 0 ? userRoles : null;
+    Security.post({path: "authorization"}, { "set-user-role": setUserRoles }, function (data) {
+      $scope.toggleUserDialog();
+      $scope.refreshSecurityPanel();
+    });
+  };
+
+  $scope.doUpsertUser = function() {
+    if (!$scope.upsertUser) {
+      delete $scope.validationError;
+      $scope.showUserDialog = false;
+      return;
+    }
+
+    if (!$scope.upsertUser.username || $scope.upsertUser.username.trim() === "") {
+      $scope.validationError = "Username is required!";
+      return;
+    }
+
+    // keep username to a reasonable length? but allow for email addresses
+    var username = $scope.upsertUser.username.trim();
+    if (username.length > 30) {
+      $scope.validationError = "Username must be 30 characters or less!";
+      return;
+    }
+
+    var doSetUser = false;
+    if ($scope.userDialogMode === 'add') {
+      if ($scope.users) {
+        var existing = $scope.users.find(u => u.username === username);
+        if (existing) {
+          $scope.validationError = "User '"+username+"' already exists!";
+          return;
+        }
+      }
+
+      if (!$scope.upsertUser.password) {
+        $scope.validationError = "Password is required!";
+        return;
+      }
+
+      if (!$scope.validatePassword()) {
+        return;
+      }
+      doSetUser = true;
+    } else {
+      if ($scope.upsertUser.password) {
+        if ($scope.validatePassword()) {
+          doSetUser = true;
+        } else {
+          return; // update to password is invalid
+        }
+      } // else no update to password
+    }
+
+    if ($scope.upsertUser.newRole && $scope.upsertUser.newRole.trim() !== "") {
+      var newRole = $scope.upsertUser.newRole.trim();
+      if (newRole === "null" || newRole === "*" || newRole.length > 30) {
+        $scope.validationError = "Invalid new role: "+newRole;
+        return;
+      }
+    }
+
+    delete $scope.validationError;
+
+    if (doSetUser) {
+      var setUserJson = {};
+      setUserJson[username] = $scope.upsertUser.password.trim();
+      Security.post({path: "authentication"}, { "set-user": setUserJson }, function (data) {
+
+        var errorCause = checkError(data);
+        if (errorCause != null) {
+          $scope.securityAPIError = "create user "+username+" failed due to: "+errorCause;
+          $scope.securityAPIErrorDetails = JSON.stringify(data);
+          return;
+        }
+
+        $scope.updateUserRoles();
+      });
+    } else {
+      $scope.updateUserRoles();
+    }
+  };
+
+  $scope.confirmDeleteUser = function() {
+    if (window.confirm("Confirm delete the '"+$scope.upsertUser.username+"' user?")) {
+      // remove all roles for the user and the delete the user
+      var removeRoles = {};
+      removeRoles[$scope.upsertUser.username] = null;
+      Security.post({path: "authorization"}, { "set-user-role": removeRoles }, function (data) {
+        Security.post({path: "authentication"}, {"delete-user": [$scope.upsertUser.username]}, function (data2) {
+          $scope.toggleUserDialog();
+          $scope.refreshSecurityPanel();
+        });
+      });
+    }
+  };
+
+  $scope.showAddUserDialog = function() {
+    $scope.userDialogMode = "add";
+    $scope.userDialogHeader = "Add New User";
+    $scope.userDialogAction = "Add User";
+    $scope.upsertUser = {};
+    $scope.toggleUserDialog();
+  };
+
+  $scope.toggleUserDialog = function() {
+    if ($scope.showUserDialog) {
+      delete $scope.upsertUser;
+      delete $scope.validationError;
+      $scope.showUserDialog = false;
+      return;
+    }
+
+    $scope.hideAll();
+    $('#user-dialog').css({left: 132, top: 132});
+    $scope.showUserDialog = true;
+  };
+
+  $scope.onPredefinedChanged = function() {
+    if (!$scope.upsertPerm) {
+      return;
+    }
+
+    if ($scope.upsertPerm.name && $scope.upsertPerm.name.trim() !== "") {
+      delete $scope.selectedPredefinedPermission;
+    } else {
+      $scope.upsertPerm.name = "";
+    }
+
+    if ($scope.selectedPredefinedPermission && $scope.selectedPredefinedPermission in $scope.predefinedPermissionCollection) {
+      $scope.upsertPerm.collection = $scope.predefinedPermissionCollection[$scope.selectedPredefinedPermission];
+    }
+
+    $scope.isPermFieldDisabled = ($scope.upsertPerm.name === "" && $scope.selectedPredefinedPermission);
+  };
+
+  $scope.showAddPermDialog = function() {
+    $scope.permDialogMode = "add";
+    $scope.permDialogHeader = "Add New Permission";
+    $scope.permDialogAction = "Add Permission";
+    $scope.upsertPerm = {};
+    $scope.upsertPerm.name = "";
+    $scope.upsertPerm.index = "";
+    $scope.upsertPerm["method"] = {"get":"true", "post":"true", "put":"true", "delete":"true"};
+    $scope.isPermFieldDisabled = false;
+    delete $scope.selectedPredefinedPermission;
+
+    $scope.params = [{"name":"", "value":""}];
+
+    var permissionNames = $scope.permissions.map(p => p.name);
+    $scope.filteredPredefinedPermissions = $scope.predefinedPermissions.filter(p => !permissionNames.includes(p));
+
+    $scope.togglePermDialog();
+  };
+
+  $scope.togglePermDialog = function() {
+    if ($scope.showPermDialog) {
+      delete $scope.upsertPerm;
+      delete $scope.validationError;
+      $scope.showPermDialog = false;
+      $scope.isPermFieldDisabled = false;
+      delete $scope.selectedPredefinedPermission;
+      return;
+    }
+
+    $scope.hideAll();
+
+    var leftPos = $scope.permDialogMode === "add" ? 500 : 100;
+    var topPos = $('#permissions').offset().top - 320;
+    if (topPos < 0) topPos = 0;
+    $('#add-permission-dialog').css({left: leftPos, top: topPos});
+
+    $scope.showPermDialog = true;
+  };
+
+  $scope.getMethods = function() {
+    var methods = [];
+    if ($scope.upsertPerm.method.get === "true") {
+      methods.push("GET");
+    }
+    if ($scope.upsertPerm.method.put === "true") {
+      methods.push("PUT");
+    }
+    if ($scope.upsertPerm.method.post === "true") {
+      methods.push("POST");
+    }
+    if ($scope.upsertPerm.method.delete === "true") {
+      methods.push("DELETE");
+    }
+    return methods;
+  };
+
+  $scope.confirmDeletePerm = function() {
+    var permName = $scope.selectedPredefinedPermission ? $scope.selectedPredefinedPermission : $scope.upsertPerm.name.trim();
+    if (window.confirm("Confirm delete the '"+permName+"' permission?")) {
+      var index = parseInt($scope.upsertPerm.index);
+      Security.post({path: "authorization"}, { "delete-permission": index }, function (data) {
+        $scope.togglePermDialog();
+        $scope.refreshSecurityPanel();
+      });
+    }
+  };
+
+  $scope.doUpsertPermission = function() {
+    if (!$scope.upsertPerm) {
+      $scope.upsertPerm = {};
+    }
+
+    var isAdd = $scope.permDialogMode === "add";
+    var name = $scope.selectedPredefinedPermission ? $scope.selectedPredefinedPermission : $scope.upsertPerm.name.trim();
+
+    if (isAdd) {
+      if (!name) {
+        $scope.validationError = "Either select a predefined permission or provide a name for a custom permission";
+        return;
+      }
+      var permissionNames = $scope.permissions.map(p => p.name);
+      if (permissionNames.includes(name)) {
+        $scope.validationError = "Permission '"+name+"' already exists!";
+        return;
+      }
+
+      if (name === "*") {
+        $scope.validationError = "Invalid permission name!";
+        return;
+      }
+    }
+
+    var role = null;
+    if ($scope.manageUserRolesEnabled) {
+      role = $scope.upsertPerm.selectedRoles;
+      if (!role || role.length === 0) {
+        role = null;
+      } else if (role.includes("*")) {
+        role = ["*"];
+      }
+    } else if ($scope.upsertPerm.manualRoles && $scope.upsertPerm.manualRoles.trim() !== "") {
+      var manualRoles = $scope.upsertPerm.manualRoles.trim();
+      role = (manualRoles === "null") ? null : toList(manualRoles);
+    }
+
+    var setPermJson = {"name": name, "role": role };
+
+    if ($scope.selectedPredefinedPermission) {
+      $scope.params = [{"name":"","value":""}];
+    } else {
+      // collection
+      var coll = null;
+      if ($scope.upsertPerm.collection != null && $scope.upsertPerm.collection !== "null") {
+        if ($scope.upsertPerm.collection === "*") {
+          coll = "*";
+        } else {
+          coll = $scope.upsertPerm.collection && $scope.upsertPerm.collection.trim() !== "" ? toList($scope.upsertPerm.collection) : "";
+        }
+      }
+      setPermJson["collection"] = coll;
+
+      // path
+      if (!$scope.upsertPerm.path || (Array.isArray($scope.upsertPerm.path) && $scope.upsertPerm.path.length === 0)) {
+        $scope.validationError = "Path is required for custom permissions!";
+        return;
+      }
+
+      setPermJson["path"] = toList($scope.upsertPerm.path);
+
+      if ($scope.upsertPerm.method) {
+        var methods = $scope.getMethods();
+        if (methods.length === 0) {
+          $scope.validationError = "Must specify at least one request method for a custom permission!";
+          return;
+        }
+
+        if (methods.length < 4) {
+          setPermJson["method"] = methods;
+        } // else no need to specify, rule applies to all methods
+      }
+
+      // params
+      var params = {};
+      if ($scope.params && $scope.params.length > 0) {
+        for (i in $scope.params) {
+          var p = $scope.params[i];
+          var name = p.name.trim();
+          if (name !== "" && p.value) {
+            if (name in params) {
+              params[name].push(p.value);
+            } else {
+              params[name] = [p.value];
+            }
+          }
+        }
+      }
+      setPermJson["params"] = params;
+    }
+
+    var indexUpdated = false;
+    if ($scope.upsertPerm.index) {
+      var indexOrBefore = isAdd ? "before" : "index";
+      var indexInt = parseInt($scope.upsertPerm.index);
+      if (indexInt < 1) indexInt = 1;
+      if (indexInt >= $scope.permissions.length) indexInt = null;
+      if (indexInt != null) {
+        setPermJson[indexOrBefore] = indexInt;
+      }
+      indexUpdated = (!isAdd && indexInt !== parseInt($scope.upsertPerm.originalIndex));
+    }
+
+    if (indexUpdated) {
+      // changing position is a delete + re-add in new position
+      Security.post({path: "authorization"}, { "delete-permission": parseInt($scope.upsertPerm.originalIndex) }, function (remData) {
+        if (setPermJson.index) {
+          var before = setPermJson.index;
+          delete setPermJson.index;
+          setPermJson["before"] = before;
+        }
+
+        // add perm back in new position
+        Security.post({path: "authorization"}, { "set-permission": setPermJson }, function (data) {
+          var errorCause = checkError(data);
+          if (errorCause != null) {
+            $scope.securityAPIError = "set-permission "+name+" failed due to: "+errorCause;
+            $scope.securityAPIErrorDetails = JSON.stringify(data);
+            return;
+          }
+          $scope.togglePermDialog();
+          $scope.refreshSecurityPanel();
+        });
+      });
+    } else {
+      var action = isAdd ? "set-permission" : "update-permission";
+      var postBody = {};
+      postBody[action] = setPermJson;
+      Security.post({path: "authorization"}, postBody, function (data) {
+        var errorCause = checkError(data);
+        if (errorCause != null) {
+          $scope.securityAPIError = action+" "+name+" failed due to: "+errorCause;
+          $scope.securityAPIErrorDetails = JSON.stringify(data);
+          return;
+        }
+
+        $scope.togglePermDialog();
+        $scope.refreshSecurityPanel();
+      });
+    }
+  };
+
+  $scope.applyUserFilter = function() {
+    $scope.userFilterText = "";
+    $scope.userFilterOption = "";
+    $scope.userFilterOptions = [];
+    $scope.filteredUsers = $scope.users; // reset the filtered when the filter type changes
+
+    if ($scope.userFilter === "name" || $scope.userFilter === "path") {
+      // no-op: filter is text input
+    } else if ($scope.userFilter === "role") {
+      $scope.userFilterOptions = $scope.roleNames;
+    } else if ($scope.userFilter === "perm") {
+      $scope.userFilterOptions = $scope.permissions.map(p => p.name).sort();
+    } else {
+      $scope.userFilter = "";
+    }
+  };
+
+  $scope.onUserFilterTextChanged = function() {
+    // don't fire until we have at least 2 chars ...
+    if ($scope.userFilterText && $scope.userFilterText.trim().length >= 2) {
+      $scope.userFilterOption = $scope.userFilterText.toLowerCase();
+      $scope.onUserFilterOptionChanged();
+    } else {
+      $scope.filteredUsers = $scope.users;
+    }
+  };
+
+  function pathMatch(paths, filter) {
+    for (p in paths) {
+      if (paths[p].includes(filter)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  $scope.onUserFilterOptionChanged = function() {
+    var filter = $scope.userFilterOption ? $scope.userFilterOption.trim() : "";
+    if (filter.length === 0) {
+      $scope.filteredUsers = $scope.users;
+      return;
+    }
+
+    if ($scope.userFilter === "name") {
+      $scope.filteredUsers = $scope.users.filter(u => u.username.toLowerCase().includes(filter));
+    } else if ($scope.userFilter === "role") {
+      $scope.filteredUsers = $scope.users.filter(u => u.roles.includes(filter));
+    } else if ($scope.userFilter === "path") {
+      var rolesForPath = Array.from(new Set($scope.permissionsTable.filter(p => p.roles && pathMatch(p.paths, filter)).flatMap(p => p.roles)));
+      var usersForPath = Array.from(new Set($scope.roles.filter(r => r.users && r.users.length > 0 && rolesForPath.includes(r.name)).flatMap(r => r.users)));
+      $scope.filteredUsers = $scope.users.filter(u => usersForPath.includes(u.username));
+    } else if ($scope.userFilter === "perm") {
+      var rolesForPerm = Array.from(new Set($scope.permissionsTable.filter(p => p.name === filter).flatMap(p => p.roles)));
+      var usersForPerm = Array.from(new Set($scope.roles.filter(r => r.users && r.users.length > 0 && rolesForPerm.includes(r.name)).flatMap(r => r.users)));
+      $scope.filteredUsers = $scope.users.filter(u => usersForPerm.includes(u.username));
+    } else {
+      // reset
+      $scope.userFilter = "";
+      $scope.userFilterOption = "";
+      $scope.userFilterText = "";
+      $scope.filteredUsers = $scope.users;
+    }
+  };
+
+  $scope.applyPermFilter = function() {
+    $scope.permFilterText = "";
+    $scope.permFilterOption = "";
+    $scope.permFilterOptions = [];
+    $scope.filteredPerms = $scope.permissionsTable;
+
+    if ($scope.permFilter === "name" || $scope.permFilter === "path") {
+      // no-op: filter is text input
+    } else if ($scope.permFilter === "role") {
+      var roles = $scope.manageUserRolesEnabled ? $scope.roleNames : Array.from(new Set($scope.permissionsTable.flatMap(p => p.roles))).sort();
+      $scope.permFilterOptions = ["*", "null"].concat(roles);
+    } else if ($scope.permFilter === "user") {
+      $scope.permFilterOptions = Array.from(new Set($scope.roles.flatMap(r => r.users))).sort();
+    } else if ($scope.permFilter === "collection") {
+      $scope.permFilterOptions = Array.from(new Set($scope.permissionsTable.flatMap(p => p.collections))).sort();
+      $scope.permFilterOptions.push("null");
+    } else {
+      // no perm filtering
+      $scope.permFilter = "";
+    }
+  };
+
+  $scope.onPermFilterTextChanged = function() {
+    // don't fire until we have at least 2 chars ...
+    if ($scope.permFilterText && $scope.permFilterText.trim().length >= 2) {
+      $scope.permFilterOption = $scope.permFilterText.trim().toLowerCase();
+      $scope.onPermFilterOptionChanged();
+    } else {
+      $scope.filteredPerms = $scope.permissionsTable;
+    }
+  };
+
+  $scope.onPermFilterOptionChanged = function() {
+    var filterCriteria = $scope.permFilterOption ? $scope.permFilterOption.trim() : "";
+    if (filterCriteria.length === 0) {
+      $scope.filteredPerms = $scope.permissionsTable;
+      return;
+    }
+
+    if ($scope.permFilter === "name") {
+      $scope.filteredPerms = $scope.permissionsTable.filter(p => p.name.toLowerCase().includes(filterCriteria));
+    } else if ($scope.permFilter === "role") {
+      if (filterCriteria === "null") {
+        $scope.filteredPerms = $scope.permissionsTable.filter(p => p.roles.length === 0);
+      } else {
+        $scope.filteredPerms = $scope.permissionsTable.filter(p => p.roles.includes(filterCriteria));
+      }
+    } else if ($scope.permFilter === "path") {
+      $scope.filteredPerms = $scope.permissionsTable.filter(p => pathMatch(p.paths, filterCriteria));
+    } else if ($scope.permFilter === "user") {
+      // get the user's roles and then find all the permissions mapped to each role
+      var rolesForUser = $scope.roles.filter(r => r.users.includes(filterCriteria)).map(r => r.name);
+      $scope.filteredPerms = $scope.permissionsTable.filter(p => p.roles.length > 0 && roleMatch(p.roles, rolesForUser));
+    } else if ($scope.permFilter === "collection") {
+      function collectionMatch(collNames, colls, filter) {
+        return (filter === "null") ?collNames === "null" : colls.includes(filter);
+      }
+      $scope.filteredPerms = $scope.permissionsTable.filter(p => collectionMatch(p.collectionNames, p.collections, filterCriteria));
+    } else {
+      // reset
+      $scope.permFilter = "";
+      $scope.permFilterOption = "";
+      $scope.permFilterText = "";
+      $scope.filteredPerms = $scope.permissionsTable;
+    }
+  };
+
+  $scope.editUser = function(row) {
+    if (!row || !$scope.hasSecurityEditPerm) {
+      return;
+    }
+
+    var userId = row.username;
+    $scope.userDialogMode = "edit";
+    $scope.userDialogHeader = "Edit User: "+userId;
+    $scope.userDialogAction = "Update";
+    var userRoles = userId in $scope.userRoles ? $scope.userRoles[userId] : [];
+    if (!Array.isArray(userRoles)) {
+      userRoles = [userRoles];
+    }
+
+    $scope.upsertUser = { username: userId, selectedRoles: userRoles };
+    $scope.toggleUserDialog();
+  };
+
+  function buildMethods(m) {
+    return {"get":""+m.includes("GET"), "post":""+m.includes("POST"), "put":""+m.includes("PUT"), "delete":""+m.includes("DELETE")};
+  }
+
+  $scope.editPerm = function(row) {
+    if (!$scope.managePermissionsEnabled || !$scope.hasSecurityEditPerm || !row) {
+      return;
+    }
+
+    var name = row.name;
+    $scope.permDialogMode = "edit";
+    $scope.permDialogHeader = "Edit Permission: "+name;
+    $scope.permDialogAction = "Update";
+
+    var perm = $scope.permissionsTable.find(p => p.name === name);
+    var isPredefined = $scope.predefinedPermissions.includes(name);
+    if (isPredefined) {
+      $scope.selectedPredefinedPermission = name;
+      $scope.upsertPerm = { };
+      $scope.filteredPredefinedPermissions = [];
+      $scope.filteredPredefinedPermissions.push(name);
+      if ($scope.selectedPredefinedPermission && $scope.selectedPredefinedPermission in $scope.predefinedPermissionCollection) {
+        $scope.upsertPerm.collection = $scope.predefinedPermissionCollection[$scope.selectedPredefinedPermission];
+      }
+      $scope.isPermFieldDisabled = true;
+    } else {
+      $scope.upsertPerm = { name: name, collection: perm.collectionNames, path: perm.paths };
+      $scope.params = [];
+      if (perm.params) {
+        for (const [key, value] of Object.entries(perm.params)) {
+          if (Array.isArray(value)) {
+            for (i in value) {
+              $scope.params.push({"name":key, "value":value[i]});
+            }
+          } else {
+            $scope.params.push({"name":key, "value":value});
+          }
+        }
+      }
+      if ($scope.params.length === 0) {
+        $scope.params = [{"name":"","value":""}];
+      }
+
+      $scope.upsertPerm["method"] = perm.method.length === 0 ? {"get":"true", "post":"true", "put":"true", "delete":"true"} : buildMethods(perm.method);
+      $scope.isPermFieldDisabled = false;
+      delete $scope.selectedPredefinedPermission;
+    }
+
+    $scope.upsertPerm.index = perm["index"];
+    $scope.upsertPerm.originalIndex = perm["index"];
+
+    // roles depending on authz plugin support
+    if ($scope.manageUserRolesEnabled) {
+      $scope.upsertPerm["selectedRoles"] = asList(perm.roles);

Review comment:
       *W069:*  ['selectedRoles'] is better written in dot notation.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/security.js
##########
@@ -0,0 +1,1124 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cookies, $window, Constants, System, Security) {
+  $scope.resetMenu("security", Constants.IS_ROOT_PAGE);
+
+  $scope.params = [];
+
+  var strongPasswordRegex = /^(?=.*[0-9])(?=.*[!@#$%^&*\-_()[\]])[a-zA-Z0-9!@#$%^&*\-_()[\]]{8,30}$/;
+
+  function toList(str) {
+    if (Array.isArray(str)) {
+      return str; // already a list
+    }
+    return str.trim().split(",").map(s => s.trim()).filter(s => s !== "");
+  }
+
+  function asList(listOrStr) {
+    return Array.isArray(listOrStr) ? listOrStr : (listOrStr ? [listOrStr] : []);
+  }
+
+  function transposeUserRoles(userRoles) {
+    var roleUsers = {};
+    for (var u in userRoles) {
+      var roleList = asList(userRoles[u]);
+      for (var i in roleList) {
+        var role = roleList[i];
+        if (!roleUsers[role]) roleUsers[role] = []
+        roleUsers[role].push(u);
+      }
+    }
+
+    var roles = [];
+    for (var r in roleUsers) {
+      roles.push({"name":r, "users":Array.from(new Set(roleUsers[r]))});
+    }
+    return roles.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  function roleMatch(roles, rolesForUser) {
+    for (r in rolesForUser) {
+      if (roles.includes(rolesForUser[r]))
+        return true;
+    }
+    return false;
+  }
+
+  function permRow(perm, i) {
+    var roles = asList(perm.role);
+    var paths = asList(perm.path);
+
+    var collectionNames = "";
+    var collections = [];
+    if ("collection" in perm) {
+      if (perm["collection"] == null) {
+        collectionNames = "null";
+      } else {
+        collections = asList(perm.collection);
+        collectionNames = collections.sort().join(", ");
+      }
+    } else {
+      // no collection property on the perm, so the default "*" applies
+      collectionNames = "";
+      collections.push("*");
+    }
+
+    var method = asList(perm.method);
+
+    // perms don't always have an index ?!?
+    var index = "index" in perm ? perm["index"] : ""+i;
+
+    return { "index": index, "name": perm.name, "collectionNames": collectionNames, "collections": collections,
+      "roles": roles, "paths": paths, "method": method, "params": perm.params };
+  }
+
+  function checkError(data) {
+    var cause = null;
+    if ("errorMessages" in data && Array.isArray(data["errorMessages"]) && data["errorMessages"].length > 0) {
+      cause = "?";
+      if ("errorMessages" in data["errorMessages"][0] && Array.isArray(data["errorMessages"][0]["errorMessages"]) && data["errorMessages"][0]["errorMessages"].length > 0) {
+        cause = data["errorMessages"][0]["errorMessages"][0];
+      }
+    }
+    return cause;
+  }
+
+  function truncateTo(str, maxLen, delim) {
+    // allow for a little on either side of maxLen for better display
+    var varLen = Math.min(Math.round(maxLen * 0.1), 15);
+    if (str.length <= maxLen + varLen) {
+      return str;
+    }
+
+    var total = str.split(delim).length;
+    var at = str.indexOf(delim, maxLen - varLen);
+    str = (at !== -1 && at < maxLen + varLen) ? str.substring(0, at) : str.substring(0, maxLen);
+    var trimmed = str.split(delim).length;
+    var diff = total - trimmed;
+    str += " ... "+(diff > 1 ? "(+"+diff+" more)" : "");
+    return str;
+  }
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.securityAPIError;
+    delete $scope.securityAPIErrorDetails;
+  };
+
+  $scope.displayList = function(listOrStr) {
+    if (!listOrStr) return "";
+    var str = Array.isArray(listOrStr) ? listOrStr.sort().join(", ") : (""+listOrStr).trim();
+    return truncateTo(str, 160, ", ");
+  };
+
+  $scope.displayParams = function(obj) {
+    if (!obj) return "";
+    if (Array.isArray(obj)) return obj.sort().join(", ");
+
+    var display = "";
+    for (const [key, value] of Object.entries(obj)) {
+      if (display.length > 0) display += "; ";
+      display += (key + "=" + (Array.isArray(value)?value.sort().join(","):value+""));
+    }
+    return truncateTo(display, 160, "; ");
+  };
+
+  $scope.displayRoles = function(obj) {
+    return (!obj || (Array.isArray(obj) && obj.length === 0)) ? "null" : $scope.displayList(obj);
+  };
+
+  $scope.predefinedPermissions = ["collection-admin-edit", "collection-admin-read", "core-admin-read", "core-admin-edit", "zk-read",
+    "read", "update", "all", "config-edit", "config-read", "schema-read", "schema-edit", "security-edit", "security-read",
+    "metrics-read", "filestore-read", "filestore-write", "package-edit", "package-read"].sort();
+
+  $scope.predefinedPermissionCollection = {"read":"*", "update":"*", "config-edit":"*", "config-read":"*", "schema-edit":"*", "schema-read":"*"};
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error && error.msg) {
+      $scope.securityAPIError = error.msg;
+      $scope.securityAPIErrorDetails = e.data.errorDetails;
+    } else if (e.data && e.data.message) {
+      $scope.securityAPIError = e.data.message;
+      $scope.securityAPIErrorDetails = JSON.stringify(e.data);
+    }
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.hideAll();
+
+    $scope.tls = false;
+    $scope.blockUnknown = "false"; // default setting
+    $scope.realmName = "solr";
+    $scope.forwardCredentials = "false";
+
+    $scope.currentUser = sessionStorage.getItem("auth.username");
+
+    $scope.userFilter = "";
+    $scope.userFilterOption = "";
+    $scope.userFilterText = "";
+    $scope.userFilterOptions = [];
+
+    $scope.permFilter = "";
+    $scope.permFilterOption = "";
+    $scope.permFilterOptions = [];
+    $scope.permFilterTypes = ["", "name", "role", "path", "collection"];
+
+    System.get(function(data) {
+      $scope.tls = data.security ? data.security["tls"] : false;
+      $scope.authenticationPlugin = data.security ? data.security["authenticationPlugin"] : null;
+      $scope.authorizationPlugin = data.security ? data.security["authorizationPlugin"] : null;
+      $scope.myRoles = data.security ? data.security["roles"] : [];
+      $scope.isSecurityAdminEnabled = $scope.authenticationPlugin != null;
+      $scope.isCloudMode = data.mode.match( /solrcloud/i ) != null;
+      $scope.zkHost = $scope.isCloudMode ? data["zkHost"] : "";
+      $scope.solrHome = data["solr_home"];

Review comment:
       *W069:*  ['solr_home'] is better written in dot notation.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)

##########
File path: solr/webapp/web/js/angular/controllers/security.js
##########
@@ -0,0 +1,1124 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements.  See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License.  You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+*/
+
+solrAdminApp.controller('SecurityController', function ($scope, $timeout, $cookies, $window, Constants, System, Security) {
+  $scope.resetMenu("security", Constants.IS_ROOT_PAGE);
+
+  $scope.params = [];
+
+  var strongPasswordRegex = /^(?=.*[0-9])(?=.*[!@#$%^&*\-_()[\]])[a-zA-Z0-9!@#$%^&*\-_()[\]]{8,30}$/;
+
+  function toList(str) {
+    if (Array.isArray(str)) {
+      return str; // already a list
+    }
+    return str.trim().split(",").map(s => s.trim()).filter(s => s !== "");
+  }
+
+  function asList(listOrStr) {
+    return Array.isArray(listOrStr) ? listOrStr : (listOrStr ? [listOrStr] : []);
+  }
+
+  function transposeUserRoles(userRoles) {
+    var roleUsers = {};
+    for (var u in userRoles) {
+      var roleList = asList(userRoles[u]);
+      for (var i in roleList) {
+        var role = roleList[i];
+        if (!roleUsers[role]) roleUsers[role] = []
+        roleUsers[role].push(u);
+      }
+    }
+
+    var roles = [];
+    for (var r in roleUsers) {
+      roles.push({"name":r, "users":Array.from(new Set(roleUsers[r]))});
+    }
+    return roles.sort((a, b) => (a.name > b.name) ? 1 : -1);
+  }
+
+  function roleMatch(roles, rolesForUser) {
+    for (r in rolesForUser) {
+      if (roles.includes(rolesForUser[r]))
+        return true;
+    }
+    return false;
+  }
+
+  function permRow(perm, i) {
+    var roles = asList(perm.role);
+    var paths = asList(perm.path);
+
+    var collectionNames = "";
+    var collections = [];
+    if ("collection" in perm) {
+      if (perm["collection"] == null) {
+        collectionNames = "null";
+      } else {
+        collections = asList(perm.collection);
+        collectionNames = collections.sort().join(", ");
+      }
+    } else {
+      // no collection property on the perm, so the default "*" applies
+      collectionNames = "";
+      collections.push("*");
+    }
+
+    var method = asList(perm.method);
+
+    // perms don't always have an index ?!?
+    var index = "index" in perm ? perm["index"] : ""+i;
+
+    return { "index": index, "name": perm.name, "collectionNames": collectionNames, "collections": collections,
+      "roles": roles, "paths": paths, "method": method, "params": perm.params };
+  }
+
+  function checkError(data) {
+    var cause = null;
+    if ("errorMessages" in data && Array.isArray(data["errorMessages"]) && data["errorMessages"].length > 0) {
+      cause = "?";
+      if ("errorMessages" in data["errorMessages"][0] && Array.isArray(data["errorMessages"][0]["errorMessages"]) && data["errorMessages"][0]["errorMessages"].length > 0) {
+        cause = data["errorMessages"][0]["errorMessages"][0];
+      }
+    }
+    return cause;
+  }
+
+  function truncateTo(str, maxLen, delim) {
+    // allow for a little on either side of maxLen for better display
+    var varLen = Math.min(Math.round(maxLen * 0.1), 15);
+    if (str.length <= maxLen + varLen) {
+      return str;
+    }
+
+    var total = str.split(delim).length;
+    var at = str.indexOf(delim, maxLen - varLen);
+    str = (at !== -1 && at < maxLen + varLen) ? str.substring(0, at) : str.substring(0, maxLen);
+    var trimmed = str.split(delim).length;
+    var diff = total - trimmed;
+    str += " ... "+(diff > 1 ? "(+"+diff+" more)" : "");
+    return str;
+  }
+
+  $scope.closeErrorDialog = function () {
+    delete $scope.securityAPIError;
+    delete $scope.securityAPIErrorDetails;
+  };
+
+  $scope.displayList = function(listOrStr) {
+    if (!listOrStr) return "";
+    var str = Array.isArray(listOrStr) ? listOrStr.sort().join(", ") : (""+listOrStr).trim();
+    return truncateTo(str, 160, ", ");
+  };
+
+  $scope.displayParams = function(obj) {
+    if (!obj) return "";
+    if (Array.isArray(obj)) return obj.sort().join(", ");
+
+    var display = "";
+    for (const [key, value] of Object.entries(obj)) {
+      if (display.length > 0) display += "; ";
+      display += (key + "=" + (Array.isArray(value)?value.sort().join(","):value+""));
+    }
+    return truncateTo(display, 160, "; ");
+  };
+
+  $scope.displayRoles = function(obj) {
+    return (!obj || (Array.isArray(obj) && obj.length === 0)) ? "null" : $scope.displayList(obj);
+  };
+
+  $scope.predefinedPermissions = ["collection-admin-edit", "collection-admin-read", "core-admin-read", "core-admin-edit", "zk-read",
+    "read", "update", "all", "config-edit", "config-read", "schema-read", "schema-edit", "security-edit", "security-read",
+    "metrics-read", "filestore-read", "filestore-write", "package-edit", "package-read"].sort();
+
+  $scope.predefinedPermissionCollection = {"read":"*", "update":"*", "config-edit":"*", "config-read":"*", "schema-edit":"*", "schema-read":"*"};
+
+  $scope.errorHandler = function (e) {
+    var error = e.data && e.data.error ? e.data.error : null;
+    if (error && error.msg) {
+      $scope.securityAPIError = error.msg;
+      $scope.securityAPIErrorDetails = e.data.errorDetails;
+    } else if (e.data && e.data.message) {
+      $scope.securityAPIError = e.data.message;
+      $scope.securityAPIErrorDetails = JSON.stringify(e.data);
+    }
+  };
+
+  $scope.showHelp = function (id) {
+    if ($scope.helpId && ($scope.helpId === id || id === '')) {
+      delete $scope.helpId;
+    } else {
+      $scope.helpId = id;
+    }
+  };
+
+  $scope.refresh = function () {
+    $scope.hideAll();
+
+    $scope.tls = false;
+    $scope.blockUnknown = "false"; // default setting
+    $scope.realmName = "solr";
+    $scope.forwardCredentials = "false";
+
+    $scope.currentUser = sessionStorage.getItem("auth.username");
+
+    $scope.userFilter = "";
+    $scope.userFilterOption = "";
+    $scope.userFilterText = "";
+    $scope.userFilterOptions = [];
+
+    $scope.permFilter = "";
+    $scope.permFilterOption = "";
+    $scope.permFilterOptions = [];
+    $scope.permFilterTypes = ["", "name", "role", "path", "collection"];
+
+    System.get(function(data) {
+      $scope.tls = data.security ? data.security["tls"] : false;

Review comment:
       *W069:*  ['tls'] is better written in dot notation.
   (at-me [in a reply](https://help.sonatype.com/lift/talking-to-lift) with `help` or `ignore`)




-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org



---------------------------------------------------------------------
To unsubscribe, e-mail: issues-unsubscribe@solr.apache.org
For additional commands, e-mail: issues-help@solr.apache.org